This commit is contained in:
2026-08-11 19:59:15 +08:00
parent c690a30422
commit 41618847b7
19 changed files with 117 additions and 142 deletions
@@ -108,7 +108,7 @@ describe('CustomerDetailView', () => {
await flushPromises() await flushPromises()
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([ expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([
'基本信息', '业务概况', '运单记录', '财务账单', '操作日志', '计费规则', '基本信息', '业务概况', '运单记录', '计费规则',
]) ])
expect(document.body.textContent).toContain('企业用户1人') expect(document.body.textContent).toContain('企业用户1人')
expect(document.body.textContent).toContain('1 人正常使用') expect(document.body.textContent).toContain('1 人正常使用')
@@ -2,8 +2,8 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye, ArrowLeft, BarChart3, Box, Building2, ClipboardList, Eye,
FileCheck2, FileText, History, Pencil, Power, ReceiptText, FileCheck2, FileText, Pencil, Power,
Scale, Ship, UserRound, UsersRound, Scale, Ship, UserRound, UsersRound,
} from '@lucide/vue' } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -38,7 +38,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue' import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue' import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing' type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean } type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute() const route = useRoute()
@@ -74,8 +74,6 @@ const tabs: Array<{ key: DetailTab; label: string; icon: typeof ClipboardList }>
{ key: 'basic', label: '基本信息', icon: ClipboardList }, { key: 'basic', label: '基本信息', icon: ClipboardList },
{ key: 'business', label: '业务概况', icon: BarChart3 }, { key: 'business', label: '业务概况', icon: BarChart3 },
{ key: 'orders', label: '运单记录', icon: Box }, { key: 'orders', label: '运单记录', icon: Box },
{ key: 'finance', label: '财务账单', icon: CircleDollarSign },
{ key: 'logs', label: '操作日志', icon: History },
{ key: 'billing', label: '计费规则', icon: Scale }, { key: 'billing', label: '计费规则', icon: Scale },
] ]
@@ -385,7 +383,7 @@ watch(orderPage, (page, previousPage) => {
</dl> </dl>
</section> </section>
<section class="detail-panel user-panel"> <section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div> <div class="panel-title"><div><h2>企业用户</h2><p>当前客户的登录账号</p></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap"> <div v-if="enterpriseMembers.length" class="user-table-wrap">
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead> <table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)"> <tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
@@ -427,8 +425,6 @@ watch(orderPage, (page, previousPage) => {
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div> <div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
</section> </section>
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section> <section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div> </div>
</DetailPageLayout> </DetailPageLayout>
@@ -455,7 +451,7 @@ watch(orderPage, (page, previousPage) => {
.overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; } .overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; }
.overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; } .overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; }
.overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; } .overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; }
.detail-tabs { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); } .detail-tabs { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
.detail-tabs button { position:relative; display:flex; min-width:128px; min-height:64px; align-items:center; justify-content:center; gap:8px; padding:0 14px; border:0; background:transparent; color:#667085; font-weight:650; cursor:pointer; } .detail-tabs button { position:relative; display:flex; min-width:128px; min-height:64px; align-items:center; justify-content:center; gap:8px; padding:0 14px; border:0; background:transparent; color:#667085; font-weight:650; cursor:pointer; }
.detail-tabs button::after { position:absolute; right:0; bottom:0; left:0; height:3px; background:transparent; content:""; } .detail-tabs button::after { position:absolute; right:0; bottom:0; left:0; height:3px; background:transparent; content:""; }
.detail-tabs button:hover { background:#fafbff; color:#344054; }.detail-tabs button.active { background:#f2f3ff; color:var(--cc-primary); }.detail-tabs button.active::after { background:var(--cc-primary); } .detail-tabs button:hover { background:#fafbff; color:#344054; }.detail-tabs button.active { background:#f2f3ff; color:var(--cc-primary); }.detail-tabs button.active::after { background:var(--cc-primary); }
@@ -484,7 +480,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
.business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; } .business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; }
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; } .contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; } .qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } } @media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(4,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } } @media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } }
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } } @media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
</style> </style>
@@ -120,7 +120,7 @@ const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
const filters = ref<CustomerFilters>({ keyword: '', status: '' }) const filters = ref<CustomerFilters>({ keyword: '', status: '' })
const queryFields: QueryField[] = [ const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户ID或联系人' }, { key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户编号或联系人' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [ { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' }, { label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' }, { label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
@@ -188,7 +188,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [ const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 280, slotName: 'actions' }, { key: 'actions', label: '操作', minWidth: 280, slotName: 'actions' },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户ID', minWidth: 170 }, { key: 'tenantCode', prop: 'tenantCode', label: '客户编号', minWidth: 170 },
{ key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' }, { key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) }, { key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
@@ -1062,7 +1062,7 @@ onMounted(() => { void loadCustomers() })
</el-dialog> </el-dialog>
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting"> <el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户ID:{{ passwordTenant.tenantCode }}</span></div> <div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert> <el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top"> <el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item> <el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
@@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
</el-dialog> </el-dialog>
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting"> <el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户ID:{{ impersonationTenant.tenantCode }}</span></div> <div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert> <el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
<el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert> <el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert>
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form> <el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
@@ -28,12 +28,11 @@ const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorag
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE')) const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE')) const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const filters = reactive({ keyword: '' }) const filters = ref({ keyword: '' })
const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }] const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }]
const columns = [ const columns = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const }, { key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerId', prop: 'customerId', label: '客户ID', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 90 }, { key: 'currency', prop: 'currency', label: '币种', width: 90 },
{ key: 'storage', slotName: 'storage', label: '仓租(CBM/天)', minWidth: 300 }, { key: 'storage', slotName: 'storage', label: '仓租(CBM/天)', minWidth: 300 },
{ key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费(托/天)', minWidth: 300 }, { key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费(托/天)', minWidth: 300 },
@@ -48,7 +47,7 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') } catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
finally { loading.value = false } finally { loading.value = false }
} }
function search() { keyword.value = filters.keyword; page.value = 1; void load() } function search() { keyword.value = filters.value.keyword; page.value = 1; void load() }
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true } function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) } function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) } function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
@@ -4,7 +4,7 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8') const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8')
describe('CustomsCasesView', () => { describe('CustomsCasesView', () => {
it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }) it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") })
it('supports create, detail, history and agent submission', () => { expect(source).toContain('createCustomsCase'); expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) it('supports detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') })
it('localizes backend statuses and timeline actions throughout the page', () => { it('localizes backend statuses and timeline actions throughout the page', () => {
expect(source).toContain('customsCaseStatusOptions') expect(source).toContain('customsCaseStatusOptions')
expect(source).toContain(':status="customsStatusLabel(row.status)"') expect(source).toContain(':status="customsStatusLabel(row.status)"')
@@ -14,19 +14,11 @@ describe('CustomsCasesView', () => {
expect(source).toContain("detail.value?.customsCase.status === 'READY_TO_SUBMIT'") expect(source).toContain("detail.value?.customsCase.status === 'READY_TO_SUBMIT'")
expect(source).not.toContain("['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED']") expect(source).not.toContain("['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED']")
}) })
it('selects business resources instead of asking operators for customer and order ids', () => { it('does not expose internal id inputs while resource query APIs are unavailable', () => {
expect(source).toContain('listPlatformTenants') expect(source).not.toContain('createCustomsCase')
expect(source).toContain('listPlatformUnifiedOrders') expect(source).not.toContain('新增报关')
expect(source).toContain('listContainers') expect(source).not.toContain('资料清单版本 ID')
expect(source).toContain('selectOrder') expect(source).not.toContain("key: 'orderId', type: 'input'")
expect(source).toContain('重新加载选项') expect(source).not.toContain("key: 'inboundId', type: 'input'")
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="订单 ID"')
expect(source).not.toContain('label="入库单 ID"')
})
it('only exposes active customers in create selectors', () => {
expect(source).toContain("customers.value.filter((customer) => customer.status === 'ACTIVE')")
expect(source).toContain('v-for="customer in activeCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
}) })
}) })
+8 -35
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage } from 'element-plus'
import { Eye, Plus, Send } from '@lucide/vue' import { Eye, Send } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue' import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
@@ -13,46 +13,19 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from '../status' import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from '../status'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders' import type { CustomsCaseDetail, CustomsCaseResource } from '../types'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0) const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) }] const filters = ref<QueryModel>({ status: '' }); const fields: QueryField[] = [{ key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单 ID', minWidth: 155 }, { prop: 'inboundId', label: '入库单 ID', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }] const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单标识', minWidth: 155 }, { prop: 'inboundId', label: '入库单标识', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, status: String(filters.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } } async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, status: String(filters.value.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 } function search() { if (page.value === 1) void load(); else page.value = 1 }
const createOpen = ref(false); const createSaving = ref(false); const createError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>(); const createForm = reactive<CustomsCaseCreateRequest>({ customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 })
const customers = ref<PlatformTenantResource[]>([]); const orders = ref<UnifiedOrderResource[]>([]); const containers = ref<ContainerBookingResource[]>([]); const optionsLoading = ref(false); const ordersLoading = ref(false); const optionsError = ref('')
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const rules: FormRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], orderId: [{ required: true, message: '请选择订单', trigger: 'change' }], inboundId: [{ required: true, message: '所选订单尚未生成入库单', trigger: 'change' }], checklistVersionId: [{ required: true, message: '请输入资料清单版本 ID', trigger: 'blur' }] }
async function loadCreateOptions() {
optionsLoading.value = true; optionsError.value = ''
const [customerResult, containerResult] = await Promise.allSettled([listPlatformTenants({ page: 1, size: 200 }), listContainers({ page: 1, size: 200 })])
if (customerResult.status === 'fulfilled') customers.value = customerResult.value.records; else optionsError.value = '客户列表加载失败'
if (containerResult.status === 'fulfilled') containers.value = containerResult.value.records; else optionsError.value += `${optionsError.value ? ';' : ''}柜次列表加载失败`
optionsLoading.value = false
}
async function reloadCreateOptions() { await loadCreateOptions(); if (createForm.customerId) await selectCustomer(createForm.customerId) }
async function selectCustomer(customerId: string) {
createForm.customerId = customerId; createForm.orderId = ''; createForm.inboundId = ''; orders.value = []
if (!customerId) return
ordersLoading.value = true
try { const result = await listPlatformUnifiedOrders({ customerId, page: 1, size: 200 }); orders.value = result.records.filter((item) => item.inboundId != null) }
catch { optionsError.value = '该客户的订单列表加载失败,请重新加载选项' }
finally { ordersLoading.value = false }
}
function selectOrder(orderId: string) { createForm.orderId = orderId; createForm.inboundId = String(orders.value.find((item) => String(item.orderId) === orderId)?.inboundId || '') }
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; orders.value = []; createOpen.value = true; void loadCreateOptions() }
async function create() { if (!await formRef.value?.validate().catch(() => false)) return; createSaving.value = true; createError.value = null; try { await createCustomsCase({ ...createForm }); ElMessage.success('报关协同已创建'); createOpen.value = false; await load() } catch (cause) { createError.value = getApiErrorDetails(cause) } finally { createSaving.value = false } }
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value?.customsCase.status === 'READY_TO_SUBMIT' && access.can('platform:firstleg:customs:submit')) const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value?.customsCase.status === 'READY_TO_SUBMIT' && access.can('platform:firstleg:customs:submit'))
async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } } async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } }
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } } async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
watch([page, pageSize], () => void load()); onMounted(load) watch([page, pageSize], () => void load()); onMounted(load)
</script> </script>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout> <template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="createOpen" title="新增报关协同" width="720px" :confirm-loading="createSaving" @confirm="create"><el-alert v-if="createError" type="error" :closable="false"><ApiErrorMeta :details="createError" /></el-alert><el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading || ordersLoading" @click="reloadCreateOptions">重新加载选项</el-button></el-alert><el-form ref="formRef" :model="createForm" :rules="rules" label-position="top"><el-row :gutter="16"><el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="createForm.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 / 入库单" prop="orderId"><el-select :model-value="createForm.orderId" filterable placeholder="请先选择客户,再选择已入库订单" :loading="ordersLoading" :disabled="!createForm.customerId" style="width:100%" @update:model-value="selectOrder(String($event || ''))"><el-option v-for="order in orders" :key="String(order.orderId)" :label="`${order.orderNo || order.orderId} / ${order.inboundCode || order.inboundId}`" :value="String(order.orderId)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select v-model="createForm.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%"><el-option v-for="container in containers" :key="container.id" :label="`${container.cabinetNo || container.bookingNo}${container.containerNo ? ` / ${container.containerNo}` : ''}`" :value="container.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" placeholder="请输入已发布的资料清单版本 ID" /><small>当前服务端尚未提供资料清单版本查询接口。</small></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" placeholder="请输入报关协同备注(可选)" /></el-form-item></el-col></el-row></el-form></AppDialog>
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template> <AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style> <style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
@@ -17,7 +17,7 @@ import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types' import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const filters = reactive<QueryModel>({ keyword: '', status: '' }) const filters = ref<QueryModel>({ keyword: '', status: '' })
const rows = ref<FinanceAccountSummary[]>([]) const rows = ref<FinanceAccountSummary[]>([])
const loading = ref(false) const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
@@ -55,13 +55,13 @@ const columns: DataTableColumn[] = [
{ key: 'lastChangedAt', label: '最近变动', width: 170 }, { key: 'lastChangedAt', label: '最近变动', width: 170 },
] ]
async function load(model: QueryModel = filters) { async function load(model: QueryModel = filters.value) {
Object.assign(filters, model) Object.assign(filters.value, model)
if (!canView.value) return if (!canView.value) return
loading.value = true loading.value = true
loadError.value = null loadError.value = null
try { try {
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') }) const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.value.keyword || '').trim(), status: String(filters.value.status || '') })
rows.value = result.records rows.value = result.records
total.value = result.total total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') } } catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
@@ -119,7 +119,7 @@ onMounted(() => void load())
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState> <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"> <template v-else-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions> <el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection> <FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection> <FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template> </template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template> <template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
@@ -14,7 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance' import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status' import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types' import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow { interface ArapRow {
@@ -38,7 +38,7 @@ const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([]) const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false) const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null) const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' }) const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const allRows = ref<ArapRow[]>([]) const allRows = ref<ArapRow[]>([])
const loading = ref(false) const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null) const loadError = ref<ApiErrorDetails | null>(null)
@@ -77,7 +77,7 @@ const columns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
] ]
const filteredRows = computed(() => { const filteredRows = computed(() => {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const direction = String(filters.direction || ''); const status = String(filters.status || '') const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.status || '')
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword)))) return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
}) })
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)) const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
@@ -88,13 +88,13 @@ async function loadCustomers() {
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') } catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false } finally { customersLoading.value = false }
} }
async function load(model: QueryModel = filters) { async function load(model: QueryModel = filters.value) {
Object.assign(filters, model); page.value = 1; allRows.value = []; loadError.value = null Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
const customerId = String(filters.customerId || ''); if (!customerId) return const customerId = String(filters.value.customerId || ''); if (!customerId) return
const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}` const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}`
loading.value = true loading.value = true
try { try {
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | '' const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId, direction || undefined) const items = await listFinanceFeePool(customerId, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt })) allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') } } catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
@@ -157,7 +157,7 @@ onMounted(() => void loadCustomers())
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template> <template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<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="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付"> <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="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template> <template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template> <template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template> <template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section> </DataTable></section>
@@ -165,7 +165,7 @@ onMounted(() => void loadCustomers())
<AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading"> <AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading">
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <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"> <template v-else-if="detail">
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ detail.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions> <el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section> <section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template> </template>
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template> <template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue' import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -14,14 +14,14 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import { getFinanceBill, listFinanceStatements } from '../api/finance' import { getFinanceBill, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status' import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types' import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = reactive<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null) const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = ref<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null)
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }] const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } } async function loadCustomers() { customerError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } } async function load(model: QueryModel = filters.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.value.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } }
async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } } async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } }
onMounted(() => void loadCustomers()) onMounted(() => void loadCustomers())
</script> </script>
<template> <template>
<AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #toolbar-actions><el-button type="primary" disabled title="接口尚未提供,暂不可操作"><Plus :size="16" />生成对账单</el-button></template><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template><template #pagination-left>币种:CNY · 生成、出账、调整、作废待后端 G1-G4 契约冻结</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions><el-alert class="blocked-alert" type="info" :closable="false" title="对账单生成与状态操作接口尚未提供,当前仅支持真实详情查询。" /></template></AppDrawer></AdminPage> <AdminPage as="div" class="finance-statements-page" spacing="normal"><el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" /><ListPageLayout v-else aria-label="客户对账单"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template><template #pagination-left>币种:CNY</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions></template></AppDrawer></AdminPage>
</template> </template>
<style scoped>.finance-statements-page{min-width:0}.blocked-alert{margin-top:16px}</style> <style scoped>.finance-statements-page{min-width:0}</style>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue' import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,14 +11,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { financePhase1Capabilities, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance' import { listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status' import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types' import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>() const props = defineProps<{ kind: 'receivable' | 'payable' }>()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([]) const customers = ref<PlatformTenantResource[]>([])
const filters = reactive<QueryModel>({ customerId: '' }) const filters = ref<QueryModel>({ customerId: '' })
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([]) const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
const loading = ref(false) const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null) const error = ref<ApiErrorDetails | null>(null)
@@ -28,14 +28,13 @@ const selected = ref<FinanceReceivableSettlement | FinancePayableSettlement | nu
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view') const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const canView = computed(() => access.can(viewPermission.value)) const canView = computed(() => access.can(viewPermission.value))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付') const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]) const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable' const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }] ? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]) : [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } } async function loadCustomers() { customerError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.customerId)) : await listFinancePayableSettlements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } } async function load(model: QueryModel = filters.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.value.customerId)) : await listFinancePayableSettlements(String(filters.value.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } }
function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true } function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount } function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' } function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
@@ -49,18 +48,16 @@ onMounted(() => void loadCustomers())
<template v-if="customerError || error" #state><LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template> <template v-if="customerError || error" #state><LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<section v-if="!customerError && !error" class="surface-panel table-panel"> <section v-if="!customerError && !error" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单"> <DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
<template #toolbar-actions><el-button type="primary" disabled :title="blocked ? '接口尚未提供,暂不可操作' : ''"><Plus :size="16" />生成{{ title }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template> <template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template> <template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template> <template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>币种:CNY · 生成接口尚未提供时仅展示真实查询结果</template> <template #pagination-left>币种:CNY</template>
</DataTable> </DataTable>
</section> </section>
</ListPageLayout> </ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)"> <AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)">
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(selected.customerId) }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selected.status) }}</el-descriptions-item></el-descriptions> <el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(selected.customerId) }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selected.status) }}</el-descriptions-item></el-descriptions>
<el-alert class="blocked-alert" type="info" :closable="false" title="结算单详情/收付款接口尚未提供,当前不显示不可执行的提交按钮。" />
</AppDrawer> </AppDrawer>
</AdminPage> </AdminPage>
</template> </template>
<style scoped>.finance-settlement-page{min-width:0}.blocked-alert{margin-top:16px}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style> <style scoped>.finance-settlement-page{min-width:0}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
@@ -44,7 +44,7 @@ const page = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const serverPaged = ref(false) const serverPaged = ref(false)
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' }) const filters = ref<QueryModel>({ keyword: '', customerId: '', status: '' })
const drawerOpen = ref(false) const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('create') const drawerMode = ref<DrawerMode>('create')
const drawerLoading = ref(false) const drawerLoading = ref(false)
@@ -324,7 +324,7 @@ async function load() {
if (!canView.value) return if (!canView.value) return
loading.value = true; loadError.value = null loading.value = true; loadError.value = null
try { try {
const customerId = String(filters.customerId || '').trim() const customerId = String(filters.value.customerId || '').trim()
if (!customerId) { allRows.value = []; total.value = 0; return } if (!customerId) { allRows.value = []; total.value = 0; return }
let source: TableRow[] let source: TableRow[]
let responseTotal = 0 let responseTotal = 0
@@ -337,14 +337,14 @@ async function load() {
} else { } else {
const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false
} }
const keyword = String(filters.keyword || '').trim().toLowerCase() const keyword = String(filters.value.keyword || '').trim().toLowerCase()
const status = String(filters.status || '') const status = String(filters.value.status || '')
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status)) allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
total.value = keyword || status ? allRows.value.length : responseTotal total.value = keyword || status ? allRows.value.length : responseTotal
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) } } catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
finally { loading.value = false } finally { loading.value = false }
} }
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() } function applyFilters(model: QueryModel) { Object.assign(filters.value, model); page.value = 1; void load() }
function changePageSize() { page.value = 1; void load() } function changePageSize() { page.value = 1; void load() }
function openCreate() { function openCreate() {
billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1 billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1
@@ -570,7 +570,7 @@ onMounted(() => { void load(); void loadCustomerOptions() })
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item> <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item>
</template> </template>
</FormSection> </FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。"> <FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="上传真实收付款凭证后再保存财务记录。">
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label> <label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p> <p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
<p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p> <p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p>
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8') const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8') const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
@@ -17,6 +18,9 @@ describe('finance presentation governance', () => {
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)') expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
expect(arap).toContain('financeOperationActionLabel(log.action)') expect(arap).toContain('financeOperationActionLabel(log.action)')
expect(arap).toContain('financeOperationResultLabel(log.result)') expect(arap).toContain('financeOperationResultLabel(log.result)')
expect(arap).toContain('financeDirectionLabel(row.direction)')
expect(arap).toContain('financeDirectionLabel(detail.direction)')
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
expect(arap).toContain('请填写驳回原因') expect(arap).toContain('请填写驳回原因')
expect(arap).toContain('请说明参与或排除结算的原因') expect(arap).toContain('请说明参与或排除结算的原因')
}) })
@@ -25,4 +29,11 @@ describe('finance presentation governance', () => {
expect(manual).toContain('凭证已上传,创建失败时可直接重试') expect(manual).toContain('凭证已上传,创建失败时可直接重试')
expect(manual).not.toContain('凭证 fileId:') expect(manual).not.toContain('凭证 fileId:')
}) })
it('hides unavailable generation controls instead of rendering disabled buttons', () => {
expect(settlement).not.toContain('生成{{ title }}')
expect(settlement).not.toContain('接口尚未提供,暂不可操作')
expect(statements).not.toContain('生成对账单')
expect(statements).not.toContain('disabled title="接口尚未提供')
})
}) })
+4 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { financeBusinessTypeLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status' import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
describe('finance status presentation', () => { describe('finance status presentation', () => {
it.each([ it.each([
@@ -32,6 +32,9 @@ describe('finance status presentation', () => {
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单') expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过') expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功') expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
expect(financeDirectionLabel('RECEIVABLE')).toBe('应收')
expect(financeDirectionLabel('PAYABLE')).toBe('应付')
expect(financeDirectionLabel('NEW_DIRECTION')).toBe('未知方向')
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源') expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
}) })
}) })
+4
View File
@@ -72,6 +72,9 @@ const financeOperationActionLabels: Record<string, string> = {
const financeOperationResultLabels: Record<string, string> = { const financeOperationResultLabels: Record<string, string> = {
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中', SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
} }
const financeDirectionLabels: Record<string, string> = {
RECEIVABLE: '应收', PAYABLE: '应付',
}
function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel: string) { function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel: string) {
const code = normalizeStatus(value) const code = normalizeStatus(value)
@@ -84,3 +87,4 @@ export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, fin
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据') export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作') export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知') export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
export const financeDirectionLabel = (value: unknown) => codeLabel(value, financeDirectionLabels, '未知方向')
@@ -625,7 +625,6 @@ onMounted(() => void loadTenants())
</template> </template>
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))"> <el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form> <el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template> <template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
</el-dialog> </el-dialog>
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAttachmentDialog.vue'), 'utf8')
describe('TruckAttachmentDialog interaction governance', () => {
it('shows existing attachments without exposing file-service internals', () => {
expect(source).toContain('预约附件')
expect(source).toContain('attachmentTypeLabel(item.attachmentType)')
expect(source).not.toContain('文件 ID')
expect(source).not.toContain('item.objectKey')
})
it('hides the unavailable manual reference flow but keeps deletion', () => {
expect(source).not.toContain('referenceTruckAppointmentAttachment')
expect(source).not.toContain('输入文件服务 ID')
expect(source).toContain('deleteTruckAppointmentAttachment')
expect(source).toContain('<Trash2 :size="15" />删除')
})
})
@@ -1,28 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { deleteTruckAppointmentAttachment, referenceTruckAppointmentAttachment } from '../api/platformTrucking' import { Trash2 } from '@lucide/vue'
import { deleteTruckAppointmentAttachment } from '../api/platformTrucking'
import type { TruckAttachmentReference } from '../types' import type { TruckAttachmentReference } from '../types'
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>() const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>() const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
const fileId = ref(''); const fileName = ref(''); const busy = ref(false) const busy = ref(false)
async function add() { if (!props.appointmentId || !/^\d+$/.test(fileId.value)) return; busy.value = true; try { await referenceTruckAppointmentAttachment(props.appointmentId, { fileId: fileId.value, fileName: fileName.value || null }); fileId.value = ''; fileName.value = ''; emit('changed'); ElMessage.success('附件引用已添加') } catch { ElMessage.error('附件引用失败') } finally { busy.value = false } } const attachmentTypeLabels: Record<string, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } } async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
</script> </script>
<template> <template>
<el-dialog :model-value="props.modelValue" title="附件引用" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)"> <el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<el-alert type="info" :closable="false" title="仅允许引用文件服务中属于当前预约和客户范围的文件,后端会校验归属与业务类型。" /> <el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
<el-form v-if="props.appointmentId && props.canManage" inline @submit.prevent="add"><el-form-item label="文件 ID"><el-input v-model="fileId" placeholder="输入文件服务 ID" /></el-form-item><el-form-item label="文件名"><el-input v-model="fileName" placeholder="可选" /></el-form-item><el-button type="primary" :loading="busy" @click="add">添加引用</el-button></el-form>
<el-empty v-if="attachments.length === 0" description="暂无真实附件引用" :image-size="72" />
<div v-for="item in attachments" :key="item.fileId" class="attachment-row"> <div v-for="item in attachments" :key="item.fileId" class="attachment-row">
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>文件 ID:{{ item.fileId }} · {{ item.attachmentType || '业务附件' }}</small></div> <div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
<code v-if="item.objectKey">{{ item.objectKey }}</code> <el-button v-if="props.appointmentId && props.canManage && item.id" text type="danger" :loading="busy" @click="remove(item)"><Trash2 :size="15" />删除</el-button>
<el-button v-if="props.appointmentId && props.canManage && item.id" text type="danger" :loading="busy" @click="remove(item)">删除</el-button>
</div> </div>
<template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template> <template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template>
</el-dialog> </el-dialog>
</template> </template>
<style scoped> <style scoped>
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }.attachment-row code { color:var(--el-text-color-secondary); font-size:12px; } .attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
@media (max-width: 560px) { .attachment-row { flex-direction:column; } } @media (max-width: 560px) { .attachment-row { flex-direction:column; } }
</style> </style>
@@ -69,9 +69,9 @@ describe('UnifiedOrdersView contract', () => {
it('covers real list states and keeps operation column first and left-fixed', () => { it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'") expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'")
expect(source).toContain("{ key: 'selection', type: 'selection', width: 52, fixed: 'left'") expect(source).not.toContain("{ key: 'selection', type: 'selection'")
expect(source).toContain('批量审核接口待接入') expect(source).not.toContain('批量审核接口待接入')
expect(source).toContain('@selection-change="handleSelectionChange"') expect(source).not.toContain('@selection-change="handleSelectionChange"')
expect(source).toContain('title="订单列表暂时无法加载"') expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"') expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"') expect(source).toContain(':loading="loading"')
@@ -74,7 +74,6 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([]) const records = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([]) const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]) const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const page = ref(1) const page = ref(1)
@@ -198,7 +197,6 @@ const queryFields = computed(() => showAdvancedFilters.value
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left', slotName: 'fulfillment', hideable: false, movable: false }, { key: 'fulfillment', type: 'expand', width: 52, fixed: 'left', slotName: 'fulfillment', hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' }, { key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
@@ -526,19 +524,6 @@ function canApprove(row: UnifiedOrderResource) {
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve') return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
} }
function canBulkReview(row: UnifiedOrderResource) {
return canApprove(row)
}
function handleSelectionChange(rows: UnifiedOrderResource[]) {
selectedOrders.value = rows
}
function openBulkReviewPlaceholder() {
if (selectedOrders.value.length === 0) return
ElMessage.info('批量审核接口待接入,请先逐笔审核')
}
function openApproval(row: UnifiedOrderResource) { function openApproval(row: UnifiedOrderResource) {
if (!canApprove(row)) return if (!canApprove(row)) return
approvalOrder.value = row approvalOrder.value = row
@@ -667,7 +652,6 @@ async function loadOrders() {
size: pageSize.value, size: pageSize.value,
}) })
records.value = result.records records.value = result.records
selectedOrders.value = []
total.value = result.total total.value = result.total
} catch (error) { } catch (error) {
records.value = [] records.value = []
@@ -1075,10 +1059,7 @@ onMounted(() => {
<template #default> <template #default>
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel"> <div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange"> <DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #toolbar-actions>
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</template>
<template #actions="{ row }"> <template #actions="{ row }">
<div class="row-actions"> <div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button> <el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>