优化
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
|
||||
|
||||
describe('FinanceManualPage delivery contract', () => {
|
||||
it('keeps every business table operation column first and fixed left', () => {
|
||||
const declarations = [...source.matchAll(/return \[([^\n]+)\]/g)].map((match) => match[1] || '').filter((value) => value.includes("key: 'actions'"))
|
||||
expect(declarations).toHaveLength(3)
|
||||
for (const declaration of declarations) {
|
||||
expect(declaration.trim().startsWith("{ key: 'actions'")).toBe(true)
|
||||
expect(declaration).toContain("fixed: 'left'")
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes supported bill operations with permission checks and traceable errors', () => {
|
||||
expect(source).toContain("platform:finance:bill:update")
|
||||
expect(source).toContain("platform:finance:bill:adjust")
|
||||
expect(source).toContain("platform:finance:bill:void")
|
||||
expect(source).toContain("openBill(row, 'detail')")
|
||||
expect(source).toContain("openBill(row, 'edit')")
|
||||
expect(source).toContain("openBill(row, 'adjust')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="actionError"')
|
||||
expect(source).toContain('<ApiErrorMeta :details="drawerError"')
|
||||
})
|
||||
|
||||
it('blocks uncontracted voucher uploads and declares narrow-screen layouts', () => {
|
||||
expect(source).toContain('凭证上传接口尚未接通')
|
||||
expect(source).toContain('type="file" accept="image/*" disabled')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('@media (max-width: 480px)')
|
||||
expect(source).toContain('grid-template-columns: 1fr')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { FileCheck2, FileUp, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { Eye, FileCheck2, FileUp, MoreHorizontal, Pencil, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts } from '../api/finance'
|
||||
import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource } from '../types'
|
||||
import { adjustFinanceBill, createFinanceBill, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from '../api/finance'
|
||||
import type { FinanceBillDetail, FinanceBillResource, FinanceCostResource, FinanceReceiptResource } from '../types'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
@@ -16,6 +16,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
|
||||
type Mode = 'bills' | 'receipts' | 'costs'
|
||||
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
|
||||
type TableRow = Record<string, string | number | null>
|
||||
const props = defineProps<{ mode: Mode }>()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -28,11 +29,17 @@ const total = ref(0)
|
||||
const serverPaged = ref(false)
|
||||
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
|
||||
const drawerOpen = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const drawerLoading = ref(false)
|
||||
const drawerError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedBill = ref<FinanceBillDetail | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const file = ref<File | null>(null)
|
||||
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
|
||||
const allocations = reactive([{ billId: '', amount: '' }])
|
||||
const billLines = reactive([{ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }])
|
||||
|
||||
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入' }[props.mode]))
|
||||
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
|
||||
@@ -40,6 +47,13 @@ const createPermission = computed(() => ({ bills: 'platform:finance:bill:create'
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
|
||||
const canIssue = computed(() => access.can('platform:finance:bill:issue'))
|
||||
const canUpdate = computed(() => access.can('platform:finance:bill:update'))
|
||||
const canAdjust = computed(() => access.can('platform:finance:bill:adjust'))
|
||||
const canVoid = computed(() => access.can('platform:finance:bill:void'))
|
||||
const drawerTitle = computed(() => {
|
||||
if (props.mode !== 'bills') return props.mode === 'receipts' ? '登记人工收款' : '录入人工成本'
|
||||
return { create: '新建客户账单', detail: '账单详情', edit: '编辑草稿账单', adjust: '调整已出账账单' }[drawerMode.value]
|
||||
})
|
||||
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
|
||||
@@ -47,7 +61,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
|
||||
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
|
||||
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'receivedAt', label: '收款日期', width: 130 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'paymentMethod', label: '收款方式', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'allocationCount', label: '关联账单数', width: 120 }]
|
||||
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '关联单号', minWidth: 160 }, { key: 'feeName', label: '费用项目', minWidth: 160 }, { key: 'supplierName', label: '客户', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'createdAt', label: '登记时间', width: 150 }]
|
||||
return []
|
||||
@@ -92,19 +106,25 @@ async function load() {
|
||||
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
|
||||
function changePageSize() { page.value = 1; void load() }
|
||||
function openCreate() {
|
||||
drawerMode.value = 'create'; selectedBill.value = null; drawerError.value = null
|
||||
Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
|
||||
allocations.splice(0, allocations.length, { billId: '', amount: '' })
|
||||
billLines.splice(0, billLines.length, { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' })
|
||||
file.value = null; drawerOpen.value = true
|
||||
}
|
||||
function selectFile(event: Event) { const selected = (event.target as HTMLInputElement).files?.[0] || null; file.value = selected }
|
||||
function positiveNumber(value: string) { const amount = Number(value); return Number.isFinite(amount) && amount > 0 }
|
||||
function addAllocation() { allocations.push({ billId: '', amount: '' }) }
|
||||
function removeAllocation(index: number) { if (allocations.length > 1) allocations.splice(index, 1) }
|
||||
function addBillLine() { billLines.push({ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }) }
|
||||
function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) }
|
||||
function billPayloadLines() { return billLines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, orderId: line.orderId || undefined, inboundId: line.inboundId || undefined, feeItemName: line.feeItemName.trim(), amount: Number(line.amount) })) }
|
||||
function validateBusinessForm() {
|
||||
if (!/^\d+$/.test(form.customerId)) return '客户ID必须为数字'
|
||||
if (!form.billDate) return '请选择日期'
|
||||
if (!positiveNumber(form.amount)) return '金额必须大于 0'
|
||||
if (props.mode === 'bills' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return '请填写数字格式的费用来源ID和费用项目'
|
||||
if (props.mode !== 'bills' && !positiveNumber(form.amount)) return '金额必须大于 0'
|
||||
if (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '每条账单费用都必须填写数字格式的费用来源ID、费用项目和大于 0 的金额'
|
||||
if (drawerMode.value === 'adjust' && !form.remark.trim()) return '调整原因不能为空'
|
||||
if (props.mode === 'costs' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return `请填写数字格式的${form.costType === 'SINGLE_ORDER' ? '订单ID' : '柜次ID'}和费用项目`
|
||||
if (props.mode === 'receipts') {
|
||||
if (allocations.some((item) => !/^\d+$/.test(item.billId) || !positiveNumber(item.amount))) return '每条分配都必须填写数字格式的账单ID和大于 0 的金额'
|
||||
@@ -118,23 +138,52 @@ async function submit() {
|
||||
const validationMessage = validateBusinessForm(); if (validationMessage) { ElMessage.warning(validationMessage); return }
|
||||
if (props.mode !== 'bills') { ElMessage.error('凭证上传接口尚未接通,当前不能保存收款或成本,避免形成无凭证台账'); return }
|
||||
saving.value = true
|
||||
drawerError.value = null
|
||||
try {
|
||||
await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines: [{ sourceFeeId: form.referenceNo, sourceFeeVersion: 1, feeItemName: form.feeName, amount: Number(form.amount) }] })
|
||||
const lines = billPayloadLines()
|
||||
if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version })
|
||||
else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines })
|
||||
else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines })
|
||||
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
|
||||
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message) }
|
||||
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); drawerError.value = details; ElMessage.error(details.message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function issueBill(row: TableRow) {
|
||||
actionError.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('出账后账单将冻结,不能再按草稿方式修改。确认出账吗?', '确认出账', { type: 'warning', confirmButtonText: '确认出账', cancelButtonText: '取消' })
|
||||
await issueFinanceBill(String(row.id), Number(row.version))
|
||||
ElMessage.success('账单已出账并冻结'); await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, '账单出账失败,请重试')
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
const details = getApiErrorDetails(error, '账单出账失败,请重试'); actionError.value = details; ElMessage.error(details.message)
|
||||
}
|
||||
}
|
||||
async function openBill(row: TableRow, mode: DrawerMode) {
|
||||
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; selectedBill.value = null
|
||||
try {
|
||||
const detail = await getFinanceBill(String(row.id)); selectedBill.value = detail
|
||||
Object.assign(form, { customerId: detail.customerId, billDate: detail.billDate, remark: '' })
|
||||
billLines.splice(0, billLines.length, ...detail.lines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, feeItemName: line.feeItemName, amount: String(line.amount), orderId: line.orderId || '', inboundId: line.inboundId || '' })))
|
||||
if (!billLines.length) addBillLine()
|
||||
} catch (error) { drawerError.value = getApiErrorDetails(error, '账单详情加载失败,请重试') }
|
||||
finally { drawerLoading.value = false }
|
||||
}
|
||||
async function voidBill(row: TableRow) {
|
||||
actionError.value = null
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因。已有收款的账单不能作废。', '作废账单', { type: 'warning', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空', confirmButtonText: '确认作废', cancelButtonText: '取消' })
|
||||
await voidFinanceBill(String(row.id), Number(row.version), result.value.trim())
|
||||
ElMessage.success('账单已作废'); await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, '账单作废失败,请重试'); actionError.value = details; ElMessage.error(details.message)
|
||||
}
|
||||
}
|
||||
function handleBillCommand(command: string, row: TableRow) {
|
||||
if (command === 'issue') void issueBill(row)
|
||||
else if (command === 'void') void voidBill(row)
|
||||
}
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
@@ -144,12 +193,22 @@ onMounted(() => void load())
|
||||
<ListPageLayout v-else aria-label="财务人工模块">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState :title="`${title}加载失败`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<div v-if="actionError" class="surface-panel operation-error" role="alert"><el-alert type="error" :closable="true" :title="actionError.message" @close="actionError = null" /><ApiErrorMeta :details="actionError" /></div>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据" @update:page="load" @update:page-size="changePageSize">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.mode === 'bills' ? '新建账单' : props.mode === 'receipts' ? '登记收款' : '录入成本' }}</el-button></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button v-if="props.mode === 'bills' && row.status === 'DRAFT' && canIssue" link type="primary" @click="issueBill(row)"><FileCheck2 :size="15" />出账</el-button>
|
||||
<span v-else class="muted-value">{{ props.mode === 'bills' && row.status !== 'DRAFT' ? '已冻结' : '人工台账' }}</span>
|
||||
<div v-if="props.mode === 'bills'" class="row-actions">
|
||||
<el-button link type="primary" @click="openBill(row, 'detail')"><Eye :size="15" />查看</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canUpdate" link type="primary" @click="openBill(row, 'edit')"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(String(row.status)) && canAdjust" link type="primary" @click="openBill(row, 'adjust')"><Pencil :size="15" />调整</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canVoid" link type="danger" @click="voidBill(row)"><Trash2 :size="15" />作废</el-button>
|
||||
<el-dropdown v-if="row.status === 'DRAFT' && (canIssue || canVoid)" trigger="click" @command="handleBillCommand($event, row)">
|
||||
<el-button link aria-label="更多账单操作"><MoreHorizontal :size="17" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canIssue" command="issue"><FileCheck2 :size="15" />出账</el-dropdown-item><el-dropdown-item v-if="canVoid" command="void" divided><Trash2 :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<span v-else class="muted-value">人工台账</span>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}</el-tag></template>
|
||||
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
|
||||
@@ -158,48 +217,77 @@ onMounted(() => void load())
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" :title="props.mode === 'bills' ? '新建客户账单' : props.mode === 'receipts' ? '登记人工收款' : '录入人工成本'" size="min(620px, 94vw)" :confirm-loading="saving">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<AppDrawer v-model="drawerOpen" :title="drawerTitle" size="min(760px, 94vw)" :loading="drawerLoading" :confirm-loading="saving">
|
||||
<div v-if="drawerError" class="drawer-error" role="alert"><el-alert type="error" :closable="false" :title="drawerError.message" /><ApiErrorMeta :details="drawerError" /></div>
|
||||
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
|
||||
<el-descriptions :column="2" border class="bill-summary">
|
||||
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<FormSection title="费用明细" description="当前账单版本中的真实费用明细。">
|
||||
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>来源ID:{{ line.sourceFeeId }}</span><b>{{ line.amount }} CNY</b></div>
|
||||
<el-empty v-if="!selectedBill.lines.length" description="暂无费用明细" />
|
||||
</FormSection>
|
||||
<el-alert type="info" :closable="false" show-icon title="后端已保存调整快照;修订记录查询接口尚未提供,当前只能查看最新版本号。" />
|
||||
</template>
|
||||
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
|
||||
<FormSection title="基本信息" description="首期只支持 CNY,由平台财务人工填写并留存凭证。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="客户ID" prop="customerId"><el-input v-model="form.customerId" inputmode="numeric" placeholder="请输入客户ID" /></el-form-item>
|
||||
<el-form-item label="日期" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="客户ID" prop="customerId"><el-input v-model="form.customerId" inputmode="numeric" placeholder="请输入客户ID" :disabled="props.mode === 'bills' && drawerMode !== 'create'" /></el-form-item>
|
||||
<el-form-item label="日期" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" placeholder="请输入金额" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="props.mode !== 'receipts'" label="关联ID" prop="referenceNo"><el-input v-model="form.referenceNo" :placeholder="props.mode === 'bills' ? '请输入费用来源ID' : form.costType === 'SINGLE_ORDER' ? '请输入订单ID' : '请输入柜次ID'" /></el-form-item>
|
||||
<template v-if="props.mode === 'receipts'">
|
||||
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
|
||||
<div class="allocation-title">账单分配</div>
|
||||
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row">
|
||||
<el-input v-model="allocation.billId" placeholder="账单ID" />
|
||||
<el-input v-model="allocation.amount" inputmode="decimal" placeholder="分配金额(CNY)" />
|
||||
<el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button>
|
||||
<template v-if="props.mode === 'bills'">
|
||||
<div class="allocation-title">费用明细</div>
|
||||
<div v-for="(line, index) in billLines" :key="index" class="bill-line-row">
|
||||
<el-input v-model="line.sourceFeeId" inputmode="numeric" placeholder="费用来源ID" />
|
||||
<el-input v-model="line.feeItemName" placeholder="费用项目" />
|
||||
<el-input v-model="line.amount" inputmode="decimal" placeholder="金额(CNY)" />
|
||||
<el-button link type="danger" aria-label="删除费用明细" @click="removeBillLine(index)"><Trash2 :size="16" /></el-button>
|
||||
</div>
|
||||
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
|
||||
<el-button link type="primary" @click="addBillLine"><Plus :size="15" />添加费用明细</el-button>
|
||||
<el-form-item v-if="drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" placeholder="请输入金额" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="关联ID" prop="referenceNo"><el-input v-model="form.referenceNo" :placeholder="form.costType === 'SINGLE_ORDER' ? '请输入订单ID' : '请输入柜次ID'" /></el-form-item>
|
||||
<template v-if="props.mode === 'receipts'">
|
||||
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
|
||||
<div class="allocation-title">账单分配</div>
|
||||
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row"><el-input v-model="allocation.billId" placeholder="账单ID" /><el-input v-model="allocation.amount" inputmode="decimal" placeholder="分配金额(CNY)" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button></div>
|
||||
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
|
||||
</template>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="props.mode !== 'receipts'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" :placeholder="props.mode === 'bills' ? '请输入账单费用项目' : '例如:清关费、拖车费'" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="当前按人工上传凭证设计;后端未提供接口时会明确返回失败,不使用假数据。">
|
||||
<el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止,不会发送空 voucherFileIds。" />
|
||||
<label class="file-picker"><FileUp :size="16" />{{ file?.name || '选择凭证图片(接口接通后启用)' }}<input type="file" accept="image/*,.pdf" @change="selectFile" /></label>
|
||||
</FormSection>
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="后端上传契约提供后才允许形成财务台账。"><el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止。" /><label class="file-picker is-disabled"><FileUp :size="16" />凭证选择将在接口接通后启用<input type="file" accept="image/*" disabled @change="selectFile" /></label></FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? '保存' : '等待凭证接口' }}</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : '等待凭证接口' }}</el-button></div></template>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-page { min-width: 0; }
|
||||
.operation-error, .drawer-error { display: grid; gap: 8px; padding: 12px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.file-picker { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.file-picker.is-disabled { cursor: not-allowed; color: var(--cc-muted); background: var(--el-fill-color-light); }
|
||||
.file-picker input { display: none; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
|
||||
.allocation-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row { grid-template-columns: 1fr; } .allocation-row .el-button { justify-self: end; } }
|
||||
.bill-line-row { display: grid; grid-template-columns: minmax(110px, .8fr) minmax(140px, 1.2fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.bill-summary { margin-bottom: 16px; }
|
||||
.detail-line { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-line span { color: var(--cc-muted); overflow-wrap: anywhere; }
|
||||
.detail-line b { white-space: nowrap; }
|
||||
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button { justify-self: end; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user