Oracle Apex主从表单(Master-Detail forms)开发:如何构建发票式主从数据录入界面?
实现发票录入界面:弹窗添加商品+批量保存主/子表数据
这是一个很典型的发票录入场景,我来给你梳理一套完整的实现方案,涵盖前端交互逻辑和后端数据处理:
核心需求回顾
- 主表(
master table)存储发票核心信息(如发票号、客户名称、开票日期等) - 子表(
detail table)存储发票关联的商品明细,仅用网格展示禁止直接在网格内录入 - 通过「添加商品(Add Product)」按钮唤起弹窗表单,录入完成后将商品追加到网格列表
- 所有信息确认无误后,点击「全部保存(SAVE ALL)」一次性提交主表+子表数据到数据库
前端实现思路(以Vue 3为例)
我会用代码示例展示核心交互逻辑,你可以根据自己的技术栈(React/原生JS等)调整:
<template> <div class="invoice-entry-container"> <!-- 主表:发票基本信息表单 --> <section class="master-form-section"> <h3>发票基本信息</h3> <div class="form-group"> <label>发票号:</label> <input v-model="masterForm.invoiceNo" placeholder="请输入发票号" required /> </div> <div class="form-group"> <label>客户名称:</label> <input v-model="masterForm.customerName" placeholder="请输入客户名称" required /> </div> <div class="form-group"> <label>开票日期:</label> <input type="date" v-model="masterForm.invoiceDate" required /> </div> </section> <!-- 子表:商品明细网格 --> <section class="detail-grid-section"> <div class="grid-header"> <h3>商品明细</h3> <button @click="openProductModal" class="add-btn">添加商品(Add Product)</button> </div> <table class="product-grid"> <thead> <tr> <th>商品名称</th> <th>单价</th> <th>数量</th> <th>小计</th> <th>操作</th> </tr> </thead> <tbody> <tr v-for="(item, idx) in tempProductList" :key="idx"> <td>{{ item.productName }}</td> <td>{{ item.unitPrice }}</td> <td>{{ item.quantity }}</td> <td>{{ (item.unitPrice * item.quantity).toFixed(2) }}</td> <td><button @click="removeProduct(idx)" class="del-btn">删除</button></td> </tr> </tbody> </table> </section> <!-- 全局保存按钮 --> <button @click="saveAllData" class="save-all-btn">全部保存(SAVE ALL)</button> <!-- 添加商品弹窗 --> <div class="modal-overlay" v-if="isModalOpen"> <div class="modal-content"> <h4>录入商品信息</h4> <div class="form-group"> <label>商品名称:</label> <input v-model="newProduct.productName" placeholder="请输入商品名称" required /> </div> <div class="form-group"> <label>单价:</label> <input type="number" step="0.01" v-model.number="newProduct.unitPrice" min="0" required /> </div> <div class="form-group"> <label>数量:</label> <input type="number" v-model.number="newProduct.quantity" min="1" required /> </div> <div class="modal-buttons"> <button @click="confirmAddProduct" class="confirm-btn">确认添加</button> <button @click="closeModal" class="cancel-btn">取消</button> </div> </div> </div> </div> </template> <script setup> import { ref } from 'vue' // 主表表单数据 const masterForm = ref({ invoiceNo: '', customerName: '', invoiceDate: new Date().toISOString().split('T')[0] }) // 临时存储商品明细(未提交到数据库) const tempProductList = ref([]) // 弹窗状态与新商品表单数据 const isModalOpen = ref(false) const newProduct = ref({ productName: '', unitPrice: 0, quantity: 1 }) // 打开添加商品弹窗 const openProductModal = () => { isModalOpen.value = true // 重置弹窗表单 newProduct.value = { productName: '', unitPrice: 0, quantity: 1 } } // 关闭弹窗 const closeModal = () => { isModalOpen.value = false } // 将弹窗录入的商品加入临时列表 const confirmAddProduct = () => { // 前端基础验证 if (!newProduct.value.productName || newProduct.value.unitPrice <= 0 || newProduct.value.quantity <= 0) { alert('请填写完整且有效的商品信息') return } tempProductList.value.push({ ...newProduct.value }) closeModal() } // 删除已添加的商品 const removeProduct = (index) => { tempProductList.value.splice(index, 1) } // 全部保存逻辑 const saveAllData = async () => { // 提交前验证 if (!masterForm.value.invoiceNo || !masterForm.value.customerName) { alert('请填写完整的发票基本信息') return } if (tempProductList.value.length === 0) { alert('请至少添加一个商品明细') return } // 组装提交数据 const submitPayload = { master: masterForm.value, details: tempProductList.value } try { // 发起后端请求 const response = await fetch('/api/invoices/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(submitPayload) }) const result = await response.json() if (result.success) { alert('保存成功!') // 重置表单与临时列表 masterForm.value = { invoiceNo: '', customerName: '', invoiceDate: new Date().toISOString().split('T')[0] } tempProductList.value = [] } else { alert('保存失败:' + result.message) } } catch (error) { console.error('保存请求出错:', error) alert('保存出错,请检查网络连接后重试') } } </script> <style scoped> .invoice-entry-container { max-width: 1000px; margin: 20px auto; padding: 20px; } .form-group { margin: 10px 0; } .add-btn, .save-all-btn, .confirm-btn { background: #2196F3; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; } .del-btn, .cancel-btn { background: #f44336; color: white; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; } .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; } .modal-content { background: white; padding: 24px; border-radius: 8px; width: 400px; } .product-grid { width: 100%; border-collapse: collapse; margin-top: 10px; } .product-grid th, .product-grid td { border: 1px solid #ddd; padding: 8px; text-align: left; } </style>
后端实现关键要点(以Node.js + MySQL为例)
后端必须通过数据库事务保证主表和子表数据的一致性,避免出现“主表保存成功但子表保存失败”的异常情况:
const express = require('express'); const mysql = require('mysql2/promise'); const app = express(); app.use(express.json()); // 数据库连接配置 const dbConfig = { host: 'localhost', user: 'your_username', password: 'your_password', database: 'your_db' }; // 保存发票主表+子表接口 app.post('/api/invoices/save', async (req, res) => { const { master, details } = req.body; let connection; try { // 创建数据库连接并开启事务 connection = await mysql.createConnection(dbConfig); await connection.beginTransaction(); // 插入主表数据,获取发票ID const [masterResult] = await connection.execute( 'INSERT INTO invoices (invoice_no, customer_name, invoice_date) VALUES (?, ?, ?)', [master.invoiceNo, master.customerName, master.invoiceDate] ); const invoiceId = masterResult.insertId; // 批量插入子表数据 const detailValues = details.map(item => [ invoiceId, item.productName, item.unitPrice, item.quantity ]); await connection.execute( 'INSERT INTO invoice_details (invoice_id, product_name, unit_price, quantity) VALUES ?', [detailValues] ); // 提交事务 await connection.commit(); res.json({ success: true, message: '发票数据保存成功' }); } catch (error) { // 出错则回滚事务 if (connection) await connection.rollback(); console.error('保存发票数据出错:', error); res.status(500).json({ success: false, message: '保存失败,请重试' }); } finally { // 释放数据库连接 if (connection) await connection.end(); } }); app.listen(3000, () => console.log('Server running on port 3000'));
额外优化建议
- 数据持久化兜底:前端临时商品列表可以用
localStorage存储,避免页面刷新丢失已录入的商品 - 加载状态处理:给「全部保存」按钮添加加载状态,防止用户重复点击
- 更完善的验证:前端可以用表单验证库(如VeeValidate),后端可以用Joi等工具做更细致的数据校验
- 商品编辑功能:可以给网格中的商品添加「编辑」按钮,唤起弹窗修改已录入的商品信息
内容的提问来源于stack exchange,提问作者Mehmet Kahveci




