This commit is contained in:
2026-10-27 17:44:49 +08:00
parent 3464f35a01
commit 8451607f6c
28 changed files with 478 additions and 282 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeCustomerServiceIcon,
children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
],
},
{
@@ -94,7 +94,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<template>
<section ref="dashboardRef" class="dashboard-view admin-workbench">
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench">
<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
<section class="dashboard-context" aria-label="数据看板操作">
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
<div class="dashboard-head-actions">
@@ -128,6 +128,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<style scoped>
.dashboard-view { display: block; min-width: 0; }
.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }
.dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); }
.dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; }
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createRequire } from 'node:module'
import DomesticContainersView from './DomesticContainersView.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import * as api from '../api/domesticContainers'
import type { InventoryRecord } from '../types'
import type { InventoryPage, InventoryRecord } from '../types'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
// Use Node's supported require export so the validator retains its constructor interop.
@@ -31,11 +34,16 @@ describe('DomesticContainersView', () => {
})
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => {
for (const fact of ['summary.inboundBoxes', 'summary.outboundBoxes', 'summary.availableBoxes', 'summary.inboundVolumeCbm']) expect(source).toContain(fact)
it('keeps all four inventory facts and units in shared compact metric cards', () => {
for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
expect(source).toContain("import './domesticContainersSummary.css'")
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap')
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)')
const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
expect(summary).not.toContain('v-if=')
expect(summary.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(4)
expect(summary.match(/<span>箱<\/span>/g)).toHaveLength(3)
expect(summary).toContain("summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span>")
expect(summaryStyles).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(summaryStyles).toContain('gap: var(--workbench-grid-gap)')
expect(summaryStyles).not.toContain('position: absolute')
})
it('does not offer an inventory detail action before an inbound has formed inventory', () => { expect(source).toContain("canViewInventory(row as ContainerOrder | InventoryRecord)"); expect(source).toContain("row.status === 'COMPLETED'") })
@@ -49,10 +57,14 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mock
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
let mounted: VueWrapper | undefined
async function openCreate(label: string) {
async function mountView() {
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
mounted = view
await flushPromises()
return view
}
async function openCreate(label: string) {
const view = await mountView()
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises()
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
@@ -64,7 +76,7 @@ async function submitCreate() {
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
await flushPromises()
}
describe('DomesticContainersView create workflows', () => {
describe('DomesticContainersView workflows', () => {
beforeEach(() => {
vi.clearAllMocks()
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
@@ -75,6 +87,60 @@ describe('DomesticContainersView create workflows', () => {
})
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
it('loads the overview on entry and keeps it above the list across all three tabs', async () => {
const view = await mountView()
const overview = view.get('.inventory-summary')
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(1)
for (const label of ['整柜出库单', '整柜库存', '整柜入库单']) {
const requests = vi.mocked(api.listDomesticContainerInventories).mock.calls.length
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
expect(view.element.firstElementChild).toBe(overview.element)
expect(overview.findAll('strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(api.listDomesticContainerInventories).toHaveBeenCalledTimes(requests + 1)
}
})
it.each(['整柜入库单', '整柜出库单'])('uses common filters for the overview on %s without passing order-only filters', async label => {
const view = await mountView()
await view.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
const filters = { warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', cargoName: '测试货物', orderNo: 'ORDER-01', status: 'PENDING' }
view.findComponent(QueryToolbar).vm.$emit('search', filters)
await flushPromises()
expect(api.listDomesticContainerInventories).toHaveBeenLastCalledWith({ page: 1, size: 1, warehouseId: filters.warehouseId, containerNo: filters.containerNo, cargoName: filters.cargoName })
const list = label === '整柜入库单' ? api.listDomesticContainerInbounds : api.listDomesticContainerOutbounds
expect(list).toHaveBeenLastCalledWith({ ...filters, page: 1, size: 20 })
})
it('shows unknown summary values on failure while keeping the order list available and allowing retry', async () => {
vi.mocked(api.listDomesticContainerInventories).mockRejectedValueOnce(new Error('统计失败'))
const view = await mountView()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['—', '—', '—', '—'])
expect(view.findComponent(DataTable).props('resultsVisible')).toBe(true)
const error = view.findComponent(LoadErrorState)
expect(error.props('title')).toBe('整柜库存统计加载失败')
error.vm.$emit('retry')
await flushPromises()
expect(view.findComponent(LoadErrorState).exists()).toBe(false)
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
})
it('ignores a stale overview response after switching tabs', async () => {
let resolveFirst!: (value: InventoryPage) => void
vi.mocked(api.listDomesticContainerInventories).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const view = await mountView()
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('true')
await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises()
resolveFirst({ records: [], page: 1, size: 1, total: 0, pages: 0, summary: { inboundBoxes: 99, outboundBoxes: 0, availableBoxes: 99, inboundVolumeCbm: 99 } })
await flushPromises()
expect(view.findAll('.inventory-summary strong').map(value => value.text())).toEqual(['10', '2', '8', '2.50'])
expect(view.get('.inventory-summary').attributes('aria-busy')).toBe('false')
})
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
const { form } = await openCreate('新建入库单')
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus } from '@lucide/vue'
import { Box, Boxes, PackageCheck, PackagePlus, Plus } from '@lucide/vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -28,7 +28,10 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
{ value: 'inventory', label: '整柜库存' },
]
const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null); const rows = ref<(ContainerOrder | InventoryRecord)[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const summary = ref<InventorySummary>({ inboundBoxes: 0, outboundBoxes: 0, availableBoxes: 0, inboundVolumeCbm: 0 })
const summary = ref<InventorySummary | null>(null)
const summaryLoading = ref(false)
const summaryError = ref<ApiErrorDetails | null>(null)
let loadSequence = 0
const warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
@@ -49,7 +52,41 @@ function displayTime(value: string) { return value ? value.replace('T', ' ').sli
function statusLabel(row: ContainerOrder | InventoryRecord) { if ('inventoryStatus' in row) return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[row.inventoryStatus]; return row.status === 'PENDING' ? (activeTab.value === 'inbound' ? '待入库' : '待出库') : (activeTab.value === 'inbound' ? '已入库' : '已出库') }
function canViewInventory(row: ContainerOrder | InventoryRecord) { return 'inventoryStatus' in row || activeTab.value === 'outbound' || row.status === 'COMPLETED' }
function queryParams() { const model = appliedFilters.value; return { page: page.value, size: pageSize.value, warehouseId: String(model.warehouseId || '') || undefined, orderNo: activeTab.value === 'inventory' ? undefined : String(model.orderNo || '') || undefined, containerNo: String(model.containerNo || '') || undefined, cargoName: String(model.cargoName || '') || undefined, status: String(model.status || '') || undefined } }
async function load() { loading.value = true; loadError.value = null; try { const result = activeTab.value === 'inbound' ? await listDomesticContainerInbounds(queryParams()) : activeTab.value === 'outbound' ? await listDomesticContainerOutbounds(queryParams()) : await listDomesticContainerInventories(queryParams()); rows.value = result.records; total.value = result.total; if ('summary' in result) summary.value = result.summary } catch (cause) { loadError.value = getApiErrorDetails(cause, '整柜业务加载失败') } finally { loading.value = false } }
async function load() {
const sequence = ++loadSequence
const tab = activeTab.value
const params = queryParams()
loading.value = true
loadError.value = null
summaryLoading.value = true
summaryError.value = null
summary.value = null
const inventoryRequest = listDomesticContainerInventories(tab === 'inventory' ? params : {
page: 1,
size: 1,
warehouseId: params.warehouseId,
containerNo: params.containerNo,
cargoName: params.cargoName,
})
const summaryTask = inventoryRequest.then(result => {
if (sequence === loadSequence) summary.value = result.summary
}).catch(cause => {
if (sequence === loadSequence && tab !== 'inventory') summaryError.value = getApiErrorDetails(cause, '整柜库存统计加载失败')
}).finally(() => {
if (sequence === loadSequence) summaryLoading.value = false
})
const listRequest = tab === 'inbound' ? listDomesticContainerInbounds(params) : tab === 'outbound' ? listDomesticContainerOutbounds(params) : inventoryRequest
const listTask = listRequest.then(result => {
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
}).catch(cause => {
if (sequence === loadSequence) loadError.value = getApiErrorDetails(cause, '整柜业务加载失败')
}).finally(() => {
if (sequence === loadSequence) loading.value = false
})
await Promise.all([summaryTask, listTask])
}
async function loadWarehouses() { warehouseLoading.value = true; warehouseError.value = null; try { warehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })).filter((item) => item.warehouseType === 'DOMESTIC' && item.status === 'ACTIVE') } catch (cause) { warehouseError.value = getApiErrorDetails(cause, '国内仓候选加载失败') } finally { warehouseLoading.value = false } }
function search(model = filters.value) { appliedFilters.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function switchTab(name: TabName) { if (activeTab.value === name) return; activeTab.value = name; filters.value = { warehouseId: filters.value.warehouseId || '', orderNo: '', containerNo: '', cargoName: '', status: '' }; appliedFilters.value = { ...filters.value }; page.value = 1; void load() }
@@ -73,13 +110,31 @@ async function submit() { if (!await formRef.value?.validate().catch(() => false
watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
</script>
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
<template><AdminPage as="div" spacing="compact" class="domestic-containers-page admin-density admin-workbench">
<section v-loading="summaryLoading" class="inventory-summary" aria-label="整柜库存概览" :aria-busy="summaryLoading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackagePlus aria-hidden="true" /></span><h2 class="workbench-metric-label">入库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已出库箱数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.outboundBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Boxes aria-hidden="true" /></span><h2 class="workbench-metric-label">当前库存</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.availableBoxes ?? '—' }}</strong><span>箱</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="teal">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Box aria-hidden="true" /></span><h2 class="workbench-metric-label">累计入库体积</h2></div>
<div class="workbench-metric-value"><strong>{{ summary?.inboundVolumeCbm.toFixed(2) ?? '—' }}</strong><span>m³</span></div>
</article>
</section>
<LoadErrorState v-if="summaryError" compact title="整柜库存统计加载失败" :message="summaryError.message" :loading="summaryLoading" @retry="load"><template #details><ApiErrorMeta :details="summaryError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></div></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog admin-workbench is-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
@@ -122,4 +177,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
</el-form>
</AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--workbench-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--workbench-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--workbench-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
<style scoped>
.admin-workbench.domestic-containers-page.admin-page.is-spacing-compact { gap: 0; }
.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
</style>
@@ -1,9 +1,17 @@
.domestic-containers-page .table-panel .inventory-summary {
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0;
padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 0; border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff);
.domestic-containers-page .inventory-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--workbench-grid-gap);
}
.domestic-containers-page .table-panel .inventory-summary > div {
display: flex; align-items: baseline; min-width: 0; gap: var(--admin-space-3, 8px); padding: 0; border: 0;
@media (max-width: 720px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 360px) {
.domestic-containers-page .inventory-summary {
grid-template-columns: 1fr;
}
}
.domestic-containers-page .table-panel .inventory-summary span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.domestic-containers-page .table-panel .inventory-summary strong { font-size: var(--admin-font-section, 14px); font-weight: 600; }
@@ -3,7 +3,7 @@ import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ArrowLeft, CircleCheck, Download, RefreshCw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -448,12 +448,6 @@ onMounted(() => {
</div>
</div>
</template>
<template v-if="detail" #summary>
<dl class="density-detail-overview">
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
</dl>
</template>
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
@@ -462,9 +456,11 @@ onMounted(() => {
<template v-else-if="detail">
<section class="version-panel">
<header class="section-heading">
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
<div><h2>版本信息</h2></div>
</header>
<dl class="detail-grid density-fields">
<div class="detail-grid__half"><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<div class="detail-grid__half"><dt>上传时间</dt><dd>{{ formatDateTime(detail.version.createdAt) }}</dd></div>
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
@@ -475,7 +471,7 @@ onMounted(() => {
</section>
<section v-if="isPending && canEdit" class="mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<header class="section-heading"><div><h2>仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
@@ -514,37 +510,39 @@ onMounted(() => {
</section>
<section class="detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<header class="section-heading"><div><h2>解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
<el-table-column prop="issueCode" label="编码" width="270" />
<el-table-column prop="message" label="说明" min-width="320" />
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
<header class="section-heading"><div><h2>按渠道预览</h2></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label><span class="density-tab-icon" aria-hidden="true">🚢</span>{{ channel.channelName }}</template></el-tab-pane>
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode"><template #label>{{ channel.channelName }}</template></el-tab-pane>
</el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
<dl class="channel-facts density-fields" aria-label="渠道信息">
<div><dt>渠道</dt><dd>{{ selectedChannel?.channelName || '—' }}</dd></div>
<div><dt>计价方式</dt><dd>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</dd></div>
<div><dt>来源表页</dt><dd>{{ selectedChannel?.sourceSheetName || '—' }}</dd></div>
<div><dt>参考时效</dt><dd>{{ selectedChannel?.referenceTransit || '—' }}</dd></div>
<div><dt>最晚进仓时间</dt><dd>{{ selectedChannel?.latestWarehouseEntry || '—' }}</dd></div>
<div><dt>船期</dt><dd>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>附加费</dt><dd>{{ selectedChannel?.surchargeDescription || '—' }}</dd></div>
<div class="channel-facts__wide"><dt>备注</dt><dd>{{ selectedChannel?.remark || '—' }}</dd></div>
</dl>
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column prop="originWarehouseName" label="起运仓" min-width="180" />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
@@ -555,23 +553,24 @@ onMounted(() => {
</template>
<style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; }
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
.price-book-detail { min-width: 0; }
.price-book-detail :deep(.density-detail-layout > .detail-page-layout__content) { padding: 0; }
.price-book-detail .channel-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); --admin-field-label-width: 110px; }
.channel-facts .channel-facts__wide { grid-column: 1 / -1; }
.detail-grid dd, .channel-facts dd { overflow-wrap: anywhere; }
.version-panel, .mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); }
.detail-section:last-child { border-bottom: 0; }
.price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.detail-grid .detail-grid__half { grid-column: span 2; }
.detail-grid .detail-grid__wide { grid-column: 1 / -1; }
.section-heading > div { min-width: 0; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-5); margin: 0; }
.section-heading h2 { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
.section-heading h2 svg { color: var(--workbench-primary); }
.section-heading p { overflow-wrap: anywhere; margin: 5px 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
.is-danger { color: var(--el-color-danger) !important; }
.mapping-section, .detail-section { display: grid; min-width: 0; gap: var(--admin-space-5); }
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
.mapping-groups { display: grid; gap: var(--admin-space-5); }
.mapping-group { display: grid; gap: var(--admin-space-3); }
.mapping-group h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); background: var(--cc-surface-soft, #f8fafc); }
.mapping-row > div { display: grid; min-width: 0; gap: var(--admin-space-1); }
.mapping-row strong { overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-body); }
.mapping-row span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@@ -580,8 +579,8 @@ onMounted(() => {
.mapping-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.mapping-load-error p { margin: var(--admin-space-1) 0; color: var(--workbench-text); font-size: var(--admin-font-caption); }
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
.channel-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
.channel-meta { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); margin: calc(-1 * var(--admin-space-1)) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) {
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-toolbar { width: 100%; }
@@ -56,8 +56,8 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
expect(detailSource).toContain(`label="${label}"`)
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
expect(detailSource).toContain(`<dt>${label}</dt>`)
}
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(detailSource).toContain(`selectedChannel?.${field}`)
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
{ key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' },
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
{ key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 290, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
{ key: 'effectivePeriod', label: '实际生效区间', minWidth: 260, formatter: (row: PlatformPriceBookVersion) => formatEffectivePeriod(row) },
]
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
@@ -382,7 +382,7 @@ onMounted(() => {
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
<template #contents="{ row }"><div class="metric-cell"><span><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong> 渠道</span><span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong> 价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
<template #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
@@ -423,6 +423,7 @@ onMounted(() => {
.version-cell strong { color: var(--workbench-text); }
.version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
.metric-cell > span, .issue-counts > span { white-space: nowrap; }
.metric-cell strong { color: var(--workbench-text); font-size: var(--admin-font-body); }
.issue-counts { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
+1 -1
View File
@@ -5,7 +5,7 @@
| 页面 | 操作 | 原型承载/布局 | 当前实现及本轮结论 |
|---|---|---|---|
| 费用项目 | 列表 | 页面内表格,原型无价格列 | 沿用列表,增加默认价格列,空值显示“—” |
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 沿用现有 620px 抽屉与表单,增加可空默认价格及说明 |
| 费用项目 | 新增、编辑 | 原型未定义表单尺寸与承载方式 | 2026-10-27 用户确认改为居中弹框(760px,受视口宽度限制),三个分区使用白底和中性边框;保留可空默认价格、字段分组及保存行为 |
| 入库费用 | 新增、编辑、调整时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入单价和金额,支持人工覆盖,回显保留原值 |
| 柜次费用 | 新增时选费 | 本轮仅变更选费带值逻辑 | 沿用现有表单;主动选择时带入金额,支持人工覆盖,调整回显保留原值 |
@@ -5,13 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FeeItemsView from './FeeItemsView.vue'
import * as api from '../api/platformFeeItems'
// Preserve the validator constructor interop through Element Plus's supported Node export.
const { default: ElementPlus, ElCheckbox, ElDrawer, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
const { default: ElementPlus, ElCheckbox, ElDialog, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() }))
let record: api.PlatformFeeItemResource
@@ -28,7 +27,7 @@ async function openEditor(create = false) {
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
}
async function setText(placeholder: string, value: string) {
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)!
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
}
async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
@@ -51,12 +50,14 @@ beforeEach(() => {
})
describe('fee item editing and confirmations', () => {
it('creates from the original compact drawer with a zero default and all direction flags', async () => {
it('creates from a centered white dialog with a zero default and all direction flags', async () => {
const wrapper = await openEditor(true)
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' })
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true)
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.el-dialog')).toBeNull()
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.fee-item-editor .density-dialog-section[data-tone]')).toBeNull()
expect(wrapper.findComponent(ElDialog).props('alignCenter')).toBe(true)
expect(document.querySelector('.el-drawer')).toBeNull()
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
@@ -64,33 +65,33 @@ describe('fee item editing and confirmations', () => {
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
await setText('请输入适用场景或核算说明', ' 新说明 ')
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' })
expect(useUnsavedWorkStore().entries).toHaveLength(0)
})
it('validates required fields and payable supplier category without losing entered values', async () => {
const wrapper = await openEditor(true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称'))
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入费用名称'))
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
await check(wrapper, '提货环节应付', true)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用')
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('新费用')
})
it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => {
record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
const wrapper = await openEditor()
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId)
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
await check(wrapper, '提货环节应付', false)
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
})
@@ -99,17 +100,17 @@ describe('fee item editing and confirmations', () => {
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true)
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
rejectSave(new Error('保存失败')); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
})
it('keeps unsaved registration and the existing permission-change closure', async () => {
@@ -118,7 +119,7 @@ describe('fee item editing and confirmations', () => {
await setText('请输入费用名称', '权限变化前的修改')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(useUnsavedWorkStore().entries).toHaveLength(0)
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
})
+8 -9
View File
@@ -23,7 +23,6 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -430,22 +429,22 @@ onMounted(() => {
</DataTable></section>
</ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor admin-workbench is-drawer"
<AppDialog class="admin-density-dialog admin-workbench is-dialog fee-item-editor"
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
width="min(760px, 94vw)" align-center
:loading="editorLoading"
:confirm-loading="saving"
>
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用基本信息">
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
<section class="density-dialog-section workbench-evidence" aria-label="费用基本信息">
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
<table class="density-table density-table--editable fee-item-editor-table"><tbody>
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr>
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" label-width="120px" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="选填" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用方向与供应商">
<section class="density-dialog-section workbench-evidence" aria-label="费用方向与供应商">
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
@@ -454,7 +453,7 @@ onMounted(() => {
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="费用适用说明">
<section class="density-dialog-section workbench-evidence" aria-label="费用适用说明">
<h3 class="density-dialog-section__title">适用说明</h3>
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
</section>
@@ -465,7 +464,7 @@ onMounted(() => {
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
</div>
</template>
</AppDrawer>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="statusOpen"
+4 -4
View File
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料">
<el-form class="density-form density-form--outlined density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
<section class="density-dialog-section workbench-evidence" aria-label="用户基本资料">
<h3 class="density-dialog-section__title">用户资料</h3>
<table class="density-table density-table--editable user-editor-table"><tbody>
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
@@ -444,7 +444,7 @@ onMounted(loadInitialData)
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" aria-label="用户作业仓库">
<h3 class="density-dialog-section__title">作业仓库</h3>
<table class="density-table density-table--editable"><tbody>
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
@@ -459,7 +459,7 @@ onMounted(loadInitialData)
</el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" aria-label="用户登录密码">
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
<table class="density-table density-table--editable user-editor-table"><tbody>
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
@@ -85,14 +85,15 @@ describe('PermissionsView IAM mode', () => {
it('keeps the shared status badge prop defined during table slot initialization', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'")
})
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
it('uses outlined business action buttons without global tree view helpers', () => {
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 text-actions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('展开全部')
expect(permissionsSource).not.toContain('收起操作权限')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
@@ -103,7 +104,10 @@ describe('PermissionsView IAM mode', () => {
})
it('keeps technical permission fields out of the default list and labels the table', () => {
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }")
expect(permissionsSource).not.toContain("key: 'code', label: '权限编码'")
expect(permissionsSource).not.toContain('<FolderTree')
expect(permissionsSource).not.toContain('<ShieldCheck')
expect(permissionsSource).not.toContain('<Braces')
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
+13 -28
View File
@@ -4,7 +4,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 { FolderTree, Plus, ShieldCheck } from '@lucide/vue'
import { Plus } 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'
@@ -90,9 +90,10 @@ const parentOptions = computed(() => permissions.value.filter((item) => (
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
)))
const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions', className: 'permission-actions-column' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
{ key: 'menuCount', label: '菜单数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).menuCount },
{ key: 'actionCount', label: '操作数', width: 90, align: 'right', formatter: (row: PermissionTreeNode) => permissionCounts(row).actionCount },
{ key: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
@@ -128,25 +129,16 @@ function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
return ids
}
function permissionSummary(row: PermissionTreeNode) {
function permissionCounts(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item))
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限'
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
return { menuCount, actionCount }
}
function expandAllRows() {
expandedRowKeys.value = collectDescendantIds(treeRows.value)
tableRenderKey.value += 1
}
function collapseActionRows() {
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
}
function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId })
@@ -235,7 +227,6 @@ async function removePermission(row: Permission) {
rowActionId.value = ''
}
}
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
function canAddChild(row: Permission) {
return canCreate.value && ['菜单', '页面'].includes(row.type)
@@ -287,13 +278,11 @@ onMounted(loadPermissions)
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #before-results><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 }"><RowActions text-actions 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 #name="{ row }"><strong>{{ (row as Permission).name }}</strong></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>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
@@ -317,23 +306,19 @@ onMounted(loadPermissions)
</template>
<style scoped>
:deep(td.permission-actions-column > .cell) { display: flex; align-items: center; white-space: nowrap; }
:deep(td.permission-actions-column > .cell > .row-actions) { display: inline-flex; flex-wrap: nowrap; flex-shrink: 0; }
:deep(td.permission-actions-column > .cell > .el-table__indent),
:deep(td.permission-actions-column > .cell > .el-table__expand-icon),
:deep(td.permission-actions-column > .cell > .el-table__placeholder) { flex-shrink: 0; }
.permission-editor-table { table-layout: fixed; }
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
.permission-protection-hint { color: var(--workbench-muted); }
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 1.35; }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) {
.permission-editor-table tr { display: grid; }
.permission-editor-table td:not([colspan="2"]) { width: auto; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style>
@@ -68,7 +68,7 @@ beforeEach(() => {
describe('roles and permission dialogs', () => {
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
expect(dialog.props()).toMatchObject({ title: '配置角色 · 测试角色', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
expect(tree.vm.getCheckedKeys()).toContain(childId)
expect(tree.vm.getNode(hiddenId)).toBeTruthy()
+2 -2
View File
@@ -117,11 +117,11 @@ describe('RolesView', () => {
expect(rolesSource).toContain('aria-label="平台角色列表"')
})
it('uses the prototype-sized centered role editor with responsive split panes', () => {
it('uses the prototype-sized centered role editor with stacked information and permissions', () => {
expect(rolesSource).toContain('<AppDialog v-model="editorOpen"')
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
expect(rolesSource).not.toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
+12 -10
View File
@@ -63,7 +63,7 @@ const formRules: FormRules = {
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
}
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色')
const editorLabel = computed(() => form.id ? `配置角色 · ${form.name.trim() || '—'}` : '新增角色')
const canEdit = computed(() => access.can('platform:role:edit'))
const canGrant = computed(() => access.can('platform:role:grant'))
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
@@ -263,13 +263,13 @@ onMounted(loadRoles)
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="角色基本资料">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-form--outlined density-dialog-sections role-editor-layout" :disabled="saving">
<section class="density-dialog-section workbench-evidence" aria-label="角色基本资料">
<h3 class="density-dialog-section__title">基本信息</h3>
<table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
<table class="density-table density-table--editable role-basic-table"><tbody>
<tr><td :colspan="form.id ? 2 : 1"><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td>
<td v-if="!form.id"><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
</tbody></table>
</section>
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
@@ -282,10 +282,12 @@ onMounted(loadRoles)
</template>
<style scoped>
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; }
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); }
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
.role-basic-table { table-layout: fixed; }
.role-basic-table td:not([colspan]) { width: 50%; }
.role-editor-permissions .permission-tree { max-height: min(320px, 35vh); overflow: auto; overscroll-behavior: contain; }
.permission-node span { min-width: 0; }
.permission-node.is-navigation-group span { font-weight: 650; }
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 720px) { .role-basic-table td { display: block; width: 100%; box-sizing: border-box; } }
</style>
@@ -53,8 +53,8 @@ async function save() {
<h3 class="density-dialog-section__title">报价资料</h3>
<dl class="density-fields fleet-quote-facts"><div v-for="(fact,index) in facts" :key="index"><dt>{{fact[0]}}</dt><dd>{{fact[1]}}</dd></div></dl>
</section>
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving">
<section class="density-dialog-section" data-tone="work" aria-label="报价路线与归属">
<el-form v-else label-position="left" label-width="130px" class="density-form density-form--outlined density-dialog-sections" :disabled="saving">
<section class="density-dialog-section" aria-label="报价路线与归属">
<h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="用途"><span>{{form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'}}</span></el-form-item></td><td><el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="saving" /></el-form-item></td></tr>
@@ -62,14 +62,14 @@ async function save() {
<tr><td><el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item></td><td><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="报价金额">
<section class="density-dialog-section" aria-label="报价金额">
<h3 class="density-dialog-section__title">报价金额<InlineHelp title="报价金额说明"><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p></InlineHelp></h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="留空表示未报价" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="报价有效期与备注">
<section class="density-dialog-section" aria-label="报价有效期与备注">
<h3 class="density-dialog-section__title">有效期与备注</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { changeExchangeRateStatus, createExchangeRate, getExchangeRate, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
@@ -33,10 +33,9 @@ const columns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
async function load() { loading.value = true; error.value = null; try { const result = await listExchangeRates(params()); rows.value = result.records; total.value = result.total } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
@@ -54,7 +53,6 @@ watch([page, pageSize], load); onMounted(load)
<template #toolbar-start><h1>汇率管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<template #before-results><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
@@ -62,10 +60,10 @@ watch([page, pageSize], load); onMounted(load)
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></div></section>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="editorOpen" :title="editingMonth ? `编辑月度汇率 · ${editingMonth}` : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form class="density-form" label-position="left" label-width="160px" :disabled="saving">
<el-form class="density-form density-form--outlined" label-position="left" label-width="160px" :disabled="saving">
<table class="density-table density-table--editable" aria-label="月度汇率资料"><tbody>
<tr><td><el-form-item label="汇率月份"><span v-if="editingMonth">{{ form.month }}</span><el-date-picker v-else v-model="form.month" type="month" value-format="YYYY-MM" /></el-form-item></td></tr>
<tr><td><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /></el-form-item></td></tr>
<tr><td><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入汇率" /></el-form-item></td></tr>
<tr><td><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
<p class="density-dialog-note">汇率方向:1 USD = X CNY</p>
@@ -46,7 +46,7 @@ watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); w
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="报价用途"><button v-for="tab in ([['PURCHASE','车队采购报价'],['SALE','外接销售报价']] as const)" :key="tab[0]" role="tab" :aria-selected="kind === tab[0]" :class="{ 'is-active': kind === tab[0] }" @click="kind = tab[0]">{{ tab[1] }}</button></div></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车队报价查询" @search="search" @reset="search" /></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export') && access.can('platform:download:list')" :loading="busy" @click="download">导出</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
<template #actions="{ row }"><RowActions text-actions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
<template #origin="{ row }">{{ row.details.originCity }}</template><template #destination="{ row }">{{ row.details.destinationCode }}</template><template #miles="{ row }">{{ row.details.distanceMiles }}</template><template #currency="{ row }">{{ row.details.currency }}</template>
<template #pickup="{ row }">{{ row.details.selfPickupPrice ?? '—' }}</template><template #full="{ row }">{{ row.details.fullDeliveryPrice ?? '—' }}</template><template #loose="{ row }">{{ row.details.looseDeliveryPrice ?? '—' }}</template>
<template #validity="{ row }">{{ row.details.validFrom }} — {{ row.details.validTo }}</template><template #status="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
@@ -85,8 +85,8 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="editorOpen" :title="editingId ? `编辑海外仓报价 · ${form.name.trim() || '—'}` : '新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" confirm-text="保存报价" @confirm="save">
<el-form class="density-form density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
<section class="density-dialog-section" data-tone="work" aria-label="报价基础信息">
<el-form class="density-form density-form--outlined density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
<section class="density-dialog-section" aria-label="报价基础信息">
<h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable quote-basics-table"><tbody>
<tr><td><el-form-item label="报价名称"><el-input v-model="form.name" placeholder="请输入报价名称" /></el-form-item></td><td><el-form-item label="海外仓"><span v-if="editingId">{{ editingWarehouse }}</span><el-select v-else v-model="form.warehouseId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item></td></tr>
@@ -95,14 +95,14 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
<tr><td><el-form-item label="报价版本"><span v-if="editingId">{{form.versionCode || '—'}}</span><el-input v-else v-model="form.versionCode" placeholder="请输入版本号" /></el-form-item></td><td><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="编辑仓储作业费用">
<section class="density-dialog-section" aria-label="编辑仓储作业费用">
<h3 class="density-dialog-section__title quote-section-heading">仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3>
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">费用项目</th><th scope="col">计费单位</th><th scope="col">单价</th><th scope="col">最低收费</th></tr></thead><tbody>
<tr v-for="(f,i) in form.warehouseFees" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项费用`" @click="form.warehouseFees.splice(i,1)">删除</el-button></td><td><el-input v-model="f.item" :aria-label="`第${i+1}项费用项目`" placeholder="请输入费用项目"/></td><td><el-input v-model="f.billingUnit" :aria-label="`第${i+1}项计费单位`" placeholder="请输入计费单位"/></td><td><el-input-number v-model="f.unitPrice" :aria-label="`第${i+1}项费用单价`" :min="0" placeholder="请输入单价" /></td><td><el-input-number v-model="f.minimumCharge" :aria-label="`第${i+1}项费用最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
<tr v-if="!form.warehouseFees.length"><td colspan="5">暂无仓储作业费用</td></tr>
</tbody></table></div>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="编辑中转派送价格">
<section class="density-dialog-section" aria-label="编辑中转派送价格">
<h3 class="density-dialog-section__title quote-section-heading">中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3>
<div class="quote-lines-scroll"><table class="density-table density-table--editable quote-lines-table"><thead><tr><th scope="col">操作</th><th scope="col">目的FBA仓</th><th scope="col">车型/提货方式</th><th scope="col">报价</th><th scope="col">最低收费</th></tr></thead><tbody>
<tr v-for="(t,i) in form.transitPrices" :key="i"><td><el-button data-tone="danger" class="density-action" :aria-label="`删除第${i+1}项派送价格`" @click="form.transitPrices.splice(i,1)">删除</el-button></td><td><el-input v-model="t.fbaWarehouseCode" :aria-label="`第${i+1}项目的FBA仓`" placeholder="请输入目的 FBA 仓"/></td><td><el-input v-model="t.deliveryMethod" :aria-label="`第${i+1}项车型或提货方式`" placeholder="请输入车型或提货方式"/></td><td><el-input-number v-model="t.price" :aria-label="`第${i+1}项派送报价`" :min="0" placeholder="请输入报价" /></td><td><el-input-number v-model="t.minimumCharge" :aria-label="`第${i+1}项派送最低收费`" :min="0" placeholder="请输入最低收费" /></td></tr>
@@ -152,13 +152,14 @@ it('allows editing after unknown failure and submits a new command', async () =>
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(payload.idempotencyKey); expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(expect.objectContaining({ handlingResult: 'changed' }))
wrapper.unmount()
})
it('keeps a conflict visible and lets the operator refresh voluntarily', async () => {
it('keeps a conflict visible and reloads when the operator reopens the detail', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1)
await wrapper.findAll('button').find(button => button.text() === '重新读取详情')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('重新读取详情')
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
@@ -284,3 +285,21 @@ it('twenty-one historical files do not get resubmitted automatically with the ne
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
wrapper.unmount()
})
it('moves processing controls into the dialog footer target', async () => {
const footer = document.createElement('div')
footer.id = 'exception-test-footer'
document.body.append(footer)
const wrapper = open()
try {
await flushPromises()
await wrapper.setProps({ actionsTarget: '#exception-test-footer' })
await flushPromises()
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
expect(footer.querySelector('[data-test="complete"]')).not.toBeNull()
expect(wrapper.text()).not.toContain('重新读取详情')
} finally {
wrapper.unmount()
footer.remove()
}
})
@@ -10,7 +10,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { returnSourceKey, returnSourceReference } from '../returnSource'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean; actionsTarget?: string }>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
@@ -156,9 +156,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<template>
<section v-loading="loading" aria-label="异常处理详情">
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions class="density-descriptions exception-detail-fields" :column="2" border>
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
@@ -180,7 +179,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history workbench-evidence">
<h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
<el-descriptions :column="3" border>
<el-descriptions class="density-descriptions" :column="3" border>
<el-descriptions-item label="退回收货号">{{ receipt.receiptNo }}</el-descriptions-item>
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</el-descriptions-item>
@@ -200,8 +199,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
</section>
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess">
<el-form label-position="top" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
<el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)" label-width="220px"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="请输入处理说明或取消原因(选填)" /></el-form-item>
<template v-if="allowed('COMPLETE_EXCEPTION')">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
@@ -219,15 +218,21 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template>
</el-form>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
<Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<div class="density-actions density-actions--footer">
<el-button v-if="allowed('START_PROCESSING')" data-test="start" type="primary" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" type="danger" plain :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" type="primary" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</div>
</Teleport>
</template>
</template>
</section>
</template>
<style scoped>
.exception-processing-form { margin-top: var(--admin-space-5); }
.exception-detail-fields :deep(.el-descriptions__table) { table-layout: fixed; }
.exception-return-history { margin: 12px 0; }
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
.exception-return-batch + .exception-return-batch { margin-top: 12px; }
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp } from '@lucide/vue'
import { FileUp, ClipboardList, Clock, CircleCheck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -158,14 +158,27 @@ onMounted(load)
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<AdminPage spacing="compact" class="customer-visits-page admin-density admin-workbench">
<section class="visit-summary-grid" aria-label="回访统计">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><ClipboardList aria-hidden="true" /></span><h2 class="workbench-metric-label">可回访订单</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.visitable.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock aria-hidden="true" /></span><h2 class="workbench-metric-label">待回访</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.pending.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已完成回访</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.completed.toLocaleString('zh-CN') }}</strong><span>单</span></div>
</article>
</section>
<ListPageLayout aria-label="客户回访列表">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户回访</h1></template>
<template #filters>
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template>
<template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -212,7 +225,8 @@ onMounted(load)
</template>
<style scoped>
.visit-summary { flex-wrap: wrap; }
.admin-workbench.customer-visits-page.admin-page.is-spacing-compact { gap: 0; }
.visit-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.visit-order-summary.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-5); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); width: 100%; }
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--workbench-muted); overflow-wrap: anywhere; }
@@ -222,5 +236,5 @@ onMounted(load)
.attachment-list, .attachment-empty { width: 100%; margin-top: var(--admin-space-3); }
.attachment-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-2) 0; border-bottom: 1px solid var(--workbench-border); overflow-wrap: anywhere; }
.attachment-list small, .attachment-empty { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
@media (max-width: 720px) { .visit-summary-grid { grid-template-columns: 1fr; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
</style>
@@ -110,7 +110,8 @@ onMounted(load)
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" actions-target="#exception-detail-actions" @changed="load()" />
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">关闭</el-button><div id="exception-detail-actions"></div></div></template>
</AppDialog>
</template>
@@ -96,7 +96,7 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
</script>
<template>
<AdminPage class="admin-density admin-workbench" >
<AdminPage class="supplier-form-page admin-density admin-workbench" >
<OperationErrorToast :details="submitError" />
<DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" :aria-label="editing ? '编辑供应商' : '新增供应商'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑供应商' : '新增供应商' }}</h1></div></div></template>
@@ -135,6 +135,11 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
</template>
<style scoped>
@media screen {
.supplier-form-page :deep(.density-detail-layout > .detail-page-layout__content) { padding: 0; }
.supplier-form-page .density-detail-header { padding-inline: 0; }
.supplier-form-page .supplier-form-panel { padding-top: var(--admin-space-5); }
}
.supplier-form-panel { min-width: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-7); }.span-2 { grid-column: 1 / -1; }
.form-grid :deep(.el-select) { width: 100%; }.file-placeholder { display: flex; width: 100%; min-height: 96px; align-items: center; justify-content: center; flex-direction: column; gap: var(--admin-space-2); padding: var(--admin-space-6); border: 1px dashed var(--workbench-border); border-radius: var(--admin-radius); background: var(--workbench-surface-soft); color: var(--workbench-muted); text-align: center; }.file-placeholder small { max-width: 420px; line-height: 1.5; }
.form-actions { display: flex; justify-content: center; gap: var(--admin-space-5); padding: var(--admin-space-6) 0 0; border-top: 1px solid var(--workbench-border); }.form-actions :deep(.el-button) { min-width: 112px; margin-left: 0; }
@@ -774,8 +774,8 @@ onMounted(() => void loadWarehouses())
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog admin-density-dialog admin-workbench is-dialog" fullscreenable :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
<el-form :model="form" label-position="left" label-width="104px" class="density-form density-dialog-sections" :disabled="saving">
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础信息</h3>
<el-form :model="form" label-position="left" label-width="104px" class="density-form density-form--outlined density-dialog-sections" :disabled="saving">
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库基础信息"><tbody>
<template v-if="isFbaCreate"><tr><td><el-form-item label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item></td><td><el-form-item label="国家或地区" required>
<span>{{ formatCountryDisplay(form.countryCode) }}</span>
@@ -792,7 +792,7 @@ onMounted(() => void loadWarehouses())
<tr v-if="activeWarehouseType === 'FBA'"><td colspan="2"><el-form-item label="内部编号"><el-input v-model="form.internalCode" maxlength="16" placeholder="例如 154 或 CADF" @input="form.internalCode = String($event ?? '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 16)" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">地址信息</h3>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">地址信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库地址信息"><tbody>
<tr><td><el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
@@ -803,12 +803,12 @@ onMounted(() => void loadWarehouses())
<tr><td colspan="2"><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">编码与状态</h3>
<section class="density-dialog-section workbench-evidence" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">编码与状态</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库编码与状态"><tbody>
<tr><td><el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item></td><td><el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">联系信息</h3>
<section class="density-dialog-section workbench-evidence" v-if="!isFbaCreate"><h3 class="density-dialog-section__title">联系信息</h3>
<table class="density-table density-table--editable warehouse-editor-table" aria-label="仓库联系信息"><tbody>
<tr><td><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item></td><td><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" inputmode="numeric" placeholder="选填" @input="form.contactPhone = digitsOnly($event)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收货时间"><el-time-picker v-model="form.receivingCutoffTime" value-format="HH:mm:ss" format="HH:mm" placeholder="请选择收货截止时间" /></el-form-item></td></tr>
+124 -94
View File
@@ -241,100 +241,24 @@ describe('approved Admin density ownership', () => {
expect(style.content).not.toMatch(/inner-wrapper::before\s*\{[^}]*display:\s*none/)
})
it('uses the shared compact metric cards for finance statistics and settlement totals', () => {
expect(read('modules/finance/pages/FinanceReceivableStatisticsView.vue')).toContain('spacing="compact"')
expect(read('modules/finance/pages/FinanceFormalSettlementPage.vue')).toContain(':spacing="detailMode ? \'normal\' : \'compact\'"')
const financeStyles = read('modules/finance/financeDensity.css')
expect(financeStyles).toContain('.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,')
expect(financeStyles).toContain('.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }')
expect(financeStyles).not.toContain('.data-table__toolbar')
const baseStyle = parse(read('shared/components/AdminPage.vue')).descriptor.styles[0]!
const compiled = compileStyle({ source: baseStyle.content, filename: 'AdminPage.vue', id: 'data-v-spacing-check', scoped: true })
expect(compiled.errors).toHaveLength(0)
const baseSelector = compiled.code.match(/([^{}]+)\{\s*gap: 12px;/)![1]!.trim()
const specificity = (selector: string) => (selector.match(/\.[\w-]+|\[[^\]]+\]/g) ?? []).length
for (const page of ['receivable-statistics-page', 'finance-settlement-page']) {
const selector = `.admin-workbench.${page}.admin-page.is-spacing-compact`
expect(specificity(selector), 'must override scoped AdminPage even when it loads later').toBeGreaterThan(specificity(baseSelector))
}
for (const file of ['FinanceReceivableStatisticsView', 'FinanceFormalSettlementPage']) {
const content = read(`modules/finance/pages/${file}.vue`)
const summary = content.match(/<section class="finance-metric-grid"[^>]*v-loading="loading"[\s\S]*?<\/section>/)![0]
expect(content.indexOf(summary), file).toBeLessThan(content.indexOf('<template #filters>'))
const template = parse(content).descriptor.template!.ast!
function visit(node: { tag?: string; loc?: { source: string }; children?: unknown[] }, insideTable = false) {
if (node.tag === 'section' && node.loc?.source === summary) expect(insideTable, file).toBe(false)
for (const child of node.children ?? []) visit(child as typeof node, insideTable || node.tag === 'DataTable')
}
visit(template)
expect(summary.match(/workbench-metric workbench-tone is-compact/g), file).toHaveLength(3)
expect(summary).toContain('v-loading="loading" :aria-busy="loading"')
expect(summary).not.toContain('density-detail-overview')
for (const tone of ['blue', 'green', 'purple']) expect(summary).toContain(`data-tone="${tone}"`)
}
})
it('keeps finance detail tables on one continuous surface', () => {
const statements = read('modules/finance/pages/FinanceCustomerStatementsView.vue')
const arap = read('modules/finance/pages/FinanceCustomerArapView.vue')
expect(statements).not.toContain('<el-tabs')
expect(statements).toContain('title="账单明细表"')
expect(statements).toContain('title="账单支付历史表"')
expect(statements.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(2)
expect(statements).not.toContain('title="账单明细"')
expect(statements).not.toContain('title="支付历史"')
expect(arap).toContain('<div class="workbench-table is-page-surface"><DataTable :data="operationLogRows"')
const css = read('modules/finance/financeDensity.css')
expect(css).toContain('.finance-density-detail > .detail-page-layout__content .density-form-section')
expect(css).toContain('padding-block: 0;')
const accounts = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
expect(accounts).not.toContain(':deep(.detail-page-layout__content)')
})
it('keeps finance detail insets on the outer page without stripping card or cell padding', () => {
for (const [file, className] of [['FinanceCustomerAccountsView', 'finance-account-detail'], ['FinanceCustomerArapView', 'finance-arap-detail']]) {
expect(read(`modules/finance/pages/${file}.vue`)).toContain(`class="${className} admin-density admin-workbench is-embedded"`)
}
const css = read('modules/finance/financeDensity.css')
const rule = css.slice(css.indexOf('/* The outer AdminPage owns'))
expect(rule).toContain('@media screen')
expect(rule).toContain('padding-inline: 0;')
expect(rule).toContain('.detail-page-layout__content > .settlement-detail-content')
expect(rule).toContain(':is(.density-detail-overview, .statement-summary-section)')
for (const protectedClass of ['workbench-metric', 'el-descriptions__cell', 'query-toolbar', 'el-table']) expect(rule).not.toContain(protectedClass)
const accountStyles = parse(read('modules/finance/pages/FinanceCustomerAccountsView.vue')).descriptor.styles[0]!.content
expect(accountStyles.match(/\.account-metrics \{([^}]+)\}/)![1]).not.toMatch(/padding:|padding-inline:|padding-left:|padding-right:/)
})
it('keeps account detail filters in the shared table layout and long identifiers readable', () => {
const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
const header = content.split('<template #header>')[1]!.split('</template>')[0]!
expect(header).toContain('@click="exportLedger"')
expect(header).toContain('v-if="canExport"')
expect(header).toContain(':loading="ledgerExportBusy"')
const ledgerSection = content.split('class="ledger-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(ledgerSection).not.toContain('#toolbar-actions')
expect(ledgerSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(ledgerSection).toContain('class="ledger-table" appearance="integrated"')
const rechargeSection = content.split('class="recharge-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(rechargeSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(content.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
expect(content).toContain('creditExpiryLabel(selected)')
expect(content).toContain("'is-negative': Number(selected.actualBalance) < 0")
expect(content).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(content).not.toContain(':deep(.ledger-query.query-toolbar)')
expect(content).toContain('class="density-descriptions account-profile"')
const styles = parse(content).descriptor.styles.map(style => style.content).join('\n')
expect(styles).not.toContain('text-overflow: ellipsis')
expect(styles).toContain('overflow-wrap: anywhere')
})
it('keeps pallet overview spacing and metric appearance owned by shared primitives', () => {
it('keeps pallet and container overview metric appearance owned by shared primitives', () => {
for (const file of ['warehouse-resources/pallets/pages/PalletsView.vue', 'overseas-warehouse/pages/PalletsView.vue']) {
const page = parse(read(`modules/${file}`)).descriptor
const css = page.styles.map(style => style.content).join('\n')
expect(page.template!.content, file).toMatch(/<AdminPage\b[^>]*spacing="compact"/)
expect(css, file).toContain('.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }')
expect(css, file).toContain('.admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }')
expect(page.template!.content, file).toContain('workbench-metric workbench-tone is-compact')
expect(css, file).not.toMatch(/\.summary-grid\s+(?:article|span|strong|\.idle|\.occupied)\b/)
expect(css, file).not.toMatch(/\.workbench-metric[\s.{]/)
}
const containerPage = parse(read('modules/domestic-containers/pages/DomesticContainersView.vue')).descriptor
const containerStyles = containerPage.styles.map(style => style.content).join('\n')
+ read('modules/domestic-containers/pages/domesticContainersSummary.css')
expect(containerPage.template!.content).toMatch(/<AdminPage\b[^>]*spacing="compact"/)
expect(containerStyles).toContain('.admin-workbench.domestic-containers-page.admin-page.is-spacing-compact { gap: 0; }')
expect(containerPage.template!.content).toContain('workbench-metric workbench-tone is-compact')
expect(containerStyles).not.toMatch(/\.inventory-summary\s*(?:>|\s)\s*(?:article|div|span|strong)\b/)
expect(containerStyles).not.toMatch(/\.workbench-metric[\s.{]/)
})
it('lets local transport editors fill the workspace and keeps form density in shared primitives', () => {
@@ -691,6 +615,8 @@ describe('approved Admin density ownership', () => {
it('keeps dashboard visualizations outside detail-field table styling', () => {
const dashboard = read('modules/dashboard/pages/DashboardView.vue')
expect(dashboard).toContain('<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">')
expect(dashboard).toContain('.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }')
const metrics = elementsOf(dashboard, 'div').filter(node => classesOf(node).includes('dashboard-metrics'))
expect(metrics).toHaveLength(1)
const cards = elementsOf(dashboard, 'article').filter(node => classesOf(node).includes('dashboard-metric'))
@@ -780,10 +706,13 @@ describe('approved Admin density ownership', () => {
it('keeps warehouse editors and import results on shared sections and cell editors', () => {
const source = read('modules/warehouses/pages/WarehousesView.vue')
expect(source).toContain('class="density-form density-dialog-sections"')
expect(source).toContain('class="density-form density-form--outlined density-dialog-sections"')
expect(source).toContain('density-table density-table--editable warehouse-editor-table')
const importer = expectWorkbenchDialogs(source).find(node => classesOf(node).includes('warehouse-import-dialog'))
expect(importer).toBeDefined()
const editor = expectWorkbenchDialogs(source).find(node => classesOf(node).includes('warehouse-editor-dialog'))
expect(editor).toBeDefined()
expect(editor!.loc.source).not.toContain('data-tone=')
expect(source).toContain('class="density-table warehouse-import-summary"')
expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).not.toContain('<FormSection')
@@ -824,26 +753,30 @@ describe('approved Admin density ownership', () => {
expect(css).toContain(':not(.is-fullscreen)')
})
it('opts fee item editing into drawer sections and existing cell editors without changing other drawers', () => {
it('opts fee item editing into white dialog sections and existing cell editors', () => {
const source = read('modules/fee-items/pages/FeeItemsView.vue')
const ast = parse(source).descriptor.template!.ast!
let editorFound = false
const visit = (nodes: typeof ast.children, inEditor = false) => { for (const node of nodes) {
if (node.type !== 1) continue
const inside = inEditor || node.tag === 'AppDrawer' && classesOf(node).includes('admin-density-drawer--editor')
if (node.tag === 'AppDrawer') {
const inside = inEditor || node.tag === 'AppDialog' && classesOf(node).includes('fee-item-editor')
if (node.tag === 'AppDialog' && classesOf(node).includes('fee-item-editor')) {
editorFound = true
for (const name of ['admin-density', 'admin-density-drawer', 'admin-density-drawer--editor']) expect(classesOf(node)).toContain(name)
for (const name of ['admin-density-dialog', 'fee-item-editor']) expect(classesOf(node)).toContain(name)
}
if (classesOf(node).some(name => ['density-dialog-section', 'density-table--editable'].includes(name))) expect(inside).toBe(true)
visit(node.children, inside)
} }
visit(ast.children)
expect(editorFound).toBe(true)
expect(source).not.toMatch(/density-dialog-section[^>]*data-tone=/)
expect(source).not.toContain('AppDrawer')
expect(source).toContain('class="density-form density-form--outlined density-dialog-sections"')
expect(source).toContain('label="默认价格(CNY)" label-width="120px"')
expect(source).not.toContain('<FormSection')
expect(source).not.toContain('label-position="top"')
expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).toContain('size="min(620px, 94vw)"')
expect(source).toContain('width="min(760px, 94vw)" align-center')
expect(shared).toContain(':where(.admin-density-drawer--detail, .admin-density-drawer--editor)')
const css = parse(source).descriptor.styles.map(style => style.content).join('')
expect(css).not.toContain('direction-grid')
@@ -864,6 +797,14 @@ describe('approved Admin density ownership', () => {
expect(css).not.toMatch(/\.task-(?:detail-summary|failure)\b|\.detail-load-error\b/)
})
it('uses compact pallet-style cards above the customer visit list', () => {
const source = read('modules/overseas-warehouse/pages/CustomerVisitsView.vue')
expect(source.match(/class="workbench-metric workbench-tone is-compact"/g)).toHaveLength(3)
expect(source.indexOf('aria-label="回访统计"')).toBeLessThan(source.indexOf('<ListPageLayout'))
expect(source).not.toContain('visit-summary density-detail-overview')
expect(source).toContain('.admin-workbench.customer-visits-page.admin-page.is-spacing-compact { gap: 0; }')
for (const field of ['visitable', 'pending', 'completed']) expect(source).toContain('summary.' + field + ".toLocaleString('zh-CN')")
})
it('keeps ship price book upload compact with shared table and confirmation styles', () => {
const list = read('modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
const detail = read('modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue')
@@ -887,7 +828,7 @@ describe('approved Admin density ownership', () => {
expect(dialog!.props.some(prop => prop.type === 6 && prop.name === 'fullscreenable')).toBe(true)
expect(dialog!.props.some(prop => prop.type === 7 && prop.name === 'bind' && prop.arg?.type === 4 && prop.arg.content === 'confirm-loading' && prop.exp?.type === 4 && prop.exp.content === 'saving')).toBe(true)
expect(source).toContain('density-table--editable')
expect(source).toContain('data-tone="work"')
if (source === importer) expect(source).toContain('data-tone="work"')
expect(source).toContain('data-tone="source"')
expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).not.toContain('label-position="top"')
@@ -895,6 +836,9 @@ describe('approved Admin density ownership', () => {
expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/)
}
expect(editor).toContain('v-if="readonly"')
expect(editor).not.toContain('data-tone="work"')
expect(editor).toContain('class="density-form density-form--outlined density-dialog-sections"')
expect(read('modules/overseas-master-data/pages/FleetQuotesView.vue')).toContain('<RowActions text-actions')
expect(editor).toContain('class="density-fields fleet-quote-facts"')
const confirmations = confirmationClasses(read('modules/overseas-master-data/pages/FleetQuotesView.vue'))
expect(confirmations.length).toBeGreaterThan(0)
@@ -903,6 +847,7 @@ describe('approved Admin density ownership', () => {
it('keeps pricing dialogs on shared tables and sections without local form paint', () => {
const rate = read('modules/overseas-master-data/pages/ExchangeRatesView.vue')
expect(rate).toContain('class="density-form density-form--outlined"')
const quote = read('modules/overseas-master-data/pages/OverseasQuotesView.vue')
for (const source of [rate, quote]) {
const dialogs = expectWorkbenchDialogs(source)
@@ -918,7 +863,8 @@ describe('approved Admin density ownership', () => {
expect(quote.match(/fullscreenable/g)).toHaveLength(2)
expect(quote).toContain('data-tone="summary"')
expect(quote).toContain('data-tone="source"')
expect(quote).toContain('data-tone="work"')
expect(quote).not.toContain('data-tone="work"')
expect(quote).toContain('class="density-form density-form--outlined density-dialog-sections"')
const css = parse(quote).descriptor.styles.map(style => style.content).join('')
expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/)
})
@@ -1769,6 +1715,90 @@ describe('approved Admin density ownership', () => {
expect(popups).not.toContain('label-position="top"')
expect(statements).not.toContain('.create-period-row')
})
it('uses the shared compact metric cards for finance statistics and settlement totals', () => {
expect(read('modules/finance/pages/FinanceReceivableStatisticsView.vue')).toContain('spacing="compact"')
expect(read('modules/finance/pages/FinanceFormalSettlementPage.vue')).toContain(':spacing="detailMode ? \'normal\' : \'compact\'"')
const financeStyles = read('modules/finance/financeDensity.css')
expect(financeStyles).toContain('.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,')
expect(financeStyles).toContain('.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }')
expect(financeStyles).not.toContain('.data-table__toolbar')
const baseStyle = parse(read('shared/components/AdminPage.vue')).descriptor.styles[0]!
const compiled = compileStyle({ source: baseStyle.content, filename: 'AdminPage.vue', id: 'data-v-spacing-check', scoped: true })
expect(compiled.errors).toHaveLength(0)
const baseSelector = compiled.code.match(/([^{}]+)\{\s*gap: 12px;/)![1]!.trim()
const specificity = (selector: string) => (selector.match(/\.[\w-]+|\[[^\]]+\]/g) ?? []).length
for (const page of ['receivable-statistics-page', 'finance-settlement-page']) {
const selector = `.admin-workbench.${page}.admin-page.is-spacing-compact`
expect(specificity(selector), 'must override scoped AdminPage even when it loads later').toBeGreaterThan(specificity(baseSelector))
}
for (const file of ['FinanceReceivableStatisticsView', 'FinanceFormalSettlementPage']) {
const content = read(`modules/finance/pages/${file}.vue`)
const summary = content.match(/<section class="finance-metric-grid"[^>]*v-loading="loading"[\s\S]*?<\/section>/)![0]
expect(content.indexOf(summary), file).toBeLessThan(content.indexOf('<template #filters>'))
const template = parse(content).descriptor.template!.ast!
function visit(node: { tag?: string; loc?: { source: string }; children?: unknown[] }, insideTable = false) {
if (node.tag === 'section' && node.loc?.source === summary) expect(insideTable, file).toBe(false)
for (const child of node.children ?? []) visit(child as typeof node, insideTable || node.tag === 'DataTable')
}
visit(template)
expect(summary.match(/workbench-metric workbench-tone is-compact/g), file).toHaveLength(3)
expect(summary).toContain('v-loading="loading" :aria-busy="loading"')
expect(summary).not.toContain('density-detail-overview')
for (const tone of ['blue', 'green', 'purple']) expect(summary).toContain(`data-tone="${tone}"`)
}
})
it('keeps finance detail tables on one continuous surface', () => {
const statements = read('modules/finance/pages/FinanceCustomerStatementsView.vue')
const arap = read('modules/finance/pages/FinanceCustomerArapView.vue')
expect(statements).not.toContain('<el-tabs')
expect(statements).toContain('title="账单明细表"')
expect(statements).toContain('title="账单支付历史表"')
expect(statements.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(2)
expect(statements).not.toContain('title="账单明细"')
expect(statements).not.toContain('title="支付历史"')
expect(arap).toContain('<div class="workbench-table is-page-surface"><DataTable :data="operationLogRows"')
const css = read('modules/finance/financeDensity.css')
expect(css).toContain('.finance-density-detail > .detail-page-layout__content .density-form-section')
expect(css).toContain('padding-block: 0;')
const accounts = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
expect(accounts).not.toContain(':deep(.detail-page-layout__content)')
})
it('keeps finance detail insets on the outer page without stripping card or cell padding', () => {
for (const [file, className] of [['FinanceCustomerAccountsView', 'finance-account-detail'], ['FinanceCustomerArapView', 'finance-arap-detail']]) {
expect(read(`modules/finance/pages/${file}.vue`)).toContain(`class="${className} admin-density admin-workbench is-embedded"`)
}
const css = read('modules/finance/financeDensity.css')
const rule = css.slice(css.indexOf('/* The outer AdminPage owns'))
expect(rule).toContain('@media screen')
expect(rule).toContain('padding-inline: 0;')
expect(rule).toContain('.detail-page-layout__content > .settlement-detail-content')
expect(rule).toContain(':is(.density-detail-overview, .statement-summary-section)')
for (const protectedClass of ['workbench-metric', 'el-descriptions__cell', 'query-toolbar', 'el-table']) expect(rule).not.toContain(protectedClass)
const accountStyles = parse(read('modules/finance/pages/FinanceCustomerAccountsView.vue')).descriptor.styles[0]!.content
expect(accountStyles.match(/\.account-metrics \{([^}]+)\}/)![1]).not.toMatch(/padding:|padding-inline:|padding-left:|padding-right:/)
})
it('keeps account detail filters in the shared table layout and long identifiers readable', () => {
const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
const header = content.split('<template #header>')[1]!.split('</template>')[0]!
expect(header).toContain('@click="exportLedger"')
expect(header).toContain('v-if="canExport"')
expect(header).toContain(':loading="ledgerExportBusy"')
const ledgerSection = content.split('class="ledger-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(ledgerSection).not.toContain('#toolbar-actions')
expect(ledgerSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(ledgerSection).toContain('class="ledger-table" appearance="integrated"')
const rechargeSection = content.split('class="recharge-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(rechargeSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(content.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
expect(content).toContain('creditExpiryLabel(selected)')
expect(content).toContain("'is-negative': Number(selected.actualBalance) < 0")
expect(content).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(content).not.toContain(':deep(.ledger-query.query-toolbar)')
expect(content).toContain('class="density-descriptions account-profile"')
const styles = parse(content).descriptor.styles.map(style => style.content).join('\n')
expect(styles).not.toContain('text-overflow: ellipsis')
expect(styles).toContain('overflow-wrap: anywhere')
})
it('keeps wallet dialogs compact while preserving financial warnings and preview space', () => {
const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
const popups = content.slice(content.indexOf('<AppDialog'))