This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
+17
View File
@@ -0,0 +1,17 @@
/* Finance owns document layout; controls and visual primitives stay shared. */
.admin-density .detail-page-layout.finance-density-detail { gap: 0; }
.admin-density .finance-density-detail > .detail-page-layout__header,
.admin-density .finance-density-detail > .detail-page-layout__summary { padding: 0; }
.admin-density .finance-density-detail > .detail-page-layout__content,
.admin-density .finance-density-detail > .detail-page-layout__footer { min-width: 0; padding: var(--admin-space-5); }
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
.admin-density .finance-detail-context { min-width: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
.admin-density .statement-summary-section { padding: var(--admin-space-5); }
.admin-density .account-metrics .is-negative,
.admin-density .density-detail-overview .is-danger,
.admin-density-dialog .partial-summary .is-danger { color: var(--el-color-danger); }
.admin-density .density-detail-overview .is-success,
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="account-metrics"')
expect(page).toContain('class="density-detail-overview account-metrics"')
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
expect(page).toContain('class="ledger-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -82,14 +82,14 @@ describe('FinanceCustomerAccountsView delivery', () => {
})
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('density-detail-overview account-metrics')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
expect(page).toContain('min-height: 64px')
expect(page).toContain('padding: 0 0 12px')
expect(page).toContain('class="density-detail-header"')
expect(page).toContain('padding: var(--admin-space-4) 0')
expect(page).toContain('class="detail-table-scroll"')
expect(page).toContain('class="ledger-identifier"')
expect(page).toContain('class="ledger-document"')
@@ -100,21 +100,21 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps account detail and term editing readable across the 1920 workspace', () => {
expect(page).toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)')
expect(page).toContain('.finance-account-detail__layout :deep(.detail-page-layout__content)')
expect(page).toContain('padding: 0 14px 10px')
expect(page).toContain('finance-density-detail')
expect(page).toContain('../financeDensity.css')
expect(page).not.toContain('max-width: 520px')
})
it('keeps high-frequency account actions visible and groups the remaining actions', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }")
expect(page).toContain('<el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button>')
expect(page).toContain('@click="openAccount(row, false)">详情</el-button>')
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
expect(page).toContain('aria-label="更多客户账户操作"')
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit"><CreditCard :size="15" />授信</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust"><Pencil :size="15" />账期</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item>')
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
expect(page).toContain('.row-actions {')
expect(page).toContain('class="row-actions density-actions"')
expect(page).toContain('white-space: nowrap')
})
@@ -124,6 +124,23 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).not.toContain('adjustmentAccount?.actualBalance -')
})
it.each([
[[], ['详情'], false],
[['platform:finance:customer-terms:view', 'platform:finance:customer-terms:update'], ['详情', '账期'], false],
[['platform:finance:customer-account:adjust'], ['详情', '充值', '扣款', ''], true],
])('folds account operations only when more than three are available: %j', async (extraPermissions, labels, overflow) => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', ...extraPermissions])
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
const trigger = actions.get('button[aria-label="更多客户账户操作"]')
expect(trigger.text()).toBe('')
expect(trigger.find('svg').exists()).toBe(true)
}
wrapper.unmount()
})
it('selects an active customer by name from master data', () => {
expect(page).toContain("key: 'customerId', type: 'select'")
expect(page).toContain("label: '客户名称'")
@@ -176,12 +193,12 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics')
expect(metrics.findAll('.account-metric')).toHaveLength(6)
expect(metrics.text()).toContain('实际余额¥126580.50CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50CNY')
expect(metrics.text()).toContain('授信额度¥20000.002028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00CNY')
expect(metrics.text()).toContain('可用授信¥20000.00CNY')
expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00 CNY')
expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 1, customerId: '10001' })
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
@@ -294,7 +311,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
exportType: 'FINANCE_CUSTOMER_ACCOUNT_LEDGER', format: 'XLSX', taskName: '客户账户流水',
parameters: { customerId: '10001', businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' },
})
expect(showExportTaskCreatedDialog).toHaveBeenLastCalledWith({ taskName: '客户账户流水', taskNo: 'EXP-2' })
expect(showExportTaskCreatedDialog).toHaveBeenLastCalledWith({ taskName: '客户账户流水', taskNo: 'EXP-2', dense: true })
detailWrapper.unmount()
})
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, Eye, MinusCircle, MoreHorizontal, Pencil, PlusCircle, Save } from '@lucide/vue'
import { ArrowLeft, CreditCard, Download, MinusCircle, MoreHorizontal, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -356,7 +358,7 @@ function customerField(value: unknown) { return String(value || '').trim() || '
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
}
async function exportAccounts() {
@@ -415,7 +417,7 @@ onBeforeUnmount(clearVoucherPreview)
</script>
<template>
<AdminPage as="div" class="finance-account-page" spacing="normal">
<AdminPage as="div" class="finance-account-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
@@ -423,7 +425,7 @@ onBeforeUnmount(clearVoucherPreview)
<section v-if="!stateError" 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-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button><el-button v-if="canAdjust" size="small" plain type="primary" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')"><PlusCircle :size="15" />充值</el-button><el-button v-if="canAdjust" size="small" plain type="danger" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')"><MinusCircle :size="15" />扣款</el-button><el-dropdown v-if="canAdjust || canUpdateTerms" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button size="small" plain aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit"><CreditCard :size="15" />授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust"><Pencil :size="15" />账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="neutral" class="density-action" size="small" @click="openAccount(row, false)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')">充值</el-button><el-button data-tone="danger" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')">扣款</el-button><el-button class="density-action" data-tone="neutral" v-if="!canAdjust && canUpdateTerms" size="small" @click="handleAccountMore('terms', row as FinanceAccountSummary)">账期</el-button><el-dropdown v-if="canAdjust" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button data-tone="neutral" class="density-action" size="small" aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -434,47 +436,47 @@ onBeforeUnmount(clearVoucherPreview)
</DataTable>
</section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · CNY 资金账户</p></div><div v-if="selected && !editing" class="detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><span>实际余额</span><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong><small>CNY</small></div><div class="account-metric is-frozen"><span>冻结金额</span><strong>{{ money(selected.frozenBalance) }}</strong><small>CNY</small></div><div class="account-metric is-available"><span>总可用余额</span><strong>{{ money(selected.availableBalance) }}</strong><small>CNY</small></div><div class="account-metric"><span>授信额度</span><strong>{{ money(selected.creditLimit) }}</strong><small>{{ creditExpiryLabel(selected) }}</small></div><div class="account-metric is-frozen"><span>已用授信</span><strong>{{ money(selected.creditUsed) }}</strong><small>CNY</small></div><div class="account-metric"><span>可用授信</span><strong>{{ money(selected.availableCredit) }}</strong><small>CNY</small></div></section></template>
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section" title="充值申请" :bordered="false">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><el-button v-if="canReviewRecharge && row.status === 'PENDING'" size="small" type="primary" plain @click="openReview(row as FinanceRechargeRequest)">审核</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template><template #paymentMethod="{ row }">{{ paymentMethodLabel(row.paymentMethod) }}</template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
</DataTable></div>
</FormSection>
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
<template v-if="editing && selected && !ledgerError" #footer><div class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<el-alert :title="adjustmentForm.type === 'RECHARGE' ? '充值会先归还已用授信,剩余金额计入实际余额。' : '扣款先使用未冻结的实际余额,不足部分再使用可用授信。'" :type="adjustmentForm.type === 'RECHARGE' ? 'info' : 'warning'" :closable="false" />
<el-form label-position="top" class="wallet-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
<el-form label-position="top" class="wallet-form density-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
<el-form label-position="top" class="wallet-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-descriptions v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
<el-form label-position="top" class="review-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
<div v-loading="voucherPreviewLoading" class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div>
</AppDialog>
</AdminPage>
@@ -501,79 +503,11 @@ onBeforeUnmount(clearVoucherPreview)
margin: 0 auto;
}
.finance-account-detail__layout :deep(.detail-page-layout) {
gap: 0;
}
.finance-account-detail__layout :deep(.detail-page-layout__header) {
min-height: 64px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.finance-account-detail__layout :deep(.detail-page-layout__summary) {
padding: 10px 16px 0;
}
.finance-account-detail__layout :deep(.detail-page-layout__content) {
padding: 0 16px 12px;
}
.finance-account-detail__layout :deep(.detail-page-layout__footer) {
padding: 0 16px 12px;
}
.row-actions,
.detail-footer,
.detail-heading,
.detail-actions,
.term-control {
display: flex;
align-items: center;
gap: 8px;
}
.row-actions .el-button {
margin-left: 0;
}
.row-actions {
white-space: nowrap;
}
.detail-heading {
width: 100%;
align-items: center;
}
.detail-title {
min-width: 0;
}
.detail-heading h2 {
margin: 0;
color: var(--cc-text);
font-size: 20px;
line-height: 1.4;
}
.detail-heading p {
margin: 3px 0 0;
color: var(--cc-muted);
font-size: 13px;
}
.detail-back {
margin-left: auto;
}
.detail-actions {
margin-left: auto;
}
.detail-actions + .detail-back {
margin-left: 0;
gap: var(--admin-space-3);
}
.balance-value {
@@ -590,7 +524,6 @@ onBeforeUnmount(clearVoucherPreview)
}
.balance-value.is-negative,
.account-metric strong.is-negative,
.credit-expired {
color: var(--el-color-danger);
}
@@ -608,63 +541,12 @@ onBeforeUnmount(clearVoucherPreview)
object-fit: contain;
}
.account-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.account-metric {
min-width: 0;
padding: 0 12px;
}
.account-metric:first-child {
padding-left: 0;
}
.account-metric:nth-child(4) {
padding-left: 0;
border-left: 0;
}
.account-metric + .account-metric {
border-left: 1px solid var(--cc-border, #e3e8ef);
}
.account-metric::before {
display: none;
}
.account-metric span {
display: block;
color: var(--cc-muted);
font-size: 12px;
}
.account-metric strong {
display: inline-block;
margin-top: 6px;
color: var(--cc-text);
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.account-metric small {
margin-left: 6px;
color: var(--cc-muted);
font-size: 11px;
}
.ledger-section {
padding: 10px 0 0;
padding: var(--admin-space-4) 0 0;
}
.recharge-section {
padding: 10px 0 0;
padding: var(--admin-space-4) 0 0;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
@@ -673,7 +555,7 @@ onBeforeUnmount(clearVoucherPreview)
}
.ledger-query {
margin-bottom: 8px;
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
@@ -699,20 +581,9 @@ onBeforeUnmount(clearVoucherPreview)
font-variant-numeric: tabular-nums;
}
:deep(.ledger-table .data-table__toolbar.is-compact) {
min-height: 36px;
padding: 0 0 6px;
border-bottom: 0;
}
:deep(.ledger-table .data-table__pagination) {
min-height: 40px;
padding: 6px 10px;
}
.term-section {
width: 100%;
padding: 10px 0;
padding: var(--admin-space-4) 0;
}
.term-editor {
@@ -720,13 +591,13 @@ onBeforeUnmount(clearVoucherPreview)
grid-template-columns: 120px minmax(0, 220px);
justify-content: start;
align-items: center;
gap: 14px;
gap: var(--admin-space-5);
padding: 2px 0;
}
.term-label {
color: var(--cc-text);
font-size: 14px;
font-size: var(--admin-font-section);
font-weight: 600;
}
@@ -740,13 +611,13 @@ onBeforeUnmount(clearVoucherPreview)
.wallet-form,
.review-form {
margin-top: 18px;
margin-top: var(--admin-space-5);
}
.wallet-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
gap: 0 var(--admin-space-5);
}
.wallet-form :deep(.el-date-editor),
@@ -758,47 +629,11 @@ onBeforeUnmount(clearVoucherPreview)
width: 100%;
justify-content: flex-end;
margin-top: 0;
padding-top: 8px;
padding-top: var(--admin-space-3);
border-top: 0;
}
@media (min-width: 1920px) {
.finance-account-detail__layout :deep(.detail-page-layout) {
gap: 8px;
}
.finance-account-detail__layout :deep(.detail-page-layout__header) {
min-height: 56px;
padding: 10px 14px;
}
.finance-account-detail__layout :deep(.detail-page-layout__summary) {
padding: 8px 14px 0;
}
.finance-account-detail__layout :deep(.detail-page-layout__content) {
padding: 0 14px 10px;
}
.finance-account-detail__layout :deep(.detail-page-layout__footer) {
padding: 0 14px 10px;
}
.finance-account-detail__layout :deep(.form-section) {
padding: 10px 0;
}
.finance-account-detail__layout :deep(.form-section__heading) {
margin-bottom: 8px;
}
.account-metrics {
gap: 8px;
}
.account-metric {
padding: 0 10px;
}
.detail-footer {
padding-top: 0;
@@ -100,6 +100,33 @@ describe('FinanceCustomerArapView G2 operations', () => {
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
})
it.each([
['DRAFT', ['详情', '编辑', '提交审核', ''], true],
['APPROVED', ['详情', '调整', '作废'], false],
['PENDING', ['详情', '作废'], false],
])('keeps fee-source actions reachable at the three-action boundary: %s', async (reviewStatus, labels, overflow) => {
const row = { ...feeSummary, sourceSystem: 'FINANCE', sourceType: 'MANUAL', reviewStatus } as FinanceFeeSourceSummary
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
const trigger = actions.get('button[title="更多费用来源操作"]')
expect(trigger.text()).toBe('')
await trigger.trigger('click')
await flushPromises()
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')].find(item => item.textContent?.trim() === '作废')
expect(item).toBeTruthy()
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
item!.click()
await flushPromises()
expect(document.body.textContent).toContain('作废费用')
expect(voidFinanceFeeSource).not.toHaveBeenCalled()
}
})
it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list'])
await loadRows(wrapper)
@@ -212,7 +239,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('restores the independent detail route from its fee source id', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(wrapper.text()).toContain('费用来源详情')
expect(wrapper.get('.density-detail-title').text()).toContain('运费')
expect(wrapper.text()).toContain('IB-91')
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
})
@@ -237,27 +264,29 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(page).toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)')
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)')
expect(page).toContain('.finance-arap-detail__layout :deep(.detail-page-layout__content)')
expect(page).toContain('finance-density-detail')
expect(page).not.toContain('max-width: 520px')
})
it('renders all permitted fee-source row operations as small plain primary buttons with icons', () => {
it('renders permitted fee-source row operations as shared outlined text buttons', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('<el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
expect(page).toContain('@click="openDetail(row)">详情</el-button>')
expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
expect(page).toContain('!isUpstreamSource(row)')
expect(page).toContain('!isUpstreamSource(detail)')
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
expect(page).toContain("=== 'LASTLEG'")
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
const actions = page.slice(page.indexOf('<template #actions'), page.indexOf('<template #customerName'))
expect(actions).not.toMatch(/<el-button[^>]*\blink\b/)
expect(page).toContain('white-space: nowrap')
})
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
expect(wrapper.text()).toContain('运费 · 客户A')
expect(wrapper.get('.density-detail-title').text()).toContain('运费')
expect(wrapper.get('.finance-detail-context').text()).toBe('客户A')
expect(wrapper.text()).toContain('其他费用来源')
expect(wrapper.text()).not.toContain('其他来源')
expect(wrapper.text()).not.toContain('NEW_SOURCE')
@@ -322,7 +351,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: '运费',
},
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户应收应付费用来源', taskNo: 'EXP-3' })
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户应收应付费用来源', taskNo: 'EXP-3', dense: true })
})
it('does not expose fee-source export without download-center access', async () => {
@@ -1,12 +1,15 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { MoreHorizontal, ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
@@ -61,6 +64,13 @@ const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const detail = ref<FinanceFeeSourceDetail | null>(null)
const operationLogRows = computed(() => (detail.value?.operationLogs || []).map((log, index) => ({ ...log, rowKey: index })))
const operationLogColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '时间', width: 170, slotName: 'occurredAt' },
{ key: 'action', label: '操作类型', minWidth: 120, slotName: 'action' },
{ key: 'operatorName', label: '操作人', minWidth: 120, slotName: 'operatorName' },
{ key: 'result', label: '备注 / 结果', minWidth: 280, slotName: 'result' },
]
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const reviewOpen = ref(false)
@@ -126,6 +136,18 @@ function closeCustomerSelectOnScroll() {
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
function sourceActionCount(row: Record<string, unknown>) {
if (!row.feeSourceId) return 0
const available = row.status === 'AVAILABLE'
const draft = ['DRAFT', 'REJECTED'].includes(String(row.reviewStatus))
const editable = canEditSource.value && !isUpstreamSource(row)
return Number(canViewSource.value)
+ Number(editable && draft && available)
+ Number(canEditSource.value && draft && available)
+ Number(canReviewSource.value && row.reviewStatus === 'PENDING' && available)
+ Number(editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated)
+ Number(editable && available && !row.statementGenerated)
}
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
@@ -334,7 +356,7 @@ async function exportFeeSources() {
...(keyword ? { keyword } : {}),
},
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户应收应付导出任务创建失败')) }
finally { exportBusy.value = false }
@@ -365,7 +387,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
</script>
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<AdminPage as="div" class="finance-arap-page admin-density" spacing="normal">
<OperationErrorToast :details="reviewError || mutationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
@@ -374,7 +396,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><template v-if="row.feeSourceId"><el-button data-tone="neutral" class="density-action" v-if="canViewSource" size="small" @click="openDetail(row)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'EDIT')">编辑</el-button><el-button data-tone="warning" class="density-action" v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'SUBMIT')">提交审核</el-button><el-button data-tone="warning" class="density-action" v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" @click="openReview(row)">审核</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" @click="openMutation(row, 'ADJUST')">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) <= 3" size="small" @click="openMutation(row, 'VOID')">作废</el-button><el-dropdown v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) > 3" trigger="click"><el-button class="density-action" data-tone="neutral" size="small" title="更多费用来源操作" :aria-label="`更多费用来源操作 · ${row.documentNo || '当前费用'}`"><MoreHorizontal :size="15" aria-hidden="true" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item class="danger-menu-item" @click="openMutation(row, 'VOID')">作废</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
@@ -382,26 +404,27 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情">
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>结算金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>来源状态</span><strong>{{ sourceStatusDisplay(detail.sourceStatus) }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-form class="density-form" label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" placeholder="请输入调整金额" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form class="density-form" label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" placeholder="请输入调整金额" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
.finance-arap-page {
min-width: 0;
}
@@ -441,141 +464,35 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
margin: 0 auto;
}
.finance-arap-detail__layout :deep(.detail-page-layout) {
gap: 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
min-height: 64px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
padding: 10px 16px 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
padding: 0 16px 12px;
}
.row-actions {
.generation-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.row-actions .el-button {
margin-left: 0;
}
.generation-actions,
.detail-heading,
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-heading {
min-width: 0;
align-items: flex-start;
}
.detail-title {
min-width: 0;
}
.detail-heading h2 {
margin: 0;
font-size: 18px;
}
.detail-heading p {
overflow: hidden;
margin: 3px 0 0;
color: var(--cc-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-actions {
margin-left: auto;
gap: var(--admin-space-3);
}
.muted-action {
color: var(--cc-muted);
}
.arap-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.arap-metric {
min-width: 0;
padding: 0 12px;
}
.arap-metric:first-child {
padding-left: 0;
}
.arap-metric + .arap-metric {
border-left: 1px solid var(--cc-border, #e3e8ef);
}
.arap-metric span {
display: block;
color: var(--cc-muted);
font-size: 12px;
}
.arap-metric strong {
display: inline-block;
margin-top: 6px;
color: var(--cc-text);
font-size: 20px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.arap-metric small {
margin-left: 6px;
color: var(--cc-muted);
font-size: 11px;
}
.arap-metric.is-amount strong {
color: #4f46e5;
}
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: 16px;
gap: var(--admin-space-6);
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.arap-information,
.arap-settlement,
.operation-log {
padding: 10px 0;
padding: var(--admin-space-4) 0;
}
.arap-settlement {
padding-left: 16px;
padding-left: var(--admin-space-6);
border-left: 1px solid var(--cc-border, #e3e8ef);
}
.operation-log {
min-height: 150px;
}
:deep(.arap-detail .el-descriptions__content) {
min-width: 0;
@@ -588,7 +505,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
}
.operation-log :deep(.el-empty) {
padding: 10px 0 2px;
padding: var(--admin-space-4) 0 2px;
}
.operation-log :deep(.el-empty__image) {
@@ -597,50 +514,18 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
.operation-log small {
display: block;
margin-top: 4px;
margin-top: var(--admin-space-1);
color: var(--cc-muted);
}
@media (min-width: 1920px) {
.finance-arap-detail__layout :deep(.detail-page-layout) {
gap: 8px;
}
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
min-height: 56px;
padding: 10px 14px;
}
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
padding: 8px 14px 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
padding: 0 14px 10px;
}
.finance-arap-detail__layout :deep(.form-section) {
padding: 10px 0;
}
.finance-arap-detail__layout :deep(.form-section__heading) {
margin-bottom: 8px;
}
.arap-metrics {
gap: 8px;
}
.arap-metric {
padding: 0 10px;
}
.arap-detail-grid {
gap: 10px;
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: 10px;
padding-left: var(--admin-space-4);
}
}
@@ -111,17 +111,19 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull()
})
it('uses the prototype-style wide summary grid at 1920px', () => {
it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
expect(source).toContain('grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))')
expect(source).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
expect(source).not.toContain('<el-descriptions :column="3" border>')
const shared = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
const moduleCss = readFileSync(resolve(process.cwd(), 'src/modules/finance/financeDensity.css'), 'utf8')
expect(source).toContain('class="density-fields statement-summary-grid"')
expect(shared).toContain('repeat(4, minmax(0, 1fr))')
expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }')
})
it('uses entity buttons for row actions and only folds the fourth action into more', () => {
@@ -130,9 +132,10 @@ describe('FinanceCustomerStatementsView', () => {
expect(actions).not.toContain(' link')
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
expect(actionLine).toContain('size="small" plain type="primary"')
expect(actionLine).toContain('class="density-action"')
expect(actionLine).toContain('size="small"')
}
expect(actions).toContain('size="small" plain type="danger"')
expect(actions).toContain('data-tone="danger"')
expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
expect(actions.match(/<el-dropdown-item/g)).toHaveLength(1)
@@ -151,10 +154,10 @@ describe('FinanceCustomerStatementsView', () => {
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废')
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
expect(detailButton?.classList).toContain('el-button--small')
expect(detailButton?.classList).toContain('is-plain')
expect(detailButton?.classList).toContain('el-button--primary')
expect(adjustButton?.classList).toContain('el-button--primary')
expect(voidButton?.classList).toContain('el-button--danger')
expect(detailButton?.classList).toContain('density-action')
expect(detailButton?.getAttribute('data-tone')).toBe('neutral')
expect(adjustButton?.classList).toContain('density-action')
expect(voidButton?.getAttribute('data-tone')).toBe('danger')
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
vm.actionBusy = true
@@ -302,7 +305,7 @@ describe('FinanceCustomerStatementsView', () => {
lastPaymentFrom: '2026-08-20', lastPaymentTo: '2026-08-21',
},
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' })
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '客户对账单', taskNo: 'EXP-STMT-1', dense: true })
expect(getReconciliationStatement).not.toHaveBeenCalledWith('501')
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
@@ -399,7 +402,7 @@ describe('FinanceCustomerStatementsView', () => {
const { router } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501')
await flushPromises()
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
expect(document.body.textContent).toContain('客户对账单详情')
expect(document.querySelector('[aria-label="客户对账单详情"]')).toBeTruthy()
expect(document.body.textContent).toContain('返回客户对账单')
expect(document.body.textContent).toContain('明细加载失败')
expect(document.body.textContent).toContain('重试')
@@ -1,12 +1,15 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
import { ArrowLeft, Download, MoreHorizontal, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -369,7 +372,7 @@ async function exportStatements() {
taskName: '客户对账单',
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
@@ -505,7 +508,7 @@ async function createStatement() {
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionBusy.value = true
@@ -571,50 +574,42 @@ watch(detailRouteId, (statementId) => {
</script>
<template>
<AdminPage as="div" class="finance-statements-page" spacing="normal">
<AdminPage as="div" class="finance-statements-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page" aria-label="客户对账单详情">
<template #header>
<header class="statement-detail-page__header">
<div><h2>客户对账单详情</h2><p>{{ detail?.billNo || '正在加载对账单信息' }}</p></div>
<el-button @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button>
</header>
</template>
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary>
<div class="statement-detail-head">
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<FormSection class="statement-summary-section" title="对账单概要" aria-label="对账单摘要">
<div class="statement-summary-grid">
<div class="statement-summary-item"><span>总应收金额</span><strong>{{ detail.totalAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>已收金额</span><strong>{{ detail.paidAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>未收金额</span><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>账期</span><strong>{{ detail.creditDays }} 天</strong></div>
<div class="statement-summary-item"><span>费用明细 / 来源</span><strong>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</strong></div>
<div class="statement-summary-item"><span>调整前金额</span><strong>{{ detailSourceAmount().toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>调整金额</span><strong>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>货币</span><strong>{{ detail.currency }}</strong></div>
<div class="statement-summary-item"><span>是否结清</span><strong>{{ detail.settled ? '是' : '否' }}</strong></div>
<div class="statement-summary-item"><span>创建人</span><strong>{{ operatorDisplayName(detail.createdByName) }}</strong></div>
<div class="statement-summary-item"><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
<div class="statement-summary-item"><span>提交时间</span><strong>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</strong></div>
<div class="statement-summary-item"><span>审核时间</span><strong>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</strong></div>
<div class="statement-summary-item"><span>出账时间</span><strong>{{ formatDateTime(revisionTime(['ISSUE'])) }}</strong></div>
<div class="statement-summary-item"><span>客户确认状态</span><strong>{{ financeStatusLabel(detail.customerConfirmationStatus || 'NOT_REQUIRED') }}</strong></div>
<div class="statement-summary-item"><span>客户确认人</span><strong>{{ operatorDisplayName(detail.customerConfirmedByName) }}</strong></div>
<div class="statement-summary-item"><span>客户确认时间</span><strong>{{ formatDateTime(detail.customerConfirmedAt) }}</strong></div>
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
</div>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>账期</dt><dd>{{ detail.creditDays }} 天</dd></div>
<div class="statement-summary-item"><dt>费用明细 / 来源</dt><dd>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</dd></div>
<div class="statement-summary-item"><dt>调整前金额</dt><dd>{{ detailSourceAmount().toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>调整金额</dt><dd>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>货币</dt><dd>{{ detail.currency }}</dd></div>
<div class="statement-summary-item"><dt>是否结清</dt><dd>{{ detail.settled ? '是' : '否' }}</dd></div>
<div class="statement-summary-item"><dt>创建人</dt><dd>{{ operatorDisplayName(detail.createdByName) }}</dd></div>
<div class="statement-summary-item"><dt>创建时间</dt><dd>{{ formatDateTime(detail.createdAt) }}</dd></div>
<div class="statement-summary-item"><dt>提交时间</dt><dd>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</dd></div>
<div class="statement-summary-item"><dt>审核时间</dt><dd>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</dd></div>
<div class="statement-summary-item"><dt>出账时间</dt><dd>{{ formatDateTime(revisionTime(['ISSUE'])) }}</dd></div>
<div class="statement-summary-item"><dt>客户确认状态</dt><dd>{{ financeStatusLabel(detail.customerConfirmationStatus || 'NOT_REQUIRED') }}</dd></div>
<div class="statement-summary-item"><dt>客户确认人</dt><dd>{{ operatorDisplayName(detail.customerConfirmedByName) }}</dd></div>
<div class="statement-summary-item"><dt>客户确认时间</dt><dd>{{ formatDateTime(detail.customerConfirmedAt) }}</dd></div>
<div class="statement-summary-item"><dt>更新人</dt><dd>{{ operatorDisplayName(detail.updatedByName) }}</dd></div>
<div class="statement-summary-item"><dt>更新时间</dt><dd>{{ formatDateTime(detail.updatedAt) }}</dd></div>
<div class="statement-summary-item statement-summary-item--remark"><dt>备注</dt><dd>{{ detail.remark || '—' }}</dd></div>
</dl>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
<el-tab-pane label="账单明细表" name="lines">
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
@@ -633,8 +628,8 @@ watch(detailRouteId, (statementId) => {
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
</FormSection>
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<FormSection v-if="detail" title="支付历史" :bordered="false">
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
@@ -659,21 +654,21 @@ watch(detailRouteId, (statementId) => {
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }">
<el-button size="small" plain type="primary" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
<el-button v-if="row.status === 'DRAFT' && canCreate" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')"><XCircle :size="15" />驳回</el-button>
<template #actions="{ row }"><div class="density-actions">
<el-button data-tone="neutral" class="density-action" size="small" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'DRAFT' && canCreate" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')">提交</el-button>
<el-button data-tone="warning" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')">通过</el-button>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')">驳回</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button size="small" plain :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item"><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
<el-button data-tone="neutral" class="density-action" size="small" :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item">作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="row.status === 'APPROVED' && canIssue" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
<el-button v-if="row.status === 'ISSUED' && canAdjust" size="small" plain type="primary" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</template>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'APPROVED' && canIssue" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')">出账</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'ISSUED' && canAdjust" size="small" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)">调整</el-button>
<el-button data-tone="neutral" class="density-action" v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')">重开</el-button>
<el-button data-tone="danger" class="density-action" v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
</div></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
@@ -695,9 +690,9 @@ watch(detailRouteId, (statementId) => {
</template>
</ListPageLayout>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<el-form label-position="top">
<el-form class="density-form" label-position="top">
<el-form-item label="生成方式">
<el-radio-group v-model="createForm.generationMode">
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
@@ -720,8 +715,8 @@ watch(detailRouteId, (statementId) => {
</el-form>
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<el-form class="density-form" label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></el-form>
<el-table :data="adjustLines" size="small" max-height="420" border stripe><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
</AppDialog>
</AdminPage>
@@ -730,87 +725,31 @@ watch(detailRouteId, (statementId) => {
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.selected-source-summary { width: 100%; margin-top: 6px; color: var(--cc-text-secondary); font-size: 12px; text-align: right; }
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-6); }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); color: var(--cc-text-secondary); font-size: var(--admin-font-caption); text-align: right; }
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-page__header h2 { margin: 0; color: var(--cc-text-primary); font-size: 20px; }
.statement-detail-page__header p { margin: 4px 0 0; color: var(--cc-text-secondary); font-size: 13px; }
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
.statement-detail-head h2 { margin: 2px 0 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.statement-summary-section { padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px 24px; }
.statement-summary-item { min-width: 0; }
.statement-summary-item span, .statement-summary-item strong { display: block; overflow-wrap: anywhere; }
.statement-summary-item span { margin-bottom: 3px; color: var(--cc-text-secondary); font-size: 12px; }
.statement-summary-item strong { color: var(--cc-text-primary); font-size: 14px; line-height: 1.45; }
.statement-summary-item--remark { grid-column: span 2; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--cc-border-subtle); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
.statement-detail-tabs :deep(.form-section) { padding: 12px 16px 12px; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: 12px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) {
.statement-detail-page :deep(.detail-page-layout) {
gap: 8px;
}
.statement-detail-page :deep(.detail-page-layout__header) {
min-height: 56px;
padding: 10px 14px;
}
.statement-detail-page__header h2 {
font-size: 18px;
}
.statement-detail-head {
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
padding: 8px 14px;
}
.statement-summary-section {
padding: 8px 14px;
}
.statement-summary-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px 24px;
}
.statement-summary-item--remark {
grid-column: span 2;
}
.statement-detail-tabs :deep(.el-tabs__header) {
padding: 0 14px;
}
.statement-detail-tabs :deep(.form-section) {
padding: 8px 14px;
}
.statement-detail-tabs :deep(.form-section__heading) {
margin-bottom: 8px;
padding: var(--admin-space-3) var(--admin-space-5);
}
.detail-table-scroll {
margin-bottom: 8px;
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: 8px;
margin-top: var(--admin-space-3);
}
}
@@ -128,7 +128,7 @@ describe('formal settlement download-center exports', () => {
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_RECEIVABLE_SETTLEMENT_DETAIL', format: 'XLSX', taskName: '应收结算单详情', parameters: { settlementId: '701' },
})
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1' })
expect(showExportTaskCreatedDialog).toHaveBeenCalledWith({ taskName: '应收结算单详情', taskNo: 'EXP-DETAIL-1', dense: true })
expect(router.currentRoute.value.name).toBe('download-center')
})
@@ -8,7 +8,7 @@ const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/financ
describe('Finance formal settlement desktop presentation', () => {
it('keeps settlement totals on the left and list actions on the right of the table toolbar', () => {
expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<div class="settlement-summary" aria-label="结算汇总">')
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">')
expect(source).not.toContain('class="settlement-list-toolbar"')
@@ -26,24 +26,21 @@ describe('Finance formal settlement desktop presentation', () => {
}
})
it('renders up to three row actions as compact icon buttons without wrapping', () => {
it('renders at most three permitted row actions as shared outlined text buttons', () => {
expect(source.match(/key: 'actions', label: '操作', width: 250/g)).toHaveLength(2)
expect(source).toContain('class="settlement-row-actions"')
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
expect(source).toContain('size="small" type="primary" :loading="receiptBusy" @click="openReceipt(row)"')
expect(source).toContain('size="small" type="primary" @click="openPayment(row)"')
expect(source).toContain('size="small" plain type="danger" :loading="actionBusy"')
expect(source).toContain('size="small" plain type="primary" :loading="actionBusy"')
expect(source).toContain('.settlement-row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; white-space: nowrap; }')
expect(source).not.toContain('<el-button link')
expect(source).toContain('class="settlement-row-actions density-actions"')
for (const action of ['openDetail(row)', 'openReceipt(row)', 'openPayment(row)']) expect(source).toContain('@click="' + action + '"')
expect(source).toContain('data-tone="danger"')
expect(source).toContain('class="density-action"')
expect(source).not.toContain('MoreHorizontal')
expect(source).toContain('v-if="canReceive && [\'ISSUED\',\'PARTIALLY_PAID\'].includes(String(row.status))"')
expect(source).toContain('v-if="canPay && [\'ISSUED\',\'PARTIALLY_PAID\'].includes(String(row.status))"')
expect(source).toContain('v-if="canVoid && row.status === \'ISSUED\'"')
expect(source).toContain("props.kind === 'payable' && access.can('platform:finance:payment:create')")
expect(source).toContain("props.kind === 'receivable' && access.can('platform:finance:receipt:create')")
expect(source).toContain("canVoid && row.status === 'ISSUED'")
expect(source).toContain("canVoid && row.status === 'VOIDED'")
})
it('uses a continuous detail layout with locally scrollable wide tables', () => {
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail"')
expect(source).toContain('class="settlement-detail-content"')
expect(source).toContain('class="detail-table-scroll"')
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
@@ -65,7 +62,7 @@ describe('Finance formal settlement desktop presentation', () => {
it('keeps receipt and payment dialogs readable at desktop width', () => {
expect(source.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(source).toContain('.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(source.match(/class="density-detail-overview partial-summary"/g)).toHaveLength(2)
expect(source).toContain('.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(source).toContain('<OperationErrorToast :message="receiptValidationError" />')
expect(source).toContain('<OperationErrorToast :details="receiptError" />')
@@ -1,13 +1,16 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { categoryLabels, listSuppliers, type Supplier } from '@/modules/suppliers'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
@@ -437,6 +440,7 @@ async function submitPayment() {
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
customClass: 'admin-density-confirm',
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
}).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return
@@ -481,7 +485,7 @@ async function createSettlementExport(detailId?: string) {
taskName,
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, `${taskName}导出任务创建失败`))
@@ -502,38 +506,31 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
<AdminPage as="div" class="finance-settlement-page admin-density" spacing="normal">
<OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" />
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel" :aria-label="`${title}详情`">
<template #header><header class="settlement-detail-header">
<div class="settlement-detail-heading">
<el-button aria-label="返回列表" title="返回列表" @click="backToList"><ArrowLeft :size="17" /></el-button>
<div><div class="settlement-detail-breadcrumb">财务管理 / {{ title }} / {{ detail?.settlementNo || settlementId }}</div><h1>{{ title }}详情</h1></div>
<el-tag v-if="detail" :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<div class="settlement-detail-actions">
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail" :aria-label="`${title}详情`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" aria-label="返回列表" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.settlementNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div><div class="finance-detail-actions">
<el-button v-if="detail && canExport" :loading="exportSubmitting" :disabled="exportSubmitting" @click="exportDetail"><Download :size="15" />导出</el-button>
<el-button v-if="detail && canReceive && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" :loading="receiptBusy" @click="openReceiptFromDetail"><HandCoins :size="15" />部分收款</el-button>
<el-button v-if="detail && canPay && ['ISSUED','PARTIALLY_PAID'].includes(detail.status)" type="primary" @click="openPaymentFromDetail"><CreditCard :size="15" />部分付款</el-button>
<el-button v-if="detail && canVoid && detail.status === 'ISSUED'" type="danger" :loading="actionBusy" @click="performAction(detailActionRow(), 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div>
</header></template>
</div></div></template>
<template v-if="detail" #summary>
<section class="settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</span><strong>{{ money(detail.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div>
<div class="summary-item"><span>未结余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div>
<div class="summary-item"><span>到期日</span><strong class="is-date">{{ displayDate(detail.dueDate) }}</strong></div>
</section>
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div>
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div>
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div>
</dl>
</template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content">
<FormSection title="基本信息">
<el-descriptions :column="4" class="settlement-detail-info">
<FormSection class="density-form-section" title="基本信息">
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
<el-descriptions-item v-if="kind === 'payable'" label="客户">{{ customerName(detail.customerId, detail.customerName) }}</el-descriptions-item>
@@ -550,7 +547,7 @@ onMounted(() => {
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions>
</FormSection>
<FormSection title="费用明细">
<FormSection class="density-form-section" title="费用明细">
<div class="detail-table-scroll">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
@@ -563,7 +560,7 @@ onMounted(() => {
</el-table>
</div>
</FormSection>
<FormSection :title="kind === 'receivable' ? '收款记录' : '付款记录'">
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'">
<div class="detail-table-scroll">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
@@ -577,7 +574,7 @@ onMounted(() => {
</el-table>
</div>
</FormSection>
<FormSection title="修订记录" :bordered="false">
<FormSection class="density-form-section" title="修订记录" :bordered="false">
<div class="detail-table-scroll">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
@@ -602,11 +599,11 @@ onMounted(() => {
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-start>
<div class="settlement-summary" aria-label="结算汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
</div>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl>
</template>
<template #toolbar-actions>
<div class="settlement-list-actions">
@@ -615,12 +612,12 @@ onMounted(() => {
</div>
</template>
<template #actions="{ row }">
<div class="settlement-row-actions">
<el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" type="primary" :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
<el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" type="primary" @click="openPayment(row)"><CreditCard :size="15" />付款</el-button>
<el-button v-if="canVoid && row.status === 'ISSUED'" size="small" plain type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="canVoid && row.status === 'VOIDED'" size="small" plain type="primary" :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
<div class="settlement-row-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" :loading="receiptBusy" @click="openReceipt(row)">收款</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" @click="openPayment(row)">付款</el-button>
<el-button data-tone="danger" class="density-action" v-if="canVoid && row.status === 'ISSUED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')">作废</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canVoid && row.status === 'VOIDED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')">重开</el-button>
</div>
</template>
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
@@ -644,23 +641,23 @@ onMounted(() => {
</template>
</ListPageLayout>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form class="density-form" label-position="top">
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
</el-form>
</AppDialog>
<AppDialog v-model="paymentOpen" title="登记付款" width="min(760px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>供应商</span><strong>{{ detailPartyName() }}</strong></div><div><span>应付金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已付金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form class="settlement-operation-form" label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="paymentOpen" title="登记付款" width="min(760px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>供应商</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应付金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已付金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
</AppDialog>
<AppDialog v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
<el-form class="settlement-operation-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></el-form>
<AppDialog class="admin-density-dialog" v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>客户公司</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应收金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已收金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></el-form>
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
</AppDialog>
</AdminPage>
@@ -669,115 +666,50 @@ onMounted(() => {
<style scoped>
.finance-settlement-page { min-width: 0; }
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-summary { gap: 6px 0; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: 8px; }
.settlement-summary { gap: var(--admin-space-2) 0; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
.settlement-row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; white-space: nowrap; }
.settlement-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.settlement-summary .summary-item + .summary-item { margin-left: 18px; padding-left: 18px; border-left: 1px solid var(--cc-border, #e3e8ef); }
.summary-item span { color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.summary-item strong { color: var(--cc-text-primary, #1f2937); font-size: 18px; line-height: 1.2; }
.summary-item strong.is-success { color: #16803c; }
.summary-item strong.is-danger { color: #c2413a; }
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
.settlement-detail-header { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 14px; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-heading, .settlement-detail-actions { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
.settlement-detail-heading h1 { margin: 0; font-size: 20px; line-height: 1.35; letter-spacing: 0; }
.settlement-detail-breadcrumb { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.settlement-detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-summary .summary-item { align-items: flex-start; flex-direction: column; gap: 4px; padding: 0 12px; border-left: 1px solid var(--cc-border, #e3e8ef); }
.settlement-detail-summary .summary-item:first-child { padding-left: 0; border-left: 0; }
.summary-item strong.is-date { font-size: 20px; }
.settlement-detail-state { margin: 14px 16px; }
.settlement-detail-content { min-width: 0; padding: 0 16px 12px; overflow: hidden; }
.settlement-detail-actions { display: flex; align-items: center; gap: var(--admin-space-4); min-width: 0; flex-wrap: wrap; }
.settlement-detail-state { margin: var(--admin-space-5) var(--admin-space-6); }
.settlement-detail-content { min-width: 0; padding: 0 var(--admin-space-6) var(--admin-space-5); overflow: hidden; }
.settlement-detail-info { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-bottom: 12px; padding: 10px 0; border: 1px solid var(--el-color-primary-light-7); border-radius: 6px; background: var(--el-color-primary-light-9); }
.partial-summary div { min-width: 0; padding: 0 10px; border-left: 1px solid var(--el-color-primary-light-7); }
.partial-summary div:first-child { border-left: 0; }
.partial-summary span, .partial-summary strong { display: block; overflow-wrap: anywhere; }
.partial-summary span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.partial-summary strong { margin-top: 4px; color: var(--cc-text-primary, #1f2937); font-size: 15px; }
.partial-summary strong.is-success { color: #16803c; }
.partial-summary strong.is-danger { color: #c2413a; }
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 14px; row-gap: 0; }
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: 0; }
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border-radius: 8px; color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
.remaining-preview strong { color: #b45309; font-size: 16px; }
.operation-error { margin-bottom: 12px; }
.operation-error-meta { margin: -6px 0 12px; }
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); padding: var(--admin-space-3) var(--admin-space-5); border-radius: var(--admin-radius); color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
.remaining-preview strong { color: #b45309; font-size: var(--admin-font-section); }
.operation-error { margin-bottom: var(--admin-space-5); }
.operation-error-meta { margin: -var(--admin-space-2) 0 var(--admin-space-5); }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (min-width: 1920px) {
.finance-settlement-page {
row-gap: 8px;
}
.settlement-summary .summary-item {
gap: 6px;
}
.settlement-summary .summary-item + .summary-item {
margin-left: 12px;
padding-left: 12px;
}
.summary-item strong {
font-size: 17px;
row-gap: var(--admin-space-3);
}
.settlement-detail-page {
gap: 0;
}
.settlement-detail-header {
min-height: 56px;
padding: 10px 14px;
}
.settlement-detail-summary {
padding: 8px 14px;
}
.settlement-detail-summary .summary-item {
padding: 0 10px;
}
.settlement-detail-summary .summary-item strong {
margin-top: 2px;
font-size: 18px;
}
.settlement-detail-state {
margin: 10px 14px;
margin: var(--admin-space-4) var(--admin-space-5);
}
.settlement-detail-content {
padding: 0 14px 10px;
padding: 0 var(--admin-space-5) var(--admin-space-4);
}
.detail-table-scroll {
margin-bottom: 8px;
}
.partial-summary {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
margin-bottom: 8px;
padding: 8px 0;
}
.partial-summary div {
padding: 0 8px;
margin-bottom: var(--admin-space-3);
}
.settlement-operation-form {
column-gap: 12px;
column-gap: var(--admin-space-5);
}
}
@media (max-width: 720px) {
@@ -3,6 +3,8 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const density = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
const financeDensity = readFileSync(resolve(process.cwd(), 'src/modules/finance/financeDensity.css'), 'utf8')
const financeRoutes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
const accounts = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
@@ -22,7 +24,7 @@ describe('finance presentation governance', () => {
expect(financeRoutes).toContain("finance/receivable-statistics")
expect(financeRoutes).toContain("platform:finance:receivable-statistics:list")
expect(financeRoutes).toContain('showPageHeading: false')
expect(settlement).toContain('class="settlement-detail-page surface-panel"')
expect(settlement).toContain('class="settlement-detail-page surface-panel finance-density-detail"')
expect(settlement).toContain('返回列表')
expect(settlement).not.toContain('AppDrawer')
expect(settlement).toContain('登记后剩余余额')
@@ -30,32 +32,27 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('remainingAfter(paymentForm.amount)')
})
it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => {
expect(settlement).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
it('uses one continuous desktop detail surface with shared summaries and scroll-safe tables', () => {
expect(settlement).toContain('class="settlement-detail-page surface-panel finance-density-detail"')
expect(settlement).toContain('class="settlement-detail-content"')
expect(settlement).toContain('.settlement-detail-header { display: flex; min-height: 68px;')
expect(settlement).toContain('align-items: center;')
expect(settlement).toContain('justify-content: space-between')
expect(settlement).toContain('<el-descriptions :column="4" class="settlement-detail-info">')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain(`title="${title}"`)
expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll"/g)).toHaveLength(3)
expect(settlement).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form"/g)).toHaveLength(2)
expect(settlement).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(settlement).toContain('column-gap: 14px;')
expect(settlement).toContain('column-gap: var(--admin-space-5);')
})
it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="settlement-summary" aria-label="结算汇总"')
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"')
expect(settlement).toContain('.settlement-summary .summary-item + .summary-item')
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }')
expect(settlement).not.toContain('class="settlement-list-toolbar"')
})
@@ -110,8 +107,8 @@ describe('finance presentation governance', () => {
it('translates source codes and provides reason placeholders', () => {
expect(arap).toContain('sourceTypeDisplay(detail.sourceType)')
expect(arap).toContain('financeOperationActionLabel(log.action)')
expect(arap).toContain('financeOperationResultLabel(log.result)')
expect(arap).toContain('financeOperationActionLabel(row.action)')
expect(arap).toContain('financeOperationResultLabel(row.result)')
expect(arap).toContain('financeDirectionLabel(row.direction)')
expect(arap).toContain('financeDirectionLabel(detail.direction)')
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
@@ -138,10 +135,10 @@ describe('finance presentation governance', () => {
})
it('opens statement details on a compact dedicated route instead of below the list', () => {
expect(statements).toContain('v-else-if="isDetailRoute" class="surface-panel statement-detail-page"')
expect(statements).toContain('v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail"')
expect(statements).toContain('@row-click="openDetail"')
expect(statements).toContain("name: 'finance-customer-statement-detail'")
expect(statements).toContain('.statement-detail-page :deep(.detail-page-layout) { gap: 0; }')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(statements).toContain('返回客户对账单')
expect(statements).not.toContain('aria-label="所选对账单联动明细"')
expect(statements).not.toContain('核对账期、金额和结清状态后查看下方业务明细。')
@@ -149,13 +146,10 @@ describe('finance presentation governance', () => {
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
})
it('uses a compact financial summary row for customer accounts', () => {
expect(accounts).toContain('.finance-account-detail__layout :deep(.detail-page-layout)')
expect(accounts).toContain('gap: 0;')
expect(accounts).toContain('.account-metric + .account-metric')
expect(accounts).toContain('border-left: 1px solid')
expect(accounts).toContain('.account-metric::before')
expect(accounts).toContain('display: none;')
it('uses a shared compact financial summary row for customer accounts', () => {
expect(accounts).toContain('class="density-detail-overview account-metrics"')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(density).toContain('.density-detail-overview')
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
expect(accounts).toContain('padding: 0;')
expect(accounts).toContain('border: 0;')
@@ -165,15 +159,12 @@ describe('finance presentation governance', () => {
})
it('keeps fee-source details continuous without nested metric and section cards', () => {
expect(arap).toContain('.finance-arap-detail__layout :deep(.detail-page-layout)')
expect(arap).toContain('.arap-metric + .arap-metric')
expect(arap).toContain('border-left: 1px solid')
expect(arap).toContain('.arap-settlement {')
expect(arap).toContain('padding-left: 16px;')
expect(arap).toContain('class="density-detail-overview arap-metrics"')
expect(arap).toContain('class="arap-information density-form-section"')
expect(arap).toContain('class="arap-settlement density-form-section"')
expect(arap).toContain('padding-left: var(--admin-space-6);')
expect(arap).toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
expect(arap).not.toContain('description="费用来源与关联业务摘要"')
expect(arap).not.toContain('description="当前审核和出账状态"')
expect(arap).not.toContain('description="按时间查看审核与结算调整记录"')
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
})
})
@@ -1,4 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -76,7 +78,7 @@ async function exportCurrent() {
try {
const task = await createPlatformExportTask({ exportType: 'FINANCE_RECEIVABLE_STATISTICS', format: 'XLSX', taskName: `应收款统计表-${month.value}`,
parameters: { month: month.value, ...(customerId.value ? { customerId: customerId.value } : {}) } })
const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (open) await router.push({ name: 'download-center' })
} catch (reason) { ElMessage.error(getApiErrorMessage(reason, '应收款统计导出任务创建失败')) }
finally { exporting.value = false }
@@ -87,25 +89,25 @@ onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page" spacing="normal">
<AdminPage class="receivable-statistics-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="surface-panel statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
<div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div>
</section>
<LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<section class="statistics-summary" aria-label="CNY应收汇总">
<div><span>应收总额</span><strong>CNY {{ money(result?.totalReceivable) }}</strong></div>
<div><span>已收合计</span><strong class="is-received">CNY {{ money(result?.totalReceived) }}</strong></div>
<div><span>余额未收</span><strong class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</strong></div>
</section>
<dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl>
<section class="surface-panel statistics-table">
<header><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable>
@@ -116,24 +118,19 @@ onMounted(() => { void loadCustomers(); void load() })
</template>
<style scoped>
.statistics-query { display: flex; align-items: end; gap: 12px; padding: 16px; }
.query-field { display: grid; min-width: 220px; gap: 6px; color: var(--cc-muted); font-size: 13px; }
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-body); }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: 8px; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.statistics-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.statistics-summary > div { padding: 16px; border: 1px solid var(--cc-border, #e3e8ef); border-radius: 8px; background: var(--cc-surface, #fff); }
.statistics-summary span { display: block; margin-bottom: 8px; color: var(--cc-muted); font-size: 13px; }
.statistics-summary strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.statistics-summary .is-received { color: var(--el-color-success); }
.statistics-summary .is-outstanding, .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.statistics-table > header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.statistics-table h2 { margin: 0; font-size: 16px; }
.statistics-table header span { color: var(--cc-muted); font-size: 13px; }
.statistics-table > header { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-table header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
@media print {
.statistics-query, :deep(.data-table__pagination) { display: none !important; }
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; color: #000; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
}
@@ -10,18 +10,3 @@ function openDetail(id: string) { void router.push({ name: 'finance-settlement-p
function back() { void router.push({ name: 'finance-settlement-payables' }) }
</script>
<template><FinanceFormalSettlementPage class="finance-settlement-route finance-settlement-route--payable" kind="payable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
<style scoped>
@media (min-width: 1920px) {
.finance-settlement-route :deep(.settlement-detail-page) { width: 100%; max-width: none; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__table > tbody) {
display: grid;
grid-template-columns: minmax(110px, .35fr) minmax(0, 1fr) minmax(110px, .35fr) minmax(0, 1fr);
column-gap: 20px;
row-gap: 10px;
}
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__table > tbody > tr) { display: contents; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__label) { min-width: 0; white-space: nowrap; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; }
}
</style>
@@ -10,18 +10,3 @@ function openDetail(id: string) { void router.push({ name: 'finance-settlement-r
function back() { void router.push({ name: 'finance-settlement-receivables' }) }
</script>
<template><FinanceFormalSettlementPage class="finance-settlement-route finance-settlement-route--receivable" kind="receivable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
<style scoped>
@media (min-width: 1920px) {
.finance-settlement-route :deep(.settlement-detail-page) { width: 100%; max-width: none; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__table > tbody) {
display: grid;
grid-template-columns: minmax(110px, .35fr) minmax(0, 1fr) minmax(110px, .35fr) minmax(0, 1fr);
column-gap: 20px;
row-gap: 10px;
}
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__table > tbody > tr) { display: contents; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__label) { min-width: 0; white-space: nowrap; }
.finance-settlement-route :deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; }
}
</style>
@@ -9,11 +9,15 @@ describe('Finance settlement route 1920px layout', () => {
it.each([
['应收', receivableSource, 'finance-settlement-route--receivable'],
['应付', payableSource, 'finance-settlement-route--payable'],
])('uses the full detail workspace and a readable two-field information grid for %s', (_label, source, routeClass) => {
])('delegates the detail grid to the shared settlement page for %s', (_label, source, routeClass) => {
expect(source).toContain(routeClass)
expect(source).toContain('@media (min-width: 1920px)')
expect(source).toContain('.finance-settlement-route :deep(.settlement-detail-page) { width: 100%; max-width: none; }')
expect(source).toContain('grid-template-columns: minmax(110px, .35fr) minmax(0, 1fr) minmax(110px, .35fr) minmax(0, 1fr)')
expect(source).toContain('> tbody > tr) { display: contents; }')
expect(source).toContain('<FinanceFormalSettlementPage')
expect(source).toContain(':settlement-id="settlementId"')
expect(source).toContain('@open-detail="openDetail" @back="back"')
expect(source).toContain('kind="' + (routeClass.endsWith('receivable') ? 'receivable' : 'payable') + '"')
expect(source).not.toContain('<style')
const shared = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
expect(shared).toContain(':column="4" class="settlement-detail-info density-descriptions"')
expect(shared).toContain('finance-density-detail')
})
})