This commit is contained in:
2026-09-18 16:48:40 +08:00
parent e10d0534ae
commit 24fa3d1c9d
19 changed files with 286 additions and 210 deletions
@@ -374,7 +374,7 @@ describe('ContainersView', () => {
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(true)
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
@@ -453,14 +453,16 @@ describe('ContainersView', () => {
it('renders container business actions as buttons without changing the transport gate', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')")
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
expect(source).toContain('<RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
expect(source).not.toContain('containerNo: optional(form.containerNo)')
expect(source).not.toContain('updateContainerTransport')
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
@@ -3,7 +3,6 @@ import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRouter } from 'vue-router'
import { MoreHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -17,6 +16,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -638,6 +638,30 @@ function handleMoreAction(command: string, source: object) {
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
}
function containerRowActions(row: ContainerWorkbenchRow): RowActionItem[] {
const processLabel = row.planStatus === 'EDITING' ? '装柜' : row.planStatus === 'LOADING' ? '预装清单' : row.planStatus === 'COMPLETED' ? '装柜清单' : ''
const canOpenProcess = row.planStatus === 'EDITING' ? canSmartLoading.value : row.planStatus === 'LOADING' ? canViewPreload.value : row.planStatus === 'COMPLETED' ? canViewLoadingList.value : false
return [
{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500, visible: canView.value },
{ key: 'edit', label: '编辑', priority: 450, visible: isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') },
{ key: 'process', label: processLabel, priority: 400, tone: 'primary', visible: isDomestic.value && Boolean(processLabel) && canOpenProcess },
{ key: 'empty-return', label: '确认还柜', priority: 350, tone: 'primary', visible: canDrayage(row, 'RECORD_EMPTY_RETURN') },
{ key: 'send-email', label: '发送邮件', priority: 300, tone: 'primary', visible: canDrayage(row, 'SEND_PICKUP_EMAIL'), loading: drayageSavingIds.value.includes(row.id) },
{ key: 'overview', label: '轨迹与日志', priority: 200, visible: canView.value },
{ key: 'email-history', label: '邮件记录', priority: 150, visible: canView.value && isOverseas.value },
{ key: 'drayage-history', label: '码头拖车记录', priority: 140, visible: canView.value && isOverseas.value },
{ key: 'documents', label: '清关 / DO / PU', priority: 130, visible: canView.value && isOverseas.value && canOverseasDocumentEdit.value && hasAction(row, 'MANAGE_DESTINATION_DOCUMENTS') },
{ key: 'link', label: '关联已放舱订舱', priority: 120, visible: canView.value && isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId },
]
}
function handleContainerRowAction(command: string, row: ContainerWorkbenchRow) {
if (command === 'view') openDetail(row)
else if (command === 'edit') void openEdit(row)
else if (command === 'process') goContainerProcess(row)
else if (command === 'empty-return') openDrayageAction(row, 'RECORD_EMPTY_RETURN')
else if (command === 'send-email') void sendPickupEmail(row)
else handleMoreAction(command, row)
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
@@ -721,7 +745,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)">查看</el-button><template v-if="isDomestic"><el-button class="density-action" v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)">编辑</el-button><el-button data-tone="primary" class="density-action" v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)">装柜</el-button><el-button class="density-action" v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)">预装清单</el-button><el-button class="density-action" v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)">装柜清单</el-button></template><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)">发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button class="density-action" size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview">轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history">邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history">码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents">清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link">关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
@@ -822,7 +822,7 @@ describe('CustomersView', () => {
await flushPromises()
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3)
expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9)
expect(document.querySelectorAll('.el-table__body .row-actions > .el-button').length).toBe(9)
})
it('keeps the action row usable when all customer identifiers are missing', async () => {
+24 -24
View File
@@ -3,9 +3,10 @@ import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, MoreHorizontal, ShieldCheck, ShieldX } from '@lucide/vue'
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { createPlatformExportTask } from '@/modules/downloads'
import {
approveEnterpriseReview,
@@ -678,17 +679,6 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
...(canArchive(row) ? ['delete' as const] : []),
]
}
function customerPrimaryActionCount(row: PlatformTenantResource) {
return Number(access.can('platform:customer:list'))
+ Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED')
+ Number(hasReviewAction(row))
}
function customerDirectSecondaryActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerOverflowActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerMoreActionsLabel(row: PlatformTenantResource) {
const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户')
return `更多客户操作:${identity}`
@@ -710,6 +700,27 @@ function customerActionDisabled(action: CustomerRowAction) {
if (action === 'delete') return archiveSubmitting.value
return false
}
function customerRowActions(row: PlatformTenantResource): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 500, visible: access.can('platform:customer:list') },
{ key: 'edit', label: '编辑', priority: 400, visible: access.can('platform:customer:edit') && row.status !== 'ARCHIVED' },
{ key: 'review', label: '审核', priority: 300, tone: 'warning', visible: hasReviewAction(row), disabled: reviewSubmitting.value },
...customerSecondaryActions(row).map((action, index): RowActionItem => ({
key: action,
label: customerActionLabel(row, action),
priority: 200 - index,
tone: customerActionType(row, action),
disabled: customerActionDisabled(action),
divided: action === 'delete',
})),
]
}
function handleCustomerRowAction(row: PlatformTenantResource, command: string) {
if (command === 'detail') showDetail(row)
else if (command === 'edit') openEdit(row)
else if (command === 'review') openDecision(row, access.can('platform:customer:approve') ? 'approve' : 'reject')
else handleMoreAction(row, command)
}
function handleMoreAction(row: PlatformTenantResource, command: string) {
if (command === 'reset-password') openResetPassword(row)
if (command === 'impersonate') openImpersonation(row)
@@ -1059,18 +1070,7 @@ onMounted(() => { void loadCustomers() })
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions density-actions">
<el-button v-if="access.can('platform:customer:list')" class="density-action customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)">详情</el-button>
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="density-action customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)">编辑</el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="density-action" data-tone="warning" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')">审核</el-button>
<el-button v-for="action in customerDirectSecondaryActions(row as PlatformTenantResource)" :key="action" class="density-action" :data-tone="action === 'reset-password' ? 'purple' : action === 'impersonate' ? 'primary' : action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE' ? 'success' : 'danger'" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-dropdown v-if="customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)">
<el-button class="density-action customer-action-more" size="small" plain :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
@@ -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)">详情</el-button>')
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
expect(page).toContain('aria-label="更多客户账户操作"')
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item>')
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item>')
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
expect(page).toContain('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('<RowActions :actions="accountActions(row as FinanceAccountSummary)"')
})
it('shows authoritative CNY wallet balances and independently permitted actions', () => {
@@ -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)
}
@@ -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<string, unknown>, 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<string, unknown>, 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)
<section v-if="!stateError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="neutral" class="density-action" size="small" @click="openAccount(row, false)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')">充值</el-button><el-button data-tone="danger" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')">扣款</el-button><el-button class="density-action" data-tone="neutral" v-if="!canAdjust && canUpdateTerms" size="small" @click="handleAccountMore('terms', row as FinanceAccountSummary)">账期</el-button><el-dropdown v-if="canAdjust" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button data-tone="neutral" class="density-action" size="small" aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -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)">详情</el-button>')
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('<template #actions'), page.indexOf('<template #customerName'))
expect(actions).not.toMatch(/<el-button[^>]*\blink\b/)
expect(page).toContain('white-space: nowrap')
expect(page).toContain('<RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)"')
})
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
@@ -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<string, unknown>) {
if (!row.feeSourceId) return 0
const available = row.status === 'AVAILABLE'
const draft = ['DRAFT', 'REJECTED'].includes(String(row.reviewStatus))
const editable = canEditSource.value && !isUpstreamSource(row)
return Number(canViewSource.value)
+ Number(editable && draft && available)
+ Number(canEditSource.value && draft && available)
+ Number(canReviewSource.value && row.reviewStatus === 'PENDING' && available)
+ Number(editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated)
+ Number(editable && available && !row.statementGenerated)
}
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
@@ -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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>, 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
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><template v-if="row.feeSourceId"><el-button data-tone="neutral" class="density-action" v-if="canViewSource" size="small" @click="openDetail(row)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'EDIT')">编辑</el-button><el-button data-tone="warning" class="density-action" v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'SUBMIT')">提交审核</el-button><el-button data-tone="warning" class="density-action" v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" @click="openReview(row)">审核</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" @click="openMutation(row, 'ADJUST')">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) <= 3" size="small" @click="openMutation(row, 'VOID')">作废</el-button><el-dropdown v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) > 3" trigger="click"><el-button class="density-action" data-tone="neutral" size="small" title="更多费用来源操作" :aria-label="`更多费用来源操作 · ${row.documentNo || '当前费用'}`"><MoreHorizontal :size="15" aria-hidden="true" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item class="danger-menu-item" @click="openMutation(row, 'VOID')">作废</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template><span v-else class="muted-action">手工成本</span></div></template>
<template #actions="{ row }"><RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
@@ -407,7 +416,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
@@ -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('<template #actions="{ row }">'), source.indexOf('<template #status="{ row }">'))
expect(actions).not.toContain(' link')
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
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(/<el-dropdown-item/g)).toHaveLength(1)
expect(actions).toContain('class="danger-menu-item"')
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'detail', label: '详情', priority: 300")
expect(source).toContain("{ key: 'approve', label: '通过', priority: 250")
expect(source).toContain("{ key: 'reject', label: '驳回', priority: 200")
expect(source).toContain("{ key: 'void', label: '作废', tone: 'danger', divided: true")
expect(source).toContain('<RowActions :actions="statementActions(row as FinanceStatementReadModel)"')
})
it('keeps three available row actions visible with primary and danger semantics', async () => {
@@ -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<string, ReconciliationActionPayload['action']> = {
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) => {
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }"><div class="density-actions">
<el-button data-tone="neutral" class="density-action" size="small" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'DRAFT' && canCreate" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')">提交</el-button>
<el-button data-tone="warning" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')">通过</el-button>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')">驳回</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button data-tone="neutral" class="density-action" size="small" :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item">作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'APPROVED' && canIssue" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')">出账</el-button>
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'ISSUED' && canAdjust" size="small" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)">调整</el-button>
<el-button data-tone="neutral" class="density-action" v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')">重开</el-button>
<el-button data-tone="danger" class="density-action" v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
</div></template>
<template #actions="{ row }"><RowActions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
+7 -10
View File
@@ -50,6 +50,7 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
RowActions: false,
AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
FormSection: { template: '<section><slot /></section>' },
@@ -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('>添加下级</el-button>')
expect(permissionsSource).toContain('>编辑</el-button>')
expect(permissionsSource).toContain(':data-tone=')
expect(permissionsSource).toContain('>删除</el-button>')
expect(permissionsSource).toContain('<MoreHorizontal aria-hidden="true" :size="16" />')
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('<RowActions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
+17 -26
View File
@@ -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)
</script>
@@ -283,19 +286,7 @@ onMounted(loadPermissions)
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }">
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions density-actions">
<el-button class="density-action" v-if="canAddChild(row as Permission)" size="small" data-tone="neutral" @click="openCreate((row as Permission).id)">添加下级</el-button>
<el-button class="density-action" v-if="canEditRow(row as Permission)" size="small" data-tone="neutral" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)">编辑</el-button>
<el-button class="density-action" v-if="canChangeStatusRow(row as Permission)" size="small" :data-tone="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button class="density-action" v-if="showDeleteDirect(row as Permission)" size="small" data-tone="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)">删除</el-button>
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
<el-button data-tone="neutral" class="density-action" size="small" :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item">删除</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<span v-else class="secondary-text">仅查看</span>
</template>
<template #actions="{ row }"><RowActions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
@@ -178,7 +178,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', ''], true],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, MoreHorizontal } from '@lucide/vue'
import { Download, Pencil } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -14,6 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -775,21 +776,27 @@ watch(containerConvertOpen, (open) => {
containerConversionIdempotencyKey = ''
})
function rowActionCount(source: object) {
const row = source as LastMileOrderListItem
const count = 1
+ Number(access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW')
+ Number(access.can(permission.inboundOrderCreate))
+ (access.can(permission.lastMileOrderTransfer) ? 2 : 0)
+ Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row))
+ Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row))
return hasRelabelActions(row) ? Math.max(4, count) : count
}
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
function hasRelabelActions(source: object) {
const row = source as LastMileOrderListItem
return access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && (canConfirmRelabel(row) || canReworkRelabel(row))
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
return [
{ key: 'detail', label: '查看', priority: 500 },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' },
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) },
]
}
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
if (command === 'detail') openDetail(row)
else if (command === 'review') openTransferReview(row)
else if (command === 'inbound-plan') openInboundPlan(row)
else if (command === 'hold') openHold(row)
else if (command === 'manual-nodes') void openManualNodes(row)
else if (command === 'confirm-relabel') void confirmRelabel(row)
else if (command === 'rework-relabel') void reworkRelabel(row)
}
async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
@@ -884,7 +891,7 @@ onMounted(() => { void load() })
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" :disabled="!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED'" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" :disabled="!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED'" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canConfirmRelabel(row)" @click="confirmRelabel(row)">换标完成确认</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canReworkRelabel(row)" @click="reworkRelabel(row)">重新换标</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
@@ -2,12 +2,13 @@
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { FileUp, MoreHorizontal, Plus, RefreshCw, X } from '@lucide/vue'
import { FileUp, Plus, RefreshCw, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
@@ -88,8 +89,23 @@ function canDelete(row: InboundFeeDetail) { return isManual(row) && access.can('
function canReview(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function rowActionCount(row: InboundFeeDetail) {
return Number(canEdit(row)) + 2 * Number(canReview(row)) + Number(canAdjust(row)) + Number(canVoid(row)) + Number(canDelete(row))
function feeRowActions(row: InboundFeeDetail): RowActionItem[] {
return [
{ key: 'edit', label: '编辑', priority: 400, visible: canEdit(row) },
{ key: 'approve', label: '审核', priority: 350, tone: 'warning', visible: canReview(row) },
{ key: 'reject', label: '驳回', priority: 300, tone: 'warning', visible: canReview(row) },
{ key: 'adjust', label: '调整', priority: 250, visible: canAdjust(row) },
{ key: 'void', label: '作废', priority: 200, tone: 'danger', visible: canVoid(row) },
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDelete(row) },
]
}
function handleFeeRowAction(command: string, row: InboundFeeDetail) {
if (command === 'edit') openEditor('edit', row)
else if (command === 'approve') void approve(row)
else if (command === 'reject') openReject(row)
else if (command === 'adjust') openEditor('adjust', row)
else if (command === 'void') void voidRow(row)
else if (command === 'delete') void remove(row)
}
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function publicationLabel(value: string) {
@@ -287,22 +303,7 @@ onMounted(() => void load())
<div v-loading="loading" class="fee-table-shell">
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
<el-table-column label="操作" fixed="left" width="250">
<template #default="{ row }">
<div class="density-actions">
<el-button v-if="canEdit(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('edit', row as InboundFeeDetail)">编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="approve(row as InboundFeeDetail)">审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="openReject(row as InboundFeeDetail)">驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('adjust', row as InboundFeeDetail)">调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" class="density-action" data-tone="danger" @click="voidRow(row as InboundFeeDetail)">作废</el-button>
<el-button v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) <= 3" class="density-action" data-tone="danger" @click="remove(row as InboundFeeDetail)">删除</el-button>
<el-dropdown v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) > 3" trigger="click">
<el-button class="density-action" data-tone="neutral" :aria-label="(row.feeTypeNameSnapshot || '当前费用') + '的更多操作'" title="更多操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="remove(row as InboundFeeDetail)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #default="{ row }"><RowActions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
</el-table-column>
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
@@ -229,10 +229,9 @@ describe('UnifiedOrdersView contract', () => {
})
it('counts detail in the three directly visible row actions', () => {
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(0, 2)')
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(2)')
expect(source).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
expect(source).toContain('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
})
it('opens smart loading from the order table toolbar with its own permission', () => {
@@ -392,9 +391,8 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('请至少输入一项需要修改的外箱尺寸')
expect(source).toContain(".map((value) => value == null || value === '' ? null : Number(value))")
expect(source).toContain('相关数值已重新计算')
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain('handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)')
expect(source).toContain(':more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
expect(source).toContain("can('platform:order:note')")
expect(source).toContain('await loadOrders()')
expect(source).toContain('orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus)')
@@ -684,9 +682,9 @@ describe('UnifiedOrdersView contract', () => {
it('uses outlined semantic buttons for row business actions while keeping detail navigation explicit', () => {
const rowActions = source.slice(source.indexOf('<template #actions'), source.indexOf('<template #inboundCode'))
expect(rowActions).toContain('density-action')
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
expect(rowActions).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
expect(rowActions).toContain('<RowActions')
expect(rowActions).toContain(':actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(rowActions).toContain('@action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)"')
expect(source).toContain('<el-button link class="back-button density-back"')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, MoreHorizontal, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -42,6 +42,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import {
firstLegBusinessTypeLabel,
firstLegContainerModeLabel,
@@ -2003,12 +2004,6 @@ function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRo
...(can('platform:order:note') ? ['note' as const] : []),
]
}
function directUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(0, 2)
}
function moreUnifiedOrderActions(row: UnifiedOrderResource) {
return unifiedOrderSecondaryActions(row).slice(2)
}
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
}
@@ -2017,6 +2012,22 @@ function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
if (action === 'approve') return '通过'
return '测量'
}
function unifiedOrderRowActions(row: UnifiedOrderResource): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 500 },
...unifiedOrderSecondaryActions(row).map((action, index): RowActionItem => ({
key: action,
label: unifiedOrderActionLabel(action),
priority: 400 - index,
tone: action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral',
loading: action === 'approve' && approvalLoadingId.value === String(row.orderId),
})),
]
}
function handleUnifiedOrderRowAction(command: string, row: UnifiedOrderResource) {
if (command === 'detail') openDetail(row)
else runMoreAction(command, row)
}
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
if (command === 'note') openNoteDialog(row)
if (command === 'approve') openApproval(row)
@@ -2647,20 +2658,7 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions density-actions">
<el-button class="density-action" size="small" @click="openDetail(row as UnifiedOrderResource)">详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" class="density-action" :data-tone="action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral'" size="small" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)">{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button class="density-action" size="small" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action">{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
+27
View File
@@ -1,6 +1,8 @@
import ElementPlus from 'element-plus'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import RowActions, { type RowActionItem } from './RowActions.vue'
const actions: RowActionItem[] = [
@@ -12,6 +14,21 @@ const actions: RowActionItem[] = [
]
describe('RowActions', () => {
it.each([
'src/modules/container-loading/pages/ContainersView.vue',
'src/modules/customers/pages/CustomersView.vue',
'src/modules/finance/pages/FinanceCustomerAccountsView.vue',
'src/modules/finance/pages/FinanceCustomerArapView.vue',
'src/modules/finance/pages/FinanceCustomerStatementsView.vue',
'src/modules/iam/pages/PermissionsView.vue',
'src/modules/overseas-warehouse/pages/DispatchesView.vue',
'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue',
'src/modules/unified-orders/components/InboundFeeDetailManager.vue',
'src/modules/unified-orders/pages/UnifiedOrdersView.vue',
])('keeps complex list actions on the shared component: %s', (path) => {
expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toContain('<RowActions')
})
it('shows at most three prioritized actions and moves the rest into more', () => {
const wrapper = mount(RowActions, {
props: { actions, moreLabel: '更多操作 · PC-1' },
@@ -49,6 +66,16 @@ describe('RowActions', () => {
expect(wrapper.emitted('action')).toEqual([['view']])
})
it('gives direct actions a business-specific accessible label when provided', () => {
const wrapper = mount(RowActions, {
props: { actions: [{ key: 'view', label: '查看', ariaLabel: '查看 CB-1' }] },
global: { plugins: [ElementPlus] },
})
expect(wrapper.get('.row-actions > .el-button').attributes('aria-label')).toBe('查看 CB-1')
expect(wrapper.get('.row-actions > .el-button').attributes('title')).toBe('查看 CB-1')
})
it('omits hidden actions without rendering an empty more menu', () => {
const wrapper = mount(RowActions, {
props: { actions: [{ key: 'edit', label: '编辑', visible: false }] },
+5 -1
View File
@@ -7,6 +7,7 @@ export type RowActionTone = 'neutral' | 'warning' | 'purple' | 'primary' | 'succ
export interface RowActionItem {
key: string
label: string
ariaLabel?: string
visible?: boolean
disabled?: boolean
loading?: boolean
@@ -57,7 +58,9 @@ function select(action: RowActionItem) {
size="small"
:disabled="action.disabled"
:loading="action.loading"
@click="select(action)"
:aria-label="action.ariaLabel"
:title="action.ariaLabel"
@click.stop="select(action)"
>{{ action.label }}</el-button>
<el-dropdown v-if="overflowActions.length" trigger="click">
<el-button
@@ -66,6 +69,7 @@ function select(action: RowActionItem) {
size="small"
:aria-label="moreLabel"
:title="moreLabel"
@click.stop
><MoreHorizontal :size="15" aria-hidden="true" /></el-button>
<template #dropdown>
<el-dropdown-menu>