You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

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'));

额外优化建议

  1. 数据持久化兜底:前端临时商品列表可以用localStorage存储,避免页面刷新丢失已录入的商品
  2. 加载状态处理:给「全部保存」按钮添加加载状态,防止用户重复点击
  3. 更完善的验证:前端可以用表单验证库(如VeeValidate),后端可以用Joi等工具做更细致的数据校验
  4. 商品编辑功能:可以给网格中的商品添加「编辑」按钮,唤起弹窗修改已录入的商品信息

内容的提问来源于stack exchange,提问作者Mehmet Kahveci

火山引擎 最新活动