2026-10-03 12:43:25 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-03 12:43:25 +08:00
import { computed , onBeforeUnmount , reactive , ref , watch } from 'vue'
import { ElMessage } from 'element-plus'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError , getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { companyCostPermissions as permissions , getCompanyCost , commandCompanyCost , payCompanyCost ,
listCompanyCostHistory , listCompanyCostPayments , uploadCompanyCostVoucher , downloadCompanyCostVoucher ,
type CompanyCost , type CompanyCostCommand , type CompanyCostPayment , type CompanyCostHistory , type CompanyCostPaid , type CompanyCostVoucher } from '../api/companyVehicleCosts'
const props = withDefaults ( defineProps < { costNo : string ; active ?: boolean } > (), { active : true })
const emit = defineEmits < { changed : []; busy : [ value : boolean ] } > ()
const access = useAccessPreviewStore (), allowed = computed (() => access . can ( permissions . list ))
const cost = ref < CompanyCost | null >( null ), loading = ref ( false ), error = ref < ApiErrorDetails | null >( null )
const action = ref < 'approve' | 'void' | 'pay' | null > ( null ), saving = ref ( false ), uploading = ref ( false ), writeError = ref < ApiErrorDetails | null >( null )
const reason = ref ( '' ), vouchers = ref < CompanyCostVoucher [] >([]), pendingFiles = ref < File [] >([])
const form = reactive < { amount : number | null ; paidAt : string ; method : CompanyCostPayment [ 'method' ]; referenceNo : string ; remark : string } > ({ amount : null , paidAt : '' , method : 'BANK_TRANSFER' , referenceNo : '' , remark : '' })
type Pending = { action : 'approve' | 'void' ; key : string ; request : CompanyCostCommand } | { action : 'pay' ; key : string ; request : CompanyCostPayment }
const pending = ref < Pending | null >( null )
const tab = ref < 'current' | 'history' | 'payments' > ( 'current' ), historyPage = ref ( 1 ), paymentPage = ref ( 1 )
const history = ref < CompanyCostHistory [] >([]), payments = ref < CompanyCostPaid [] >([]), historyTotal = ref ( 0 ), paymentTotal = ref ( 0 )
const recordsLoading = ref ( false ), recordsError = ref < ApiErrorDetails | null >( null )
let epoch = 0 , readRequest = 0 , recordsRequest = 0 , disposed = false
const previews = new Set < { target : Window ; url : string ; timer : ReturnType < typeof setInterval > } > ()
const busy = computed (() => saving . value || uploading . value || !! pending . value )
watch ( busy , value => emit ( 'busy' , value ))
const canApprove = computed (() => allowed . value && access . can ( permissions . approve ) && cost . value ? . status === 'PENDING' )
const canVoid = computed (() => allowed . value && access . can ( permissions . void ) && !! cost . value && [ 'PENDING' , 'APPROVED' ]. includes ( cost . value . status ) && cost . value . paidAmount === 0 )
const canPay = computed (() => allowed . value && access . can ( permissions . pay ) && !! cost . value && [ 'APPROVED' , 'PARTIALLY_PAID' ]. includes ( cost . value . status ) && ( cost . value . outstandingAmount ?? 0 ) > 0 )
const statusLabels = { PENDING : '待财务入账' , APPROVED : '已入账 · 未付款' , PARTIALLY_PAID : '部分付款' , PAID : '已付款' , VOIDED : '已作废' }
const methods = { BANK_TRANSFER : '银行转账' , ONLINE : '线上支付' , CASH : '现金' , OTHER : '其他' }
const money = ( value : number | null ) => value == null ? '未形成应付' : `CNY ${ value . toFixed ( 2 ) } `
const time = ( value : string ) => new Date ( value ). toLocaleString ( 'zh-CN' , { hour12 : false })
const historyColumns : DataTableColumn [] = [
{ key : 'action' , label : '操作' , width : 90 , slotName : 'action' }, { key : 'occurredAt' , label : '时间' , minWidth : 170 , slotName : 'time' },
{ key : 'operatorName' , prop : 'operatorName' , label : '操作人' , minWidth : 100 }, { key : 'amount' , label : '当时金额' , minWidth : 130 , slotName : 'amount' },
{ key : 'reason' , prop : 'reason' , label : '说明' , minWidth : 160 },
]
const paymentColumns : DataTableColumn [] = [
{ key : 'paymentNo' , prop : 'paymentNo' , label : '付款编号' , minWidth : 170 }, { key : 'amount' , label : '已付金额' , minWidth : 130 , slotName : 'amount' },
{ key : 'paidAt' , label : '实际付款时间' , minWidth : 170 , slotName : 'time' }, { key : 'method' , label : '方式' , width : 100 , slotName : 'method' },
{ key : 'referenceNo' , prop : 'referenceNo' , label : '流水号' , minWidth : 150 }, { key : 'files' , label : '付款凭证' , minWidth : 130 , slotName : 'files' },
]
async function load () {
const request = ++ readRequest , scope = epoch ; error . value = null ; cost . value = null
if ( ! allowed . value ) { loading . value = false ; return }
loading . value = true
try { const value = await getCompanyCost ( props . costNo ); if ( ! disposed && scope === epoch && request === readRequest ) cost . value = value }
catch ( cause ) { if ( ! disposed && scope === epoch && request === readRequest ) error . value = getApiErrorDetails ( cause , '公司费用读取失败' ) }
finally { if ( scope === epoch && request === readRequest ) loading . value = false }
}
watch ([() => props . costNo , allowed ], () => {
epoch ++ ; readRequest ++ ; recordsRequest ++ ; pending . value = null ; action . value = null ; saving . value = false ; uploading . value = false
vouchers . value = []; pendingFiles . value = []; history . value = []; payments . value = []; tab . value = 'current' ; historyPage . value = 1 ; paymentPage . value = 1
2026-10-03 13:20:35 +08:00
reason . value = '' ; Object . assign ( form , { amount : null , paidAt : '' , method : 'BANK_TRANSFER' , referenceNo : '' , remark : '' })
2026-10-03 12:43:25 +08:00
writeError . value = null ; recordsError . value = null ; void load ()
}, { immediate : true })
watch (() => props . active , value => { if ( value && ! busy . value ) void load () })
function choose ( next : 'approve' | 'void' | 'pay' ) {
if ( busy . value || ! ( next === 'approve' ? canApprove . value : next === 'void' ? canVoid . value : canPay . value )) return
action . value = next ; tab . value = 'current' ; writeError . value = null ; reason . value = ''
if ( next === 'pay' && ! form . paidAt ) form . paidAt = new Date (). toISOString ()
}
function selectFiles ( event : Event ) {
if ( busy . value || ! access . can ( permissions . upload )) return
const input = event . target as HTMLInputElement , files = Array . from ( input . files ?? [])
if ( vouchers . value . length + pendingFiles . value . length + files . length > 20 || files . some ( file => ! file . size || file . size > 10 * 1024 * 1024 || ! [ 'image/png' , 'image/jpeg' ]. includes ( file . type ))) {
ElMessage . warning ( '最多20张非空JPG/PNG付款凭证,每张不超过10MiB' ); input . value = '' ; return
}
pendingFiles . value . push (... files ); input . value = '' ; void uploadFiles ()
}
async function uploadFiles () {
if ( uploading . value || saving . value || pending . value || ! access . can ( permissions . upload )) return
const scope = epoch , no = props . costNo ; uploading . value = true ; writeError . value = null
try {
while ( pendingFiles . value . length ) {
const file = await uploadCompanyCostVoucher ( no , pendingFiles . value [ 0 ] ! )
if ( disposed || scope !== epoch ) return
if ( ! vouchers . value . some ( value => value . fileId === file . fileId )) vouchers . value . push ( file )
pendingFiles . value . shift ()
}
} catch ( cause ) { if ( ! disposed && scope === epoch ) writeError . value = getApiErrorDetails ( cause , '凭证上传未完成;已成功上传的凭证已保留,尚未登记付款' ) }
finally { if ( scope === epoch ) uploading . value = false }
}
async function submit () {
const chosen = pending . value ? . action ?? action . value
if ( ! chosen || saving . value || uploading . value || ! allowed . value || ! access . can ( permissions [ chosen ])) return
if ( ! pending . value ) {
if ( ! cost . value || ! ( chosen === 'approve' ? canApprove . value : chosen === 'void' ? canVoid . value : canPay . value )) return
if ( chosen === 'pay' ) {
if ( form . amount == null || ! Number . isFinite ( form . amount ) || form . amount <= 0 || form . amount > ( cost . value . outstandingAmount ?? 0 )
|| ! form . paidAt || ! Number . isFinite ( Date . parse ( form . paidAt )) || ! vouchers . value . length || pendingFiles . value . length ) {
ElMessage . warning ( '请填写不超过未付余额的正数金额、实际付款时间,并完成凭证上传' ); return
}
pending . value = { action : chosen , key : crypto . randomUUID (), request : { expectedVersion : cost . value . recordVersion , amount : form . amount ,
paidAt : form . paidAt , method : form . method , referenceNo : form . referenceNo . trim () || null , voucherFileIds : vouchers . value . map ( v => v . fileId ), remark : form . remark . trim () || null } }
} else pending . value = { action : chosen , key : crypto . randomUUID (), request : { expectedVersion : cost . value . recordVersion , reason : reason . value . trim () || null } }
}
const command = pending . value , scope = epoch , no = props . costNo
saving . value = true ; writeError . value = null ; readRequest ++
try {
const result = command . action === 'pay' ? await payCompanyCost ( no , command . key , command . request ) : await commandCompanyCost ( no , command . action , command . key , command . request )
if ( disposed || scope !== epoch ) return
cost . value = result ; pending . value = null ; action . value = null ; vouchers . value = []; pendingFiles . value = []
Object . assign ( form , { amount : null , paidAt : '' , method : 'BANK_TRANSFER' , referenceNo : '' , remark : '' })
ElMessage . success ( chosen === 'pay' ? '实际付款已登记' : chosen === 'void' ? '公司费用已作废' : '公司费用已入账' )
emit ( 'changed' )
if ( tab . value !== 'current' ) void loadRecords ()
} catch ( cause ) {
if ( disposed || scope !== epoch ) return
if ( cause instanceof ApiResponseError && ( /^A04/ . test ( cause . code ) || [ 'FIN_400' , 'FIN_404' , 'FIN_409' ]. includes ( cause . code ))) pending . value = null
writeError . value = getApiErrorDetails ( cause , '财务操作未完成,请保留输入并重试' )
} finally { if ( scope === epoch ) saving . value = false }
}
async function loadRecords () {
const request = ++ recordsRequest , scope = epoch , current = tab . value
history . value = []; payments . value = []; historyTotal . value = 0 ; paymentTotal . value = 0 ; recordsError . value = null
if ( ! allowed . value || current === 'current' ) { recordsLoading . value = false ; return }
recordsLoading . value = true
try {
if ( current === 'history' ) {
const result = await listCompanyCostHistory ( props . costNo , historyPage . value )
if ( ! disposed && scope === epoch && request === recordsRequest ) { history . value = result . records ; historyTotal . value = result . total }
} else {
const result = await listCompanyCostPayments ( props . costNo , paymentPage . value )
if ( ! disposed && scope === epoch && request === recordsRequest ) { payments . value = result . records ; paymentTotal . value = result . total }
}
} catch ( cause ) { if ( ! disposed && scope === epoch && request === recordsRequest ) recordsError . value = getApiErrorDetails ( cause , '财务记录读取失败' ) }
finally { if ( scope === epoch && request === recordsRequest ) recordsLoading . value = false }
}
watch ([ tab , historyPage , paymentPage ], loadRecords )
async function preview ( fileId : string ) {
if ( ! allowed . value ) return
const target = window . open ( 'about:blank' , '_blank' )
if ( ! target ) { ElMessage . warning ( '请允许浏览器打开付款凭证' ); return }
target . opener = null ; const scope = epoch
try {
const blob = await downloadCompanyCostVoucher ( props . costNo , fileId )
if ( disposed || scope !== epoch || target . closed ) { target . close (); return }
const url = URL . createObjectURL ( blob ); target . location . href = url
const item = { target , url , timer : setInterval (() => { if ( target . closed ) { clearInterval ( item . timer ); URL . revokeObjectURL ( url ); previews . delete ( item ) } }, 1000 ) }
previews . add ( item )
} catch ( cause ) { target . close (); if ( ! disposed && scope === epoch ) writeError . value = getApiErrorDetails ( cause , '付款凭证读取失败' ) }
}
onBeforeUnmount (() => { disposed = true ; epoch ++ ; readRequest ++ ; recordsRequest ++ ; previews . forEach ( item => { clearInterval ( item . timer ); URL . revokeObjectURL ( item . url ) }); emit ( 'busy' , false ) })
</ script >
< template >
< section aria -label =" 公司成本财务处理 " >
< p v-if = "!allowed" > 暂无公司成本财务查询权限 。 </ p >
< template v-else >
< div class = "ledger-actions" >< el-button : disabled = "loading || saving || uploading" @click ="load" > 刷新费用 </ el -button >< el-button @click ="tab = 'current'" > 费用详情 </ el -button >< el-button @click ="tab = 'history'" > 操作历史 </ el -button >< el-button @click ="tab = 'payments'" > 付款流水 </ el -button ></ div >
< LoadErrorState v-if = "error" title="公司费用读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState >
< LoadErrorState v-if = "writeError" title="财务操作未完成" :message="writeError.message" @retry="pendingFiles.length ? uploadFiles() : submit()"><template #details><ApiErrorMeta :details="writeError" /></ template ></ LoadErrorState >
< p v-if = "pending" > 上次操作结果尚未确认 。 重试使用原版本 、 原内容和原请求键 。 </ p >
< template v-if = "tab === 'current' && cost" >
< p >{{ cost . costNo }} · {{ statusLabels [ cost . status ] }} · {{ cost . supplierName }}</ p >
< p >{{ cost . source . feeName }} · {{ cost . source . businessDate }} · 原币 {{ cost . source . originalCurrency }} {{ cost . source . originalAmount . toFixed ( 2 ) }}</ p >
< p > 汇率月份 {{ cost . source . exchangeRateMonth }} · 汇率 {{ cost . source . exchangeRate }} · 折算 {{ money ( cost . source . amount ) }}</ p >
< p > 已付 {{ money ( cost . paidAmount ) }} · 未付 {{ money ( cost . outstandingAmount ) }}</ p >
< div class = "ledger-actions" >< el-button v-if = "canApprove" :disabled="busy" type="primary" @click="choose('approve')">确认入账</el-button><el-button v-if="canVoid" :disabled="busy" type="danger" plain @click="choose('void')">作废费用</el-button><el-button v-if="canPay" :disabled="busy" type="primary" @click="choose('pay')" > 登记付款 </ el -button ></ div >
< el-form v-if = "action" label-position="top" :disabled="saving || uploading || !!pending" class="ledger-form" >
< template v-if = "action === 'pay'" >
< el-form-item label = "实际付款金额(CNY) " required >< el-input-number v-model = "form.amount" :min="0.01" :max="cost.outstandingAmount ?? 0" :precision="2" placeholder="请输入本次实际付款金额" /></ el-form-item >
< el-form-item label = "实际付款时间" required >< el-date-picker v-model = "form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></ el-form-item >
< el-form-item label = "付款方式" required >< el-select v-model = "form.method"><el-option v-for="(label, key) in methods" :key="key" :label="label" :value="key" /></ el-select ></ el-form-item >
< el-form-item label = "付款流水号(选填)" >< el-input v-model = "form.referenceNo" maxlength="128" placeholder="填写银行流水号或支付凭据号" /></ el-form-item >
< el-form-item label = "付款凭证" required class = "ledger-wide" >
< input v-if = "access.can(permissions.upload)" type="file" accept="image/png,image/jpeg" multiple :disabled="busy" aria-label="上传公司付款凭证" @change="selectFiles" />
< p v-else > 登记新付款需公司付款凭证上传权限 。 </ p >
< p v-for = "file in vouchers" :key="file.fileId" > 已上传 : {{ file.fileName }}</ p >
< p v-if = "pendingFiles.length" >{{ pendingFiles.length }} 张凭证待完成上传 。 </ p >
< el-button v-if = "pendingFiles.length" :disabled="busy" @click="uploadFiles" > 重试剩余凭证 </ el -button >
</ el-form-item >
< el-form-item label = "付款说明(选填)" class = "ledger-wide" >< el-input v-model = "form.remark" type="textarea" maxlength="1000" placeholder="补充本次付款用途或说明" /></ el-form-item >
</ template >
< el-form-item v-else label = "说明(选填)" class = "ledger-wide" >< el-input v-model = "reason" maxlength="1000" placeholder="补充本次入账或作废原因" /></ el-form-item >
</ el-form >
< template v-if = "action" >
< p >{{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}</ p >
< el-button :loading = "saving" : disabled = "uploading || !access.can(permissions[action])" : type = "action === 'void' ? 'danger' : 'primary'" @click ="submit" >{{ pending ? ' 重试原操作 ' : ' 确认操作 ' }} </ el -button >
< el-button :disabled = "busy" @click ="action = null" > 取消操作 </ el -button >
</ template >
</ template >
< LoadErrorState v-if = "recordsError" title="财务记录读取失败" :message="recordsError.message" @retry="loadRecords"><template #details><ApiErrorMeta :details="recordsError" /></ template ></ LoadErrorState >
2026-10-20 23:34:11 +08:00
< div class = "workbench-table" v-if = "tab === 'history'"><DataTable v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion" >
2026-10-03 12:43:25 +08:00
< template # action = "{ row }" >{{ ({ RECEIVE : '接收' , APPROVE : '入账' , VOID : '作废' , PAY : '付款' } as Record < string , string >)[ row . action ] || '—' }}</ template >
< template # time = "{ row }" >{{ time ( row . occurredAt ) }}</ template >< template # amount = "{ row }" >{{ money ( row . snapshot . source . amount ) }}</ template >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
< div class = "workbench-table" v-if = "tab === 'payments'"><DataTable v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo" >
2026-10-03 12:43:25 +08:00
< template # amount = "{ row }" >{{ money ( row . amount ) }}</ template >< template # time = "{ row }" >{{ time ( row . paidAt ) }}</ template >
< template # method = "{ row }" >{{ methods [ row . method as keyof typeof methods ] || '—' }}</ template >
< template # files = "{ row }" >< el-button v-for = "(fileId, index) in row.voucherFileIds" :key="fileId" link type="primary" @click="preview(fileId)" > 凭证 {{ Number ( index ) + 1 }} </ el -button ></ template >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
2026-10-03 12:43:25 +08:00
</ template >
</ section >
</ template >
< style scoped >
. ledger - actions { display : flex ; flex - wrap : wrap ; gap : var ( -- admin - space - 2 ); margin - bottom : var ( -- admin - space - 3 ); }
. ledger - form { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 0 var ( -- admin - space - 4 ); }
. ledger - wide { grid - column : 1 /- 1 ; }
. ledger - form : deep (. el - select ), . ledger - form : deep (. el - input - number ), . ledger - form : deep (. el - date - editor ) { width : 100 % ; }
</ style >