17 小时以前 b5b12cb8d780bff964b0c7f34e2ccbe5b768bde0
src/views/financialManagement/voucher/index.vue
@@ -9,9 +9,12 @@
      </el-form-item>
      <el-form-item label="制单人:">
        <el-select v-model="filters.creator" placeholder="请选择制单人" clearable style="width: 150px;">
          <el-option label="张三" value="张三" />
          <el-option label="李四" value="李四" />
          <el-option label="王五" value="王五" />
          <el-option
            v-for="item in creatorOptions"
            :key="item"
            :label="item"
            :value="item"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="状态:">
@@ -62,98 +65,192 @@
        </template>
        <template #operation="{ row }">
          <el-button type="primary" link @click="view(row)">查看</el-button>
          <el-button type="primary" link @click="edit(row)" v-if="row.status === 'unposted'">编辑</el-button>
          <el-button type="success" link @click="handlePost(row)" v-if="row.status === 'unposted'">过账</el-button>
          <el-button type="danger" link @click="handleCancel(row)" v-if="row.status === 'unposted'">作废</el-button>
          <el-button type="primary" link @click="edit(row)" v-if="canEditVoucher(row.status)">编辑</el-button>
          <el-button type="success" link @click="handlePost(row)" v-if="canEditVoucher(row.status)">过账</el-button>
          <el-button type="danger" link @click="handleCancel(row)" v-if="canEditVoucher(row.status)">作废</el-button>
        </template>
      </PIMTable>
    </div>
    <el-dialog :title="dialogTitle" v-model="dialogVisible" width="900px" append-to-body>
      <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="凭证字号" prop="voucherNo">
              <el-input v-model="form.voucherNo" placeholder="系统自动生成" disabled />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="凭证日期" prop="voucherDate">
              <el-date-picker v-model="form.voucherDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%;" />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="附件张数" prop="attachmentCount">
              <el-input-number v-model="form.attachmentCount" :min="0" style="width: 100%;" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="凭证分录" prop="entries">
          <el-table :data="form.entries" border style="width: 100%">
            <el-table-column type="index" label="序号" width="60" />
            <el-table-column prop="subjectCode" label="科目编码" width="120">
              <template #default="{ $index }">
                <el-select v-model="form.entries[$index].subjectCode" placeholder="选择科目" filterable style="width: 100%;" @change="(val) => handleSubjectChange(val, $index)">
                  <el-option v-for="item in subjectList" :key="item.code" :label="item.code" :value="item.code" />
                </el-select>
              </template>
            </el-table-column>
            <el-table-column prop="subjectName" label="科目名称" width="150">
              <template #default="{ $index }">
                <el-input v-model="form.entries[$index].subjectName" disabled />
              </template>
            </el-table-column>
            <el-table-column prop="summary" label="摘要">
              <template #default="{ $index }">
                <el-input v-model="form.entries[$index].summary" placeholder="请输入摘要" />
              </template>
            </el-table-column>
            <el-table-column prop="debit" label="借方金额" width="130">
              <template #default="{ $index }">
                <el-input-number v-model="form.entries[$index].debit" :min="0" :precision="2" style="width: 100%;" @change="calculateTotal" />
              </template>
            </el-table-column>
            <el-table-column prop="credit" label="贷方金额" width="130">
              <template #default="{ $index }">
                <el-input-number v-model="form.entries[$index].credit" :min="0" :precision="2" style="width: 100%;" @change="calculateTotal" />
              </template>
            </el-table-column>
            <el-table-column label="操作" width="80">
              <template #default="{ $index }">
                <el-button type="danger" link @click="removeEntry($index)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
          <div style="display: flex; justify-content: space-between; margin-top: 10px;">
            <el-button type="primary" link @click="addEntry">+ 添加分录</el-button>
            <div>
              <span style="margin-right: 20px;">合计: 借方 <span :class="totalDebitEntry === totalCreditEntry ? 'text-success' : 'text-danger'">¥{{ formatMoney(totalDebitEntry) }}</span></span>
              <span>贷方 <span :class="totalDebitEntry === totalCreditEntry ? 'text-success' : 'text-danger'">¥{{ formatMoney(totalCreditEntry) }}</span></span>
    <FormDialog :title="dialogTitle" v-model="dialogVisible" width="1200px" @confirm="submitForm" @cancel="dialogVisible = false">
      <div class="voucher-container">
        <div class="voucher-header">
          <h2 class="voucher-title">记账凭证</h2>
          <div class="voucher-period">{{ form.voucherDate ? form.voucherDate.substring(0, 7) + '期' : '' }}</div>
        </div>
        <el-form :model="form" :rules="rules" :disabled="isViewMode" ref="formRef" label-width="0">
          <div class="voucher-info">
            <div class="voucher-no-section">
              <span class="label">凭证字:</span>
              <el-select v-model="form.voucherPrefix" :disabled="isViewMode" style="width: 70px;">
                <el-option label="记" value="记" />
              </el-select>
              <el-input v-model="form.voucherNum" :disabled="isViewMode" style="width: 60px;" />
              <span class="label" style="margin-left: 5px;">号</span>
            </div>
            <div class="voucher-date-section">
              <span class="label">日期:</span>
              <el-date-picker v-model="form.voucherDate" :disabled="isViewMode" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 140px;" />
            </div>
            <div class="voucher-attachment-section">
              <span class="label">附件:</span>
              <el-input-number v-model="form.attachmentCount" :disabled="isViewMode" :min="0" :controls="false" style="width: 60px;" />
              <span class="label" style="margin-left: 5px;">张</span>
              <el-button type="primary" link :disabled="isViewMode" style="margin-left: 10px;">上传文件</el-button>
            </div>
          </div>
        </el-form-item>
        <el-form-item label="制单人" prop="creator">
          <el-input v-model="form.creator" disabled />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注" />
        </el-form-item>
      </el-form>
          <div class="voucher-table">
            <table class="accounting-voucher">
              <thead>
                <tr>
                  <th class="col-summary" rowspan="2">摘要</th>
                  <th class="col-subject" rowspan="2">会计科目</th>
                  <th class="col-debit-header" colspan="11">借方</th>
                  <th class="col-credit-header" colspan="11">贷方</th>
                  <th class="col-action" rowspan="2">操作</th>
                </tr>
                <tr class="amount-header">
                  <th>亿</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>万</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>元</th>
                  <th>角</th>
                  <th>分</th>
                  <th>亿</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>万</th>
                  <th>千</th>
                  <th>百</th>
                  <th>十</th>
                  <th>元</th>
                  <th>角</th>
                  <th>分</th>
                </tr>
              </thead>
              <tbody>
                <tr v-for="(entry, rowIndex) in form.entries" :key="rowIndex" @click="selectRow(rowIndex)" :class="{ 'selected-row': selectedRowIndex === rowIndex }">
                  <td class="col-summary">
                    <el-input v-model="entry.summary" :disabled="isViewMode" placeholder="请输入摘要" @focus="selectRow(rowIndex)" />
                  </td>
                  <td class="col-subject">
                    <el-tree-select
                      v-model="entry.subjectCode"
                      :data="subjectTreeOptions"
                      :props="subjectTreeSelectProps"
                      :disabled="isViewMode"
                      placeholder="选择科目"
                      filterable
                      check-strictly
                      clearable
                      :render-after-expand="false"
                      @change="(val) => handleSubjectChange(val, rowIndex)"
                      @focus="selectRow(rowIndex)"
                    />
                    <div class="subject-name">{{ entry.subjectName }}</div>
                  </td>
                  <!-- 借方11列 -->
                  <template v-if="editingCell.row === rowIndex && editingCell.type === 'debit'">
                    <td colspan="11" class="debit-input-cell">
                      <el-input-number ref="amountInputRef" v-model="entry.debit" :disabled="isViewMode" :min="0" :precision="2" :controls="false" size="small" @blur="finishEdit" class="full-width-input" />
                    </td>
                  </template>
                  <template v-else>
                    <td v-for="(digit, dIndex) in getAmountDigits(entry.debit, 11)" :key="'debit-'+dIndex" class="amount-cell debit-cell" @click="openAmountInput(rowIndex, 'debit')">
                      <span :class="{ 'text-primary': digit !== '', 'zero': digit === '' }">{{ digit || '' }}</span>
                    </td>
                  </template>
                  <!-- 贷方11列 -->
                  <template v-if="editingCell.row === rowIndex && editingCell.type === 'credit'">
                    <td colspan="11" class="credit-input-cell">
                      <el-input-number ref="amountInputRef" v-model="entry.credit" :disabled="isViewMode" :min="0" :precision="2" :controls="false" size="small" @blur="finishEdit" class="full-width-input" />
                    </td>
                  </template>
                  <template v-else>
                    <td v-for="(digit, dIndex) in getAmountDigits(entry.credit, 11)" :key="'credit-'+dIndex" class="amount-cell credit-cell" @click="openAmountInput(rowIndex, 'credit')">
                      <span :class="{ 'text-danger': digit !== '', 'zero': digit === '' }">{{ digit || '' }}</span>
                    </td>
                  </template>
                  <td class="col-action">
                    <el-button type="danger" link size="small" @click="removeEntry(rowIndex)" icon="Delete" :disabled="isViewMode || form.entries.length <= 2">删除</el-button>
                  </td>
                </tr>
                <tr class="total-row">
                  <td class="col-summary" colspan="2" style="text-align: center; font-weight: bold;">合计:</td>
                  <td v-for="(digit, index) in getAmountDigits(totalDebitEntry, 11)" :key="'total-debit-'+index" class="amount-cell total-debit-cell">
                    <span :class="{ 'text-primary': digit !== '' }">{{ digit }}</span>
                  </td>
                  <td v-for="(digit, index) in getAmountDigits(totalCreditEntry, 11)" :key="'total-credit-'+index" class="amount-cell total-credit-cell">
                    <span :class="{ 'text-danger': digit !== '' }">{{ digit }}</span>
                  </td>
                  <td class="col-action"></td>
                </tr>
              </tbody>
            </table>
          </div>
          <div class="voucher-toolbar">
            <el-button type="primary" link @click="addEntry" icon="Plus" :disabled="isViewMode">新增行</el-button>
          </div>
          <div class="voucher-footer">
            <div class="creator-section">
              <span class="label">制单人:</span>
              <el-select
                v-model="form.creator"
                :disabled="isViewMode"
                placeholder="请选择制单人"
                filterable
                clearable
                style="width: 200px;"
              >
                <el-option
                  v-for="item in creatorOptions"
                  :key="item"
                  :label="item"
                  :value="item"
                />
              </el-select>
            </div>
          </div>
        </el-form>
      </div>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="submitForm" :disabled="totalDebitEntry !== totalCreditEntry">确定</el-button>
        <div>
          <el-button v-if="!isViewMode" type="primary" @click="submitForm" :disabled="!isBalanced">保存</el-button>
          <el-button @click="dialogVisible = false">{{ isViewMode ? '关闭' : '取消' }}</el-button>
        </div>
      </template>
    </el-dialog>
    </FormDialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import { ref, reactive, onMounted, computed, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import useUserStore from "@/store/modules/user";
import { userListNoPageByTenantId } from "@/api/system/user";
import { listAccountSubject } from "@/api/financialManagement/accountSubject";
import {
  listVoucherPage,
  addVoucher,
  updateVoucher,
  postVoucher,
  cancelVoucher,
  getVoucherDetail,
} from "@/api/financialManagement/voucher";
defineOptions({
  name: "凭证管理",
});
const userStore = useUserStore();
const getDefaultCreator = () => userStore.nickName || userStore.name || "张三";
const filters = reactive({
  voucherNo: "",
@@ -172,48 +269,108 @@
  { label: "凭证字号", prop: "voucherNo", width: "120" },
  { label: "凭证日期", prop: "voucherDate", width: "120" },
  { label: "摘要", prop: "summary", showOverflowTooltip: true },
  { label: "借方金额", prop: "debit", slot: "debit" },
  { label: "贷方金额", prop: "credit", slot: "credit" },
  { label: "借方金额", prop: "debit", dataType: "slot", slot: "debit" },
  { label: "贷方金额", prop: "credit", dataType: "slot", slot: "credit" },
  { label: "制单人", prop: "creator", width: "100" },
  { label: "状态", prop: "status", slot: "status" },
  { label: "操作", prop: "operation", slot: "operation", width: "220", fixed: "right" },
  { label: "状态", prop: "status", dataType: "slot", slot: "status" },
  { label: "操作", prop: "operation", dataType: "slot", slot: "operation", width: "220", fixed: "right" },
];
const dataList = ref([]);
const dialogVisible = ref(false);
const dialogTitle = ref("");
const formRef = ref(null);
const dialogMode = ref("add");
const isEdit = ref(false);
const currentId = ref(null);
const isViewMode = computed(() => dialogMode.value === "view");
const subjectList = [
  { code: "1001", name: "库存现金" },
  { code: "1002", name: "银行存款" },
  { code: "1122", name: "应收账款" },
  { code: "2202", name: "应付账款" },
  { code: "5001", name: "生产成本" },
  { code: "6001", name: "主营业务收入" },
  { code: "6401", name: "主营业务成本" },
const fallbackSubjectTree = [
  { subjectCode: "1001", subjectName: "库存现金", balanceDirection: "借方", children: [] },
  { subjectCode: "1002", subjectName: "银行存款", balanceDirection: "借方", children: [] },
  { subjectCode: "1122", subjectName: "应收账款", balanceDirection: "借方", children: [] },
  { subjectCode: "2202", subjectName: "应付账款", balanceDirection: "贷方", children: [] },
  { subjectCode: "5001", subjectName: "生产成本", balanceDirection: "借方", children: [] },
  { subjectCode: "6001", subjectName: "主营业务收入", balanceDirection: "贷方", children: [] },
  { subjectCode: "6401", subjectName: "主营业务成本", balanceDirection: "借方", children: [] },
];
const form = reactive({
const subjectTreeOptions = ref([]);
const subjectList = ref([]);
const subjectTreeSelectProps = {
  children: "children",
  label: "label",
  value: "value",
};
const buildSubjectTreeOptions = (nodes = [], flatList = []) =>
  (nodes || [])
    .filter(item => item.subjectCode && item.subjectName)
    .map(item => {
      const balanceDirection = item.balanceDirection || "";
      const flatItem = {
        code: item.subjectCode,
        name: item.subjectName,
        balanceDirection,
      };
      flatList.push(flatItem);
      return {
        value: flatItem.code,
        label: `${flatItem.code} ${flatItem.name}${balanceDirection ? ` [${balanceDirection}]` : ""}`,
        children: buildSubjectTreeOptions(item.children || [], flatList),
      };
    });
const createEmptyEntry = () => ({
  subjectCode: "",
  subjectName: "",
  balanceDirection: "",
  summary: "",
  debit: 0,
  credit: 0,
});
const createDefaultForm = () => ({
  voucherNo: "",
  voucherPrefix: "记",
  voucherNum: "",
  voucherDate: "",
  attachmentCount: 0,
  entries: [],
  creator: "张三",
  entries: [createEmptyEntry(), createEmptyEntry()],
  creator: getDefaultCreator(),
  remark: "",
});
const form = reactive({
  ...createDefaultForm(),
});
const userOptions = ref([]);
const creatorOptions = computed(() => {
  const source = [
    ...userOptions.value.map(item => item.nickName || item.userName || item.name),
    getDefaultCreator(),
    form.creator,
    filters.creator,
  ];
  return [...new Set(source.filter(Boolean))];
});
const selectedRowIndex = ref(-1);
const editingCell = reactive({
  row: -1,
  type: "",
});
const amountInputRef = ref(null);
const isBalanced = computed(() => {
  return totalDebitEntry.value === totalCreditEntry.value && totalDebitEntry.value > 0;
});
const rules = {
  voucherDate: [{ required: true, message: "请选择凭证日期", trigger: "change" }],
};
const mockData = [
  { id: 1, voucherNo: "记-0001", voucherDate: "2024-01-15", summary: "销售收入", debit: 5650, credit: 5650, creator: "张三", status: "posted", entries: [{ subjectCode: "1002", subjectName: "银行存款", summary: "销售收入", debit: 5650, credit: 0 }, { subjectCode: "6001", subjectName: "主营业务收入", summary: "销售收入", debit: 0, credit: 5000 }, { subjectCode: "2221", subjectName: "应交税费", summary: "销项税额", debit: 0, credit: 650 }] },
  { id: 2, voucherNo: "记-0002", voucherDate: "2024-01-16", summary: "采购原材料", debit: 9040, credit: 9040, creator: "李四", status: "unposted", entries: [{ subjectCode: "5001", subjectName: "生产成本", summary: "采购原材料", debit: 8000, credit: 0 }, { subjectCode: "2221", subjectName: "应交税费", summary: "进项税额", debit: 1040, credit: 0 }, { subjectCode: "2202", subjectName: "应付账款", summary: "采购原材料", debit: 0, credit: 9040 }] },
  { id: 3, voucherNo: "记-0003", voucherDate: "2024-01-18", summary: "支付货款", debit: 5000, credit: 5000, creator: "张三", status: "posted", entries: [{ subjectCode: "2202", subjectName: "应付账款", summary: "支付货款", debit: 5000, credit: 0 }, { subjectCode: "1002", subjectName: "银行存款", summary: "支付货款", debit: 0, credit: 5000 }] },
];
const totalDebit = computed(() => {
  return dataList.value.reduce((sum, item) => sum + Number(item.debit), 0);
@@ -236,32 +393,79 @@
  return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
const normalizeVoucherStatus = status => String(status || "").toLowerCase();
const canEditVoucher = status => {
  const key = normalizeVoucherStatus(status);
  return key === "unposted" || status === "未过账";
};
const getStatusLabel = (status) => {
  const key = normalizeVoucherStatus(status);
  const map = { unposted: "未过账", posted: "已过账", cancelled: "已作废" };
  return map[status] || status;
  return map[key] || status;
};
const getStatusType = (status) => {
  const key = normalizeVoucherStatus(status);
  const map = { unposted: "warning", posted: "success", cancelled: "info" };
  return map[status] || "";
  return map[key] || "";
};
const getTableData = () => {
  let result = [...mockData];
  if (filters.voucherNo) {
    result = result.filter(item => item.voucherNo.includes(filters.voucherNo));
// 联调约定:分页参数使用 current/size,日期范围拆分为 startDate/endDate
const getTableData = async () => {
  try {
    const [startDate, endDate] =
      filters.dateRange && filters.dateRange.length === 2 ? filters.dateRange : ["", ""];
    const { data } = await listVoucherPage({
      current: pagination.currentPage,
      size: pagination.pageSize,
      voucherNo: filters.voucherNo,
      creator: filters.creator,
      status: filters.status,
      startDate,
      endDate,
    });
    dataList.value = data?.records || [];
    pagination.total = Number(data?.total || 0);
  } catch (error) {
    // 提示由全局请求拦截器处理,这里仅防止未捕获异常
  }
  if (filters.dateRange && filters.dateRange.length === 2) {
    result = result.filter(item => item.voucherDate >= filters.dateRange[0] && item.voucherDate <= filters.dateRange[1]);
};
// 凭证分录里的科目下拉与总账科目保持一致,避免提交不存在科目
const loadSubjectList = async () => {
  try {
    const { data } = await listAccountSubject({
      current: 1,
      size: 1000,
      status: 0
    });
    const flatList = [];
    const treeOptions = buildSubjectTreeOptions(data?.records || [], flatList);
    if (treeOptions.length > 0) {
      subjectTreeOptions.value = treeOptions;
      subjectList.value = flatList;
      return;
    }
    const fallbackFlatList = [];
    subjectTreeOptions.value = buildSubjectTreeOptions(fallbackSubjectTree, fallbackFlatList);
    subjectList.value = fallbackFlatList;
  } catch (error) {
    // 全局拦截器已提示错误,这里保留默认科目作为兜底
    const fallbackFlatList = [];
    subjectTreeOptions.value = buildSubjectTreeOptions(fallbackSubjectTree, fallbackFlatList);
    subjectList.value = fallbackFlatList;
  }
  if (filters.creator) {
    result = result.filter(item => item.creator === filters.creator);
};
const loadUserOptions = async () => {
  try {
    const { data } = await userListNoPageByTenantId();
    userOptions.value = Array.isArray(data) ? data : [];
  } catch (error) {
    userOptions.value = [];
  }
  if (filters.status) {
    result = result.filter(item => item.status === filters.status);
  }
  pagination.total = result.length;
  dataList.value = result.slice((pagination.currentPage - 1) * pagination.pageSize, pagination.currentPage * pagination.pageSize);
};
const resetFilters = () => {
@@ -280,49 +484,143 @@
};
const addEntry = () => {
  form.entries.push({ subjectCode: "", subjectName: "", summary: "", debit: 0, credit: 0 });
  if (isViewMode.value) {
    return;
  }
  form.entries.push(createEmptyEntry());
};
const selectRow = (index) => {
  selectedRowIndex.value = index;
};
const openAmountInput = (index, type) => {
  if (isViewMode.value) {
    return;
  }
  editingCell.row = index;
  editingCell.type = type;
  nextTick(() => {
    if (amountInputRef.value) {
      amountInputRef.value.focus();
    }
  });
};
const finishEdit = () => {
  editingCell.row = -1;
  editingCell.type = "";
};
const getAmountDigits = (amount, length) => {
  if (!amount || amount === 0) {
    return new Array(length).fill('');
  }
  const amountStr = Number(amount).toFixed(2);
  const [intPart, decPart] = amountStr.split('.');
  const fullAmount = intPart + decPart;
  // 左填充0到指定长度
  const paddedAmount = fullAmount.padStart(length, '0');
  const digits = paddedAmount.split('');
  // 找到第一个非零数字的位置
  let firstNonZeroIndex = 0;
  for (let i = 0; i < digits.length; i++) {
    if (digits[i] !== '0') {
      firstNonZeroIndex = i;
      break;
    }
  }
  // 只隐藏前导零(第一个非零数字之前的零)
  return digits.map((d, index) => {
    if (index < firstNonZeroIndex) {
      return ''; // 前导零显示为空
    }
    return d; // 保留金额中的零
  });
};
const removeEntry = (index) => {
  if (isViewMode.value) {
    return;
  }
  if (form.entries.length <= 2) {
    return;
  }
  form.entries.splice(index, 1);
  calculateTotal();
};
const handleSubjectChange = (val, index) => {
  const subject = subjectList.find(item => item.code === val);
  const subject = subjectList.value.find(item => item.code === val);
  if (subject) {
    form.entries[index].subjectName = subject.name;
    form.entries[index].balanceDirection = subject.balanceDirection || "";
  } else {
    form.entries[index].subjectName = "";
    form.entries[index].balanceDirection = "";
  }
};
const calculateTotal = () => {
  // 自动计算,由computed属性处理
};
const add = () => {
  dialogMode.value = "add";
  isEdit.value = false;
  currentId.value = null;
  dialogTitle.value = "新增凭证";
  Object.assign(form, {
    voucherNo: "记-" + String(mockData.length + 1).padStart(4, "0"),
  const nextNum = String((pagination.total || 0) + 1).padStart(4, "0");
  Object.assign(form, createDefaultForm(), {
    voucherPrefix: "记",
    voucherNum: nextNum,
    voucherNo: `记-${nextNum}`,
    voucherDate: new Date().toISOString().split('T')[0],
    attachmentCount: 0,
    entries: [{ subjectCode: "", subjectName: "", summary: "", debit: 0, credit: 0 }],
    creator: "张三",
    remark: "",
  });
  selectedRowIndex.value = 0;
  dialogVisible.value = true;
};
const edit = (row) => {
  isEdit.value = true;
  currentId.value = row.id;
  dialogTitle.value = "编辑凭证";
  Object.assign(form, row);
  dialogVisible.value = true;
const openVoucherDialog = async (row, mode = "edit") => {
  try {
    dialogMode.value = mode;
    isEdit.value = mode === "edit";
    currentId.value = row.id;
    dialogTitle.value = mode === "view" ? "查看凭证" : "编辑凭证";
    const { data } = await getVoucherDetail(row.id);
    const detail = data || row;
    const parts = (detail.voucherNo || "").split("-");
    Object.assign(form, createDefaultForm(), detail, {
      voucherPrefix: parts[0] || "记",
      voucherNum: parts[1] || "",
      creator: detail.creator || getDefaultCreator(),
      entries:
        detail.entries?.map(item => ({
          subjectCode: item.subjectCode || "",
          subjectName: item.subjectName || "",
          balanceDirection: item.balanceDirection || "",
          summary: item.summary || "",
          debit: Number(item.debit || 0),
          credit: Number(item.credit || 0),
        })) || [],
    });
    if (form.entries.length < 2) {
      while (form.entries.length < 2) {
        form.entries.push(createEmptyEntry());
      }
    }
    selectedRowIndex.value = 0;
    dialogVisible.value = true;
  } catch (error) {
    // 提示由全局请求拦截器处理,这里仅防止未捕获异常
  }
};
const view = (row) => {
  ElMessage.info(`查看凭证: ${row.voucherNo}`);
const edit = async row => {
  await openVoucherDialog(row, "edit");
};
const view = async row => {
  await openVoucherDialog(row, "view");
};
const handlePost = (row) => {
@@ -330,13 +628,10 @@
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "info",
  }).then(() => {
    const index = mockData.findIndex(item => item.id === row.id);
    if (index !== -1) {
      mockData[index].status = "posted";
    }
  }).then(async () => {
    await postVoucher({ id: row.id });
    ElMessage.success("过账成功");
    getTableData();
    await getTableData();
  });
};
@@ -345,13 +640,10 @@
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    const index = mockData.findIndex(item => item.id === row.id);
    if (index !== -1) {
      mockData[index].status = "cancelled";
    }
  }).then(async () => {
    await cancelVoucher({ id: row.id });
    ElMessage.success("作废成功");
    getTableData();
    await getTableData();
  });
};
@@ -364,32 +656,79 @@
};
const submitForm = () => {
  formRef.value.validate((valid) => {
  if (isViewMode.value) {
    dialogVisible.value = false;
    return;
  }
  formRef.value.validate(async valid => {
    if (valid) {
      if (totalDebitEntry.value !== totalCreditEntry.value) {
      // 前置校验:与后端规则对齐,减少无效请求
      if (!isBalanced.value) {
        ElMessage.error("借贷不平衡,请检查分录");
        return;
      }
      const summary = form.entries.find(e => e.debit > 0)?.summary || "";
      if (isEdit.value) {
        const index = mockData.findIndex(item => item.id === currentId.value);
        if (index !== -1) {
          mockData[index] = { ...mockData[index], ...form, summary, debit: totalDebitEntry.value, credit: totalCreditEntry.value };
        }
        ElMessage.success("编辑成功");
      } else {
        const newId = mockData.length > 0 ? Math.max(...mockData.map(item => item.id)) + 1 : 1;
        mockData.push({ id: newId, ...form, summary, debit: totalDebitEntry.value, credit: totalCreditEntry.value, status: "unposted" });
        ElMessage.success("新增成功");
      const validEntries = form.entries.filter(
        entry => entry.subjectCode && (Number(entry.debit) > 0 || Number(entry.credit) > 0)
      );
      if (validEntries.length === 0) {
        ElMessage.error("请至少填写一条有效分录");
        return;
      }
      dialogVisible.value = false;
      getTableData();
      const invalidEntry = validEntries.find(
        entry => Number(entry.debit) > 0 && Number(entry.credit) > 0
      );
      if (invalidEntry) {
        ElMessage.error("同一分录不能同时填写借方和贷方");
        return;
      }
      const summary = validEntries.find(e => e.debit > 0)?.summary || "";
      const voucherNo = `${form.voucherPrefix}-${form.voucherNum}`;
      const dataToSave = {
        voucherNo,
        voucherDate: form.voucherDate,
        summary,
        creator: form.creator,
        attachmentCount: Number(form.attachmentCount || 0),
        remark: form.remark,
        debit: totalDebitEntry.value,
        credit: totalCreditEntry.value,
        entries: validEntries.map(entry => ({
          subjectCode: entry.subjectCode,
          subjectName: entry.subjectName,
          summary: entry.summary,
          debit: Number(entry.debit || 0),
          credit: Number(entry.credit || 0),
        })),
      };
      try {
        if (isEdit.value) {
          await updateVoucher({
            id: currentId.value,
            ...dataToSave,
          });
          ElMessage.success("编辑成功");
        } else {
          await addVoucher(dataToSave);
          ElMessage.success("新增成功");
        }
        dialogVisible.value = false;
        await getTableData();
      } catch (error) {
        // 提示由全局请求拦截器处理,这里仅防止未捕获异常
      }
    }
  });
};
onMounted(() => {
  getTableData();
onMounted(async () => {
  await loadUserOptions();
  await loadSubjectList();
  await getTableData();
});
</script>
@@ -415,4 +754,272 @@
  color: #f56c6c;
  font-weight: bold;
}
.text-primary {
  color: #409eff;
}
.voucher-container {
  background: #fff;
  padding: 20px;
}
.voucher-header {
  text-align: center;
  margin-bottom: 15px;
  .voucher-title {
    font-size: 22px;
    font-weight: bold;
    margin: 0 0 5px 0;
    color: #303133;
  }
  .voucher-period {
    font-size: 14px;
    color: #909399;
  }
}
.voucher-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
  padding: 0 10px;
  .label {
    font-size: 14px;
    color: #606266;
  }
  .voucher-no-section,
  .voucher-date-section,
  .voucher-attachment-section {
    display: flex;
    align-items: center;
  }
}
.voucher-table {
  border: 1px solid #dcdfe6;
  border-right: none;
  margin-bottom: 15px;
}
.accounting-voucher {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
  th,
  td {
    border: 1px solid #dcdfe6;
    text-align: center;
    padding: 0;
    height: 36px;
  }
  & th:last-child,
  & td:last-child {
    border-right: none !important;
  }
  thead {
    background-color: #f5f7fa;
    th {
      font-weight: normal;
      color: #606266;
      font-size: 12px;
    }
    .col-summary,
    .col-subject {
      font-weight: bold;
      font-size: 13px;
    }
    .col-debit-header,
    .col-credit-header {
      background-color: #ecf5ff;
      color: #409eff;
      font-weight: bold;
    }
  }
  .amount-header {
    th {
      font-size: 11px;
      padding: 2px 0;
      background-color: #f5f7fa;
    }
  }
  .col-summary {
    width: 160px;
    min-width: 160px;
  }
  .col-subject {
    width: 180px;
    min-width: 180px;
  }
  .col-action {
    width: 60px;
    min-width: 60px;
    text-align: center;
  }
  .amount-cell {
    width: 24px;
    min-width: 24px;
    max-width: 24px;
    padding: 0;
    font-size: 13px;
    font-family: 'Courier New', monospace;
    cursor: pointer;
    text-align: center;
    &:hover {
      background-color: #f5f7fa;
    }
    span {
      display: block;
      width: 100%;
      height: 100%;
      line-height: 36px;
      &.zero {
        color: #c0c4cc;
      }
    }
  }
  .debit-input-cell,
  .credit-input-cell {
    padding: 0;
    background-color: #ecf5ff;
    .full-width-input {
      width: 100%;
      :deep(.el-input__wrapper) {
        padding: 0 10px;
        box-shadow: none;
        background-color: transparent;
      }
      input {
        text-align: right;
        font-size: 14px;
        height: 34px;
      }
    }
  }
  tbody {
    tr {
      &:hover {
        background-color: #f5f7fa;
      }
      &.selected-row {
        background-color: #ecf5ff;
      }
    }
    td {
      .el-input {
        .el-input__wrapper {
          box-shadow: none;
          padding: 0 5px;
        }
        input {
          text-align: center;
          height: 34px;
        }
      }
      .el-select {
        width: 100%;
        .el-input__wrapper {
          box-shadow: none;
        }
        input {
          text-align: center;
          height: 34px;
        }
      }
    }
    .col-summary {
      .el-input input {
        text-align: left;
        padding-left: 10px;
      }
    }
    .col-subject {
      position: relative;
      .el-select,
      .el-tree-select {
        .el-input input {
          font-size: 12px;
        }
      }
      .subject-name {
        font-size: 11px;
        color: #909399;
        margin-top: 2px;
        line-height: 1.2;
      }
    }
  }
  .total-row {
    background-color: #fdf6ec;
    td {
      font-weight: bold;
    }
    .total-cell {
      background-color: #fdf6ec;
      font-weight: bold;
    }
  }
}
.voucher-toolbar {
  display: flex;
  justify-content: flex-start;
  padding: 10px 0;
  margin-top: 5px;
}
.voucher-footer {
  display: flex;
  justify-content: flex-end;
  padding: 0 10px;
  margin-top: 10px;
  .creator-section {
    .label {
      font-size: 14px;
      color: #606266;
    }
  }
}
:deep(.el-dialog__body) {
  padding: 10px 20px;
}
</style>