导出Excel新增客户
-
-
- 详情
- 编辑
- 审核
- {{ customerActionLabel(row as PlatformTenantResource, action) }}
-
-
- {{ customerActionLabel(row as PlatformTenantResource, action) }}
-
-
-
+
{{ (row as PlatformTenantResource).tenantName }}
{{ statusMeta((row as PlatformTenantResource).status).label }}
{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}
@@ -1112,7 +1116,7 @@ onMounted(() => { void loadCustomers() })
{{ draft.file?.name || draft.uploadedName || '未选择文件' }}{{ draft.error }}
-
+
支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。
@@ -1222,7 +1226,7 @@ onMounted(() => { void loadCustomers() })
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
-.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
+.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
@@ -1230,6 +1234,12 @@ onMounted(() => { void loadCustomers() })
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
+@container (max-width:660px) {
+ .qualification-row { grid-template-columns:112px minmax(0,1fr); }
+ .qualification-header { display:none; }
+ .qualification-label { grid-row:span 2; align-items:center; }
+ .qualification-file,.qualification-validity { min-height:44px; }
+}
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); }
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts
index c0d5b51c..04678b36 100644
--- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts
@@ -107,15 +107,13 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps high-frequency account actions visible and groups the remaining actions', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }")
- expect(page).toContain('@click="openAccount(row, false)">详情')
- expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
- expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
- expect(page).toContain('aria-label="更多客户账户操作"')
- expect(page).toContain('授信')
- expect(page).toContain('账期')
- expect(page).not.toContain('')
- expect(page).toContain('class="row-actions density-actions"')
- expect(page).toContain('white-space: nowrap')
+ expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
+ expect(page).toContain("{ key: 'detail', label: '详情', priority: 300 }")
+ expect(page).toContain("{ key: 'recharge', label: '充值', priority: 250")
+ expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200")
+ expect(page).toContain("{ key: 'credit', label: '授信', priority: 100")
+ expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value")
+ expect(page).toContain(' {
@@ -134,7 +132,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
- const trigger = actions.get('button[aria-label="更多客户账户操作"]')
+ const trigger = actions.get('button[aria-label^="更多客户账户操作"]')
expect(trigger.text()).toBe('')
expect(trigger.find('svg').exists()).toBe(true)
}
diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue
index f161c4b5..76a2356a 100644
--- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue
+++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
-import { ArrowLeft, CreditCard, Download, MinusCircle, MoreHorizontal, PlusCircle, Save } from '@lucide/vue'
+import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -18,6 +18,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
+import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
@@ -270,6 +271,21 @@ function handleAccountMore(command: string, account: FinanceAccountSummary) {
if (command === 'credit') openCreditLimit(account)
if (command === 'terms') void openAccount(account as unknown as Record, true)
}
+function accountActions(account: FinanceAccountSummary): RowActionItem[] {
+ return [
+ { key: 'detail', label: '详情', priority: 300 },
+ { key: 'recharge', label: '充值', priority: 250, visible: canAdjust.value },
+ { key: 'deduction', label: '扣款', priority: 200, tone: 'danger', visible: canAdjust.value },
+ { key: 'credit', label: '授信', priority: 100, visible: canAdjust.value },
+ { key: 'terms', label: '账期', visible: canUpdateTerms.value, divided: canAdjust.value },
+ ]
+}
+function handleAccountAction(command: string, account: FinanceAccountSummary) {
+ if (command === 'detail') void openAccount(account as unknown as Record, false)
+ else if (command === 'recharge') openAdjustment(account, 'RECHARGE')
+ else if (command === 'deduction') openAdjustment(account, 'DEDUCTION')
+ else handleAccountMore(command, account)
+}
async function submitCreditLimit() {
if (!adjustmentAccount.value) return
const raw = creditLimitForm.amount.trim()
@@ -425,7 +441,7 @@ onBeforeUnmount(clearVoucherPreview)
导出Excel
- 详情充值扣款账期授信账期
+
{{ customerName(row.customerId, row.customerName) }}
{{ customerField(row.customerCode) }}{{ customerField(row.contactName) }}{{ customerField(row.contactPhone) }}
{{ formatLastChangedAt(row.lastChangedAt) }}
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
index 3885197f..ceb2f5eb 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
@@ -113,7 +113,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) {
- const trigger = actions.get('button[title="更多费用来源操作"]')
+ const trigger = actions.get('button[title^="更多费用来源操作"]')
expect(trigger.text()).toBe('')
await trigger.trigger('click')
await flushPromises()
@@ -268,18 +268,18 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(page).not.toContain('max-width: 520px')
})
- it('renders permitted fee-source row operations as shared outlined text buttons', () => {
+ it('renders permitted fee-source row operations through the shared row-actions component', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
- expect(page).toContain('@click="openDetail(row)">详情')
- expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
- expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
+ expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
+ expect(page).toContain("{ key: 'detail', label: '详情', priority: 300")
+ expect(page).toContain("{ key: 'submit', label: '提交审核', priority: 200")
+ expect(page).toContain("{ key: 'adjust', label: '调整', priority: 150")
+ expect(page).toContain("{ key: 'void', label: '作废', tone: 'danger'")
expect(page).toContain('!isUpstreamSource(row)')
expect(page).toContain('!isUpstreamSource(detail)')
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
expect(page).toContain("=== 'LASTLEG'")
- const actions = page.slice(page.indexOf(']*\blink\b/)
- expect(page).toContain('white-space: nowrap')
+ expect(page).toContain(' {
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue
index 1aa197cd..4c4ddbbd 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.vue
+++ b/src/modules/finance/pages/FinanceCustomerArapView.vue
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
-import { MoreHorizontal, ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
+import { ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -20,6 +20,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
+import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
@@ -136,18 +137,6 @@ function closeCustomerSelectOnScroll() {
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
-function sourceActionCount(row: Record) {
- if (!row.feeSourceId) return 0
- const available = row.status === 'AVAILABLE'
- const draft = ['DRAFT', 'REJECTED'].includes(String(row.reviewStatus))
- const editable = canEditSource.value && !isUpstreamSource(row)
- return Number(canViewSource.value)
- + Number(editable && draft && available)
- + Number(canEditSource.value && draft && available)
- + Number(canReviewSource.value && row.reviewStatus === 'PENDING' && available)
- + Number(editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated)
- + Number(editable && available && !row.statementGenerated)
-}
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
@@ -258,14 +247,13 @@ async function loadDetail(row: ArapRow) {
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
finally { detailLoading.value = false }
}
-async function openDetail(row: Record) {
- const target = row as unknown as ArapRow
- if (!target.feeSourceId) return
- await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
+async function openDetail(row: ArapRow) {
+ if (!row.feeSourceId) return
+ await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: row.feeSourceId } })
}
-async function openReview(row: Record) {
- const target = row as unknown as ArapRow; selected.value = target
- const loaded = await loadDetail(target)
+async function openReview(row: ArapRow) {
+ selected.value = row
+ const loaded = await loadDetail(row)
if (!loaded) { detailOpen.value = true; return }
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
}
@@ -279,18 +267,39 @@ async function submitReview() {
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
finally { reviewSaving.value = false }
}
-async function openMutation(row: Record, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
- const target = row as unknown as ArapRow; selected.value = target
- if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(target)) {
- ElMessage.info(String(target.sourceSystem || '').toUpperCase() === 'LASTLEG'
+async function openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
+ selected.value = row
+ if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(row)) {
+ ElMessage.info(String(row.sourceSystem || '').toUpperCase() === 'LASTLEG'
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
return
}
- const loaded = await loadDetail(target)
+ const loaded = await loadDetail(row)
if (!loaded) { detailOpen.value = true; return }
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
}
+function sourceActions(row: ArapRow): RowActionItem[] {
+ const available = row.status === 'AVAILABLE'
+ const draft = ['DRAFT', 'REJECTED'].includes(row.reviewStatus)
+ const editable = canEditSource.value && !isUpstreamSource(row)
+ return [
+ { key: 'detail', label: '详情', priority: 300, visible: canViewSource.value },
+ { key: 'edit', label: '编辑', priority: 250, visible: editable && draft && available },
+ { key: 'submit', label: '提交审核', priority: 200, tone: 'warning', visible: canEditSource.value && draft && available },
+ { key: 'review', label: '审核', priority: 200, tone: 'warning', visible: canReviewSource.value && row.reviewStatus === 'PENDING' && available },
+ { key: 'adjust', label: '调整', priority: 150, visible: editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated },
+ { key: 'void', label: '作废', tone: 'danger', divided: true, visible: editable && available && !row.statementGenerated },
+ ]
+}
+function handleSourceAction(command: string, row: ArapRow) {
+ if (command === 'detail') void openDetail(row)
+ else if (command === 'review') void openReview(row)
+ else if (command === 'edit') void openMutation(row, 'EDIT')
+ else if (command === 'submit') void openMutation(row, 'SUBMIT')
+ else if (command === 'adjust') void openMutation(row, 'ADJUST')
+ else if (command === 'void') void openMutation(row, 'VOID')
+}
async function submitMutation() {
if (!detail.value) return
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
@@ -396,7 +405,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
导出Excel生成所选对账单
- 详情编辑提交审核审核调整作废手工成本
+ 手工成本
{{ customerName(row.customerId, row.customerName) }}{{ financeDirectionLabel(row.direction) }}
{{ businessReference(row as unknown as ArapRow) }}{{ sourceTypeDisplay(row.sourceType) }}{{ supplierDisplay(row as unknown as ArapRow) }}{{ sourceStatusDisplay(row.status) }}
{{ statusLabel(row.reviewStatus) }}
@@ -407,7 +416,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
-
+
- 结算金额
- {{ money(detail.amount) }} CNY
- 收付方向
- {{ financeDirectionLabel(detail.direction) }}
- 来源状态
- {{ sourceStatusDisplay(detail.sourceStatus) }}
- 审核状态
- {{ statusLabel(detail.reviewStatus) }}
{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}{{ displayFeeName(detail.feeItemName) }}{{ sourceTypeDisplay(detail.sourceType) }}{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}{{ detail.supplierName || '供应商待补充' }}{{ formatBusinessDate(detail.businessDate) }}{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}{{ detail.exchangeRate }}({{ detail.exchangeRateMonth }}){{ detail.quantity }} {{ detail.quantityUnit || '' }} × {{ detail.originalCurrency }} {{ detail.unitPrice }}{{ detail.periodStart }} 至 {{ detail.periodEnd }}{{ sourceStatusDisplay(detail.sourceStatus) }}{{ detail.statementGenerated ? '是' : '否' }}{{ detail.reviewReason || '未填写' }}
{{ formatDateTime(row.occurredAt) }}{{ financeOperationActionLabel(row.action) }}{{ displayOperator(row.operatorName) }}{{ row.remark || financeOperationResultLabel(row.result) }}
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
index ca7a61e9..a0dee767 100644
--- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts
@@ -126,20 +126,14 @@ describe('FinanceCustomerStatementsView', () => {
expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }')
})
- it('uses entity buttons for row actions and only folds the fourth action into more', () => {
+ it('uses the shared row-actions component and prioritizes the first three available operations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
- const actions = source.slice(source.indexOf(''), source.indexOf(''))
- expect(actions).not.toContain(' link')
- for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
- const actionLine = actions.split('\n').find((line) => line.includes(`${label}`))
- expect(actionLine).toContain('class="density-action"')
- expect(actionLine).toContain('size="small"')
- }
- expect(actions).toContain('data-tone="danger"')
- expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
- expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
- expect(actions.match(/ {
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
index d37e0db0..5a076221 100644
--- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue
+++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue
@@ -4,7 +4,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
-import { ArrowLeft, Download, MoreHorizontal, Plus } from '@lucide/vue'
+import { ArrowLeft, Download, Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -19,6 +19,7 @@ import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
+import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
@@ -531,6 +532,29 @@ async function openAdjustment(row: FinanceStatementReadModel) {
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
+function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
+ return [
+ { key: 'detail', label: '详情', priority: 300, disabled: loading.value },
+ { key: 'submit', label: '提交', priority: 250, visible: row.status === 'DRAFT' && canCreate.value, loading: actionBusy.value },
+ { key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
+ { key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
+ { key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
+ { key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
+ { key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
+ { key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
+ ]
+}
+function handleStatementAction(command: string, row: FinanceStatementReadModel) {
+ if (command === 'detail') void openDetail(row)
+ else if (command === 'adjust') void openAdjustment(row)
+ else {
+ const actions: Record = {
+ submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
+ }
+ const action = actions[command]
+ if (action) void performAction(row, action)
+ }
+}
async function submitAdjustment() {
const statement = adjustStatement.value
@@ -654,21 +678,7 @@ watch(detailRouteId, (statementId) => {
导出
生成对账单
-
- 详情
- 提交
- 通过
- 驳回
- performAction(row as FinanceStatementReadModel, command)">
-
-
-
- 作废
- 出账
- 调整
- 重开
- 作废
-
+
{{ financeStatusLabel(row.status) }}
{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}
{{ customerDisplayName(row.customerId, row.customerName) }}
diff --git a/src/modules/iam/pages/PermissionsView.test.ts b/src/modules/iam/pages/PermissionsView.test.ts
index 5bc8dab0..3c2a389d 100644
--- a/src/modules/iam/pages/PermissionsView.test.ts
+++ b/src/modules/iam/pages/PermissionsView.test.ts
@@ -50,6 +50,7 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
+ RowActions: false,
AdminPage: { template: '' },
ListPageLayout: { template: '' },
FormSection: { template: '' },
@@ -85,16 +86,12 @@ describe('PermissionsView IAM mode', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'")
})
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
- expect(permissionsSource).toContain('size="small" data-tone="neutral" @click="openCreate((row as Permission).id)"')
- expect(permissionsSource).toContain('size="small" data-tone="neutral" :disabled="rowActionId')
- expect(permissionsSource).toContain('size="small" :data-tone="(row as Permission).status')
- expect(permissionsSource).toContain('size="small" data-tone="danger"')
- expect(permissionsSource).toContain('size="small" :aria-label="moreActionsLabel(row as Permission)"')
- expect(permissionsSource).toContain('>添加下级')
- expect(permissionsSource).toContain('>编辑')
- expect(permissionsSource).toContain(':data-tone=')
- expect(permissionsSource).toContain('>删除')
- expect(permissionsSource).toContain('')
+ expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
+ expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
+ expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
+ expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
+ expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
+ expect(permissionsSource).toContain('展开全部')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
diff --git a/src/modules/iam/pages/PermissionsView.vue b/src/modules/iam/pages/PermissionsView.vue
index 6757d9bf..3d55d9e7 100644
--- a/src/modules/iam/pages/PermissionsView.vue
+++ b/src/modules/iam/pages/PermissionsView.vue
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '../iamDensity.css'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
-import { Braces, FolderTree, MoreHorizontal, Plus, ShieldCheck } from '@lucide/vue'
+import { Braces, FolderTree, Plus, ShieldCheck } from '@lucide/vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -12,6 +12,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
+import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
@@ -247,24 +248,26 @@ function canChangeStatusRow(row: Permission) {
function canDeleteRow(row: Permission) {
return canDelete.value && !row.builtIn
}
-function permissionActionCount(row: Permission) {
- return Number(canAddChild(row))
- + Number(canEditRow(row))
- + Number(canChangeStatusRow(row))
- + Number(canDeleteRow(row))
-}
-function showDeleteDirect(row: Permission) {
- return canDeleteRow(row) && permissionActionCount(row) <= 3
-}
-function showDeleteMore(row: Permission) {
- return canDeleteRow(row) && permissionActionCount(row) > 3
-}
function moreActionsLabel(row: Permission) {
const resourceLabel = [row.name]
.find((value) => typeof value === 'string' && value.trim())
?.trim() || '当前权限'
return `${resourceLabel}的更多操作`
}
+function permissionActions(row: Permission): RowActionItem[] {
+ return [
+ { key: 'add-child', label: '添加下级', priority: 400, visible: canAddChild(row) },
+ { key: 'edit', label: '编辑', priority: 300, visible: canEditRow(row), disabled: rowActionId.value === row.id },
+ { key: 'status', label: row.status === '启用' ? '停用' : '启用', priority: 200, visible: canChangeStatusRow(row), tone: row.status === '启用' ? 'danger' : 'success', loading: rowActionId.value === row.id },
+ { key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDeleteRow(row), disabled: rowActionId.value === row.id },
+ ]
+}
+function handlePermissionAction(command: string, row: Permission) {
+ if (command === 'add-child') openCreate(row.id)
+ else if (command === 'edit') openEdit(row)
+ else if (command === 'status') void toggleStatus(row)
+ else if (command === 'delete') void removePermission(row)
+}
onMounted(loadPermissions)
@@ -283,19 +286,7 @@ onMounted(loadPermissions)
新增权限
-
-
- 添加下级
- 编辑
- {{ (row as Permission).status === '启用' ? '停用' : '启用' }}
- 删除
- command === 'delete' && removePermission(row as Permission)">
-
-
-
-
- 仅查看
-
+ 仅查看
{{ (row as Permission).name }}{{ permissionSummary(row as PermissionTreeNode) }}
{{ (row as Permission).code }}
{{ (row as Permission).method }}{{ (row as Permission).path || '-' }}
diff --git a/src/modules/inbounds/components/InboundTaskList.test.ts b/src/modules/inbounds/components/InboundTaskList.test.ts
index 8ebeb2d2..0b76d9c8 100644
--- a/src/modules/inbounds/components/InboundTaskList.test.ts
+++ b/src/modules/inbounds/components/InboundTaskList.test.ts
@@ -37,7 +37,7 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain(':tenant-id="selectedInbound.tenantId"')
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
- expect(source).toContain(':customer-name="selectedInbound.customerName || selectedInbound.companyName"')
+ expect(source).toContain(':customer-name="customerDisplayName(selectedInbound)"')
expect(source).toContain(':channel-name="selectedInbound.channelName"')
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
})
diff --git a/src/modules/inbounds/components/InboundTaskList.vue b/src/modules/inbounds/components/InboundTaskList.vue
index b2bfce49..5fed05bd 100644
--- a/src/modules/inbounds/components/InboundTaskList.vue
+++ b/src/modules/inbounds/components/InboundTaskList.vue
@@ -65,6 +65,9 @@ const actionPermission = computed(() => isReview.value
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
+const customerNameById = computed(() => new Map(customers.value.map((customer) => [
+ String(customer.id), customer.legalName?.trim() || customer.tenantName,
+])))
const queryFields = computed(() => [
{
@@ -111,6 +114,11 @@ function text(value: unknown) {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
+function customerDisplayName(inbound: InboundOrderSummary) {
+ const snapshot = inbound.customerName?.trim() || inbound.companyName?.trim()
+ return snapshot || customerNameById.value.get(String(inbound.customerId || inbound.tenantId)) || ''
+}
+
function formatNumber(value: number | null | undefined) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
}
@@ -440,6 +448,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
v-if="!isReview && selectedInbound"
v-model="measurementDialogOpen"
:customer-id="selectedInbound.tenantId"
+ :customer-name="customerDisplayName(selectedInbound)"
:inbound-id="selectedInbound.id"
:inbound-code="selectedInbound.inboundCode"
@updated="handleMeasurementUpdated"
diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts
index 9b5f1dc9..fe382b05 100644
--- a/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts
+++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.test.ts
@@ -40,6 +40,7 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('@click="reviewDialogOpen = true"')
expect(source).toContain(':key="`review-dialog-${revision}`"')
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
+ expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
expect(source).toContain(':channel-name="detail?.summary.channelName"')
expect(source).toContain('@changed="handleReviewChanged"')
expect(source).not.toContain('')
diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue
index 6ec4e862..267a5dd9 100644
--- a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue
+++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue
@@ -197,6 +197,7 @@ onBeforeUnmount(() => {
{
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
expect(source).toContain(' = {}): Inbound
channelName: '美西海运',
processStatus: 'MEASURING',
measureReviewStatus: null,
+ customerFeeConfirmed: false,
recommendedPalletType: 'STANDARD',
reviewReason: null,
reviewerId: null,
@@ -135,7 +136,7 @@ async function mountPanel(permissions = [
'platform:firstleg:inbound:measure:edit',
'platform:firstleg:inbound:measure:manual-entry',
'platform:firstleg:inbound:measure:submit',
-]) {
+], props: Partial<{ customerId: string; inboundId: string; customerName: string }> = {}) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
@@ -143,7 +144,7 @@ async function mountPanel(permissions = [
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(InboundMeasurementPanel, {
- props: { customerId: '10001', inboundId: '7001' },
+ props: { customerId: '10001', inboundId: '7001', ...props },
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
@@ -195,6 +196,13 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
})
+ it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
+ vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
+ const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
+
+ expect(wrapper.find('.measurement-summary-context').text()).toContain('柏悦物流')
+ })
+
it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
actualGrossWeightKg: 132,
@@ -499,7 +507,7 @@ describe('InboundMeasurementPanel', () => {
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
- expect.stringContaining('准备托盘与库位占用'),
+ expect.stringContaining('提交成功后本批次不能继续编辑'),
'提交测量审核',
expect.objectContaining({ confirmButtonText: '确认提交' }),
)
diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
index 3b02e553..2282f24d 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
@@ -21,6 +21,7 @@ const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
+ customerName?: string | null
}>()
const emit = defineEmits<{
@@ -110,6 +111,7 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edi
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
+const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
@@ -698,7 +700,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock