@@ -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(62 0px, 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(7 60px, 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-co ls" >
< 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 " p laceholder="请输入金额" / > < / 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 === 'bil ls' " >
< div class = "allocation-title" > 费用明细 < / div >
< div v-for = "(line, index) in billLines" :key="index " c lass="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 : 8 px ; padding : 12 px ; }
. row - actions { display : flex ; align - items : center ; gap : 2 px ; white - space : nowrap ; }
. row - actions . el - button { margin - left : 0 ; }
. file - picker { display : inline - flex ; align - items : center ; gap : 8 px ; max - width : 100 % ; padding : 10 px 14 px ; border : 1 px dashed var ( -- el - border - color ) ; border - radius : 6 px ; 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 : 10 px ; }
. allocation - title { margin : 4 px 0 8 px ; font - weight : 600 ; }
. allocation - row { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ) 32 px ; gap : 8 px ; margin - bottom : 8 px ; align - items : center ; }
@ media ( max - width : 720 px ) { : deep ( . form - grid . two - cols ) { grid - template - columns : 1 fr ; } }
@ media ( max - width : 480 px ) { . drawer - footer { flex - wrap : wrap ; } . drawer - footer . el - button { flex : 1 1 120 px ; } . allocation - row { grid - template - columns : 1 fr ; } . allocation - row . el - button { justify - self : end ; } }
. bill - line - row { display : grid ; grid - template - columns : minmax ( 110 px , .8 fr ) minmax ( 140 px , 1.2 fr ) minmax ( 110 px , .7 fr ) 32 px ; gap : 8 px ; margin - bottom : 8 px ; align - items : center ; }
. bill - summary { margin - bottom : 16 px ; }
. detail - line { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , .8 fr ) auto ; gap : 12 px ; align - items : center ; padding : 10 px 0 ; border - bottom : 1 px 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 : 720 px ) { : deep ( . form - grid . two - cols ) { grid - template - columns : 1 fr ; } . bill - line - row { grid - template - columns : 1 fr 1 fr ; } . drawer - footer . el - button { min - height : 44 px ; } : deep ( . bill - summary . el - descriptions _ _body . el - descriptions _ _table ) { table - layout : fixed ; } }
@ media ( max - width : 480 px ) { . drawer - footer { flex - wrap : wrap ; } . drawer - footer . el - button { flex : 1 1 120 px ; } . allocation - row , . bill - line - row , . detail - line { grid - template - columns : 1 fr ; } . allocation - row . el - button , . bill - line - row . el - button { justify - self : end ; } : deep ( . bill - summary . el - descriptions _ _cell ) { display : block ; width : 100 % ; } }
< / style >