| | |
| | | </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="状态:"> |
| | |
| | | </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: "", |
| | |
| | | { 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); |
| | |
| | | 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 = () => { |
| | |
| | | }; |
| | | |
| | | 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) => { |
| | |
| | | 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(); |
| | | }); |
| | | }; |
| | | |
| | |
| | | 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(); |
| | | }); |
| | | }; |
| | | |
| | |
| | | }; |
| | | |
| | | 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> |
| | | |
| | |
| | | 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> |