@@ -0,0 +1,136 @@
< script setup lang = "ts" >
import { computed , onMounted , reactive , ref } from 'vue'
import { ElMessage , type FormInstance , type FormRules } from 'element-plus'
import { FileUp , Plus , Save } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { createFinanceBill , createFinanceCost , createFinanceReceipt , listFinanceBills , listFinanceCosts , listFinanceReceipts , listFinanceReports } from '../api/finance'
import type { FinanceBillResource , FinanceCostResource , FinanceReceiptResource , FinanceReportResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
type Mode = 'bills' | 'receipts' | 'costs' | 'reports'
type TableRow = Record < string , string | number | null >
const props = defineProps < { mode : Mode } > ( )
const access = useAccessPreviewStore ( )
const rows = ref < TableRow [ ] > ( [ ] )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null > ( null )
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
const filters = reactive < QueryModel > ( { keyword : '' , customerId : '' , status : '' } )
const drawerOpen = ref ( false )
const saving = ref ( false )
const formRef = ref < FormInstance > ( )
const file = ref < File | null > ( null )
const form = reactive ( { customerId : '' , billDate : '' , amount : '' , paymentMethod : '' , referenceNo : '' , costType : 'WAYBILL' , feeName : '' , supplierName : '' , remark : '' } )
const title = computed ( ( ) => ( { bills : '客户账单' , receipts : '收款登记' , costs : '成本录入' , reports : '财务统计' } [ props . mode ] ) )
const viewPermission = computed ( ( ) => ( { bills : 'platform:finance:bill:view' , receipts : 'platform:finance:receipt:view' , costs : 'platform:finance:cost:view' , reports : 'platform:finance:report:view' } [ props . mode ] ) )
const createPermission = computed ( ( ) => ( { bills : 'platform:finance:bill:create' , receipts : 'platform:finance:receipt:create' , costs : 'platform:finance:cost:create' , reports : '' } [ props . mode ] ) )
const canView = computed ( ( ) => access . can ( viewPermission . value ) )
const canCreate = computed ( ( ) => Boolean ( createPermission . value ) && access . can ( createPermission . value ) )
const queryFields = computed < QueryField [ ] > ( ( ) => [
{ key : 'keyword' , type : 'input' , label : '关键词' , width : 220 , placeholder : '客户/单号/费用项目' , props : { 'aria-label' : '关键词' } } ,
{ key : 'customerId' , type : 'input' , label : '客户' , width : 180 , placeholder : '请输入客户名称或ID' , props : { 'aria-label' : '客户' } } ,
... ( 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 : 'VOID' } ] } ] : [ ] ) ,
] )
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 === '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 [ { key : 'customerName' , label : '客户' , minWidth : 180 } , { key : 'billedAmount' , label : '账单收入(CNY)' , width : 150 } , { key : 'receivedAmount' , label : '已收(CNY)' , width : 130 } , { key : 'costAmount' , label : '成本(CNY)' , width : 130 } , { key : 'profitAmount' , label : '利润(CNY)' , width : 130 } ]
} )
const rules : FormRules = { customerId : [ { required : true , message : '请输入客户' , trigger : 'blur' } ] , amount : [ { required : true , message : '请输入金额' , trigger : 'blur' } ] }
function mapBill ( item : FinanceBillResource ) : TableRow { return { id : item . id , billNo : item . billNo , customerName : item . customerName , billDate : item . billDate , dueDate : item . dueDate , amount : item . totalAmount , paidAmount : item . paidAmount , outstandingAmount : item . outstandingAmount , status : item . status } }
function mapReceipt ( item : FinanceReceiptResource ) : TableRow { return { id : item . id , customerName : item . customerName , receivedAt : item . receivedAt , amount : item . amount , paymentMethod : item . paymentMethod , voucherFileName : item . voucherFileName || '未上传' , allocationCount : item . allocationCount } }
function mapCost ( item : FinanceCostResource ) : TableRow { return { id : item . id , costType : item . costType , referenceNo : item . referenceNo , feeName : item . feeName , supplierName : item . supplierName || '—' , amount : item . amount , voucherFileName : item . voucherFileName || '未上传' , createdAt : item . createdAt } }
function mapReport ( item : FinanceReportResource ) : TableRow { return { customerName : item . customerName , billedAmount : item . billedAmount , receivedAmount : item . receivedAmount , costAmount : item . costAmount , profitAmount : item . profitAmount } }
async function load ( ) {
if ( ! canView . value ) return
loading . value = true ; loadError . value = null
try {
const params = { keyword : String ( filters . keyword || '' ) , customerId : String ( filters . customerId || '' ) , status : String ( filters . status || '' ) , page : page . value , size : pageSize . value }
if ( props . mode === 'bills' ) { const result = await listFinanceBills ( params ) ; rows . value = result . records . map ( mapBill ) ; total . value = result . total }
else if ( props . mode === 'receipts' ) { const result = await listFinanceReceipts ( params ) ; rows . value = result . records . map ( mapReceipt ) ; total . value = result . total }
else if ( props . mode === 'costs' ) { const result = await listFinanceCosts ( params ) ; rows . value = result . records . map ( mapCost ) ; total . value = result . total }
else { rows . value = ( await listFinanceReports ( params ) ) . map ( mapReport ) ; total . value = rows . value . length }
} catch ( error ) { rows . value = [ ] ; total . value = 0 ; loadError . value = getApiErrorDetails ( error , ` ${ title . value } 加载失败,请检查 Finance 接口后重试 ` ) }
finally { loading . value = false }
}
function applyFilters ( model : QueryModel ) { Object . assign ( filters , model ) ; page . value = 1 ; void load ( ) }
function openCreate ( ) { Object . assign ( form , { customerId : '' , billDate : new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) , amount : '' , paymentMethod : '' , referenceNo : '' , costType : 'WAYBILL' , feeName : '' , supplierName : '' , remark : '' } ) ; file . value = null ; drawerOpen . value = true }
function selectFile ( event : Event ) { const selected = ( event . target as HTMLInputElement ) . files ? . [ 0 ] || null ; file . value = selected }
async function submit ( ) {
const valid = await formRef . value ? . validate ( ) . catch ( ( ) => false ) ; if ( ! valid ) return
saving . value = true
try {
if ( props . mode === 'bills' ) await createFinanceBill ( { customerId : form . customerId , billDate : form . billDate , amount : Number ( form . amount ) , remark : form . remark } )
else if ( props . mode === 'receipts' ) await createFinanceReceipt ( { customerId : form . customerId , receivedAt : form . billDate , amount : Number ( form . amount ) , paymentMethod : form . paymentMethod , allocations : form . referenceNo . split ( ',' ) . map ( ( billId ) => ( { billId : billId . trim ( ) , amount : Number ( form . amount ) } ) ) . filter ( ( item ) => item . billId ) } , file . value || undefined )
else await createFinanceCost ( { costType : form . costType , referenceNo : form . referenceNo , feeName : form . feeName , supplierName : form . supplierName , amount : Number ( form . amount ) , remark : form . remark } , file . value || undefined )
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 ) }
finally { saving . value = false }
}
onMounted ( ( ) => void load ( ) )
< / script >
< template >
< AdminPage as = "div" class = "finance-page" spacing = "normal" >
< el-result v-if = "!canView" icon="warning" title="无权访问财务模块" sub-title="请联系管理员开通平台财务权限" / >
< 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 >
< 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 = "props.mode === 'reports' ? undefined : total" :preference-key = "`finance-${props.mode}-table`" :aria-label = "`${title}列表`" row -key = " id " empty -text = " 暂无数据 " >
< 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 > < span class = "muted-value" > 人工台账 < / span > < / template >
< template # status = "{ row }" > < el-tag : type = "row.status === 'PAID' ? 'success' : row.status === 'VOID' ? 'info' : 'warning'" > { { row . status === 'DRAFT' ? '草稿' : row . status === 'ISSUED' ? '已出账' : row . status === 'PARTIALLY_PAID' ? '部分收款' : row . status === 'PAID' ? '已收清' : '作废' } } < / el-tag > < / template >
< template # costType = "{ row }" > { { row . costType === 'CONTAINER' ? '整柜公共成本' : '单票成本' } } < / template >
< template # empty > < el-empty description = "暂无数据;后端接口接通后将显示真实财务记录" / > < / template >
< template # pagination -left > 币种 : CNY · 数据由平台财务人工维护 < / template >
< / 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" >
< FormSection title = "基本信息" description = "首期只支持 CNY,由平台财务人工填写并留存凭证。" >
< div class = "form-grid two-cols" >
< el-form-item label = "客户" prop = "customerId" > < el-input v-model = "form.customerId" placeholder="请输入客户名称或ID" / > < / el-form-item >
< el-form-item label = "日期" > < el-date-picker v-model = "form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" / > < / 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="WAYBILL" /><el-option label="整柜公共成本" value="CONTAINER" / > < / 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 !== 'bills'" label="关联单号/账单ID"><el-input v-model="form.referenceNo" placeholder="多个账单用逗号分隔" / > < / el-form-item >
< el-form-item v-if = "props.mode === 'receipts'" label="收款方式"><el-input v-model="form.paymentMethod" placeholder="例如:银行转账" / > < / el-form-item >
< el-form-item v-if = "props.mode === 'costs'" label="费用项目"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" / > < / el-form-item >
< el-form-item v-if = "props.mode === 'costs'" label="供应商/成本对象"><el-input v-model="form.supplierName" placeholder="请输入成本对象" / > < / 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="当前按人工上传凭证设计;后端未提供接口时会明确返回失败,不使用假数据。" >
< label class = "file-picker" > < FileUp :size = "16" / > { { file ? . name || '选择凭证图片' } } < input type = "file" accept = "image/*,.pdf" @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" @click ="submit" > < Save :size = "16" / > 保存 < / el-button > < / div > < / template >
< / AppDrawer >
< / AdminPage >
< / template >
< style scoped >
. finance - page { min - width : 0 ; }
. file - picker { display : inline - flex ; align - items : center ; gap : 8 px ; padding : 10 px 14 px ; border : 1 px dashed var ( -- el - border - color ) ; border - radius : 6 px ; cursor : pointer ; color : var ( -- cc - text ) ; }
. file - picker input { display : none ; }
. drawer - footer { display : flex ; justify - content : flex - end ; gap : 10 px ; }
@ media ( max - width : 720 px ) { : deep ( . form - grid . two - cols ) { grid - template - columns : 1 fr ; } }
< / style >