Compare commits

..
63 changed files with 1374 additions and 504 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeCustomerServiceIcon, icon: PrototypeCustomerServiceIcon,
children: [ children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' }, { 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> <template>
<section ref="dashboardRef" class="dashboard-view admin-workbench"> <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="数据看板操作"> <section class="dashboard-context" aria-label="数据看板操作">
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p> <p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
<div class="dashboard-head-actions"> <div class="dashboard-head-actions">
@@ -128,6 +128,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<style scoped> <style scoped>
.dashboard-view { display: block; min-width: 0; } .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-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-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; } .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 { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import DomesticContainersView from './DomesticContainersView.vue' 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 * as api from '../api/domesticContainers'
import type { InventoryRecord } from '../types' import type { InventoryPage, InventoryRecord } from '../types'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
// Use Node's supported require export so the validator retains its constructor interop. // 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('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('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', () => { 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) for (const fact of ['summary?.inboundBoxes', 'summary?.outboundBoxes', 'summary?.availableBoxes', 'summary?.inboundVolumeCbm']) expect(source).toContain(fact)
expect(source).toContain("import './domesticContainersSummary.css'") expect(source).toContain("import './domesticContainersSummary.css'")
expect(summaryStyles).toContain('display: flex; flex-wrap: wrap') const summary = source.match(/<section\b[^>]*class="inventory-summary"[\s\S]*?<\/section>/)?.[0] || ''
expect(summaryStyles).toContain('font-size: var(--admin-font-section, 14px)') 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') 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'") }) 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() })) 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' } 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 let mounted: VueWrapper | undefined
async function openCreate(label: string) { async function mountView() {
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
mounted = view mounted = view
await flushPromises() 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') if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
await flushPromises() await flushPromises()
await view.findAll('button').find(button => button.text() === label)!.trigger('click') 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() document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
await flushPromises() await flushPromises()
} }
describe('DomesticContainersView create workflows', () => { describe('DomesticContainersView workflows', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 } 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 = '' }) 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 () => { 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 }) vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
const { form } = await openCreate('新建入库单') const { form } = await openCreate('新建入库单')
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, 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 { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -28,7 +28,10 @@ const businessTabs: Array<{ value: TabName; label: string }> = [
{ value: 'inventory', label: '整柜库存' }, { 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 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 warehouses = ref<PlatformWarehouseSummaryResource[]>([]); const warehouseLoading = ref(false); const warehouseError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' }) const filters = ref<QueryModel>({ warehouseId: '', orderNo: '', containerNo: '', cargoName: '', status: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value }) 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 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 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 } } 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 } } 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 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() } 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()]) }) watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWarehouses(), load()]) })
</script> </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> <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' ? '暂无整柜出库单' : '暂无整柜库存'"> <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 #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 #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 #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> <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"> <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> <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> </el-form>
</AppDialog></AdminPage></template> </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 { .domestic-containers-page .inventory-summary {
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; margin: 0; display: grid;
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); 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/adminDensity.css'
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import { computed, onMounted, ref, watch } from 'vue' 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 { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -448,12 +448,6 @@ onMounted(() => {
</div> </div>
</div> </div>
</template> </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"> <LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
@@ -462,9 +456,11 @@ onMounted(() => {
<template v-else-if="detail"> <template v-else-if="detail">
<section class="version-panel"> <section class="version-panel">
<header class="section-heading"> <header class="section-heading">
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div> <div><h2>版本信息</h2></div>
</header> </header>
<dl class="detail-grid density-fields"> <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.channelCount }}</dd></div>
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</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> <div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
@@ -475,7 +471,7 @@ onMounted(() => {
</section> </section>
<section v-if="isPending && canEdit" class="mapping-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 <el-alert
v-if="nonMappingBlockingIssues.length" v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。" title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
@@ -514,37 +510,39 @@ onMounted(() => {
</section> </section>
<section class="detail-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-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 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 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="issueCode" label="编码" width="270" />
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip /> <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-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
</el-table> </el-table>
</section> </section>
<section class="detail-section channel-preview"> <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" /> <el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else> <template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs"> <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> </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 :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="180" />
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column> <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 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 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="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-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table> </el-table>
</template> </template>
@@ -555,23 +553,24 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; } .price-book-detail { min-width: 0; }
.version-panel, .mapping-section, .detail-section { padding: var(--admin-space-5) 0; border-bottom: 1px solid var(--workbench-border); } .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; } .detail-section:last-child { border-bottom: 0; }
.price-book-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); } .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; } .detail-grid .detail-grid__wide { grid-column: 1 / -1; }
.section-heading > div { min-width: 0; } .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 { 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; } .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-groups { display: grid; gap: var(--admin-space-5); }
.mapping-group { display: grid; gap: var(--admin-space-3); } .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-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 > 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 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); } .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 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); } .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 { 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-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) { @media (max-width: 720px) {
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; } .detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
.detail-toolbar { width: 100%; } .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', () => { it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) { for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注']) {
expect(detailSource).toContain(`label="${label}"`) expect(detailSource).toContain(`<dt>${label}</dt>`)
} }
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) { for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
expect(detailSource).toContain(`selectedChannel?.${field}`) expect(detailSource).toContain(`selectedChannel?.${field}`)
@@ -82,15 +82,15 @@ const queryFields = computed<QueryField[]>(() => [
]) ])
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' }, { key: 'scope', label: '报价范围', minWidth: 160, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' }, { key: 'version', label: '价格表版本', width: 110, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false }, { key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'contents', label: '解析结果', minWidth: 155, slotName: 'contents' }, { key: 'contents', label: '解析结果', minWidth: 170, slotName: 'contents' },
{ key: 'issues', label: '校验结果', minWidth: 150, slotName: 'issues' }, { key: 'issues', label: '校验结果', width: 140, slotName: 'issues' },
{ key: 'createdAt', label: '上传时间', minWidth: 165, formatter: (row: PlatformPriceBookVersion) => formatDateTime(row.createdAt) }, { 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')) 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 #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 #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 #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 #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 #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template> <template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
@@ -423,6 +423,7 @@ onMounted(() => {
.version-cell strong { color: var(--workbench-text); } .version-cell strong { color: var(--workbench-text); }
.version-cell span, .metric-cell span { color: var(--workbench-muted); font-size: var(--admin-font-caption); } .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, .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); } .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 { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; } .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 { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import FeeItemsView from './FeeItemsView.vue' import FeeItemsView from './FeeItemsView.vue'
import * as api from '../api/platformFeeItems' import * as api from '../api/platformFeeItems'
// Preserve the validator constructor interop through Element Plus's supported Node export. // 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() })) 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 let record: api.PlatformFeeItemResource
@@ -28,7 +27,7 @@ async function openEditor(create = false) {
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
} }
async function setText(placeholder: string, value: string) { 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() 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) { async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
@@ -51,12 +50,14 @@ beforeEach(() => {
}) })
describe('fee item editing and confirmations', () => { 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) const wrapper = await openEditor(true)
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' }) expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '新增费用项目', width: 'min(760px, 94vw)' })
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true) expect(document.querySelector('.el-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3) expect(document.querySelectorAll('.el-dialog .density-dialog-section')).toHaveLength(3)
expect(document.querySelector('.el-dialog')).toBeNull() 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('例如 仓储成本', ' 操作费 ') await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0) wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true) 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') wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true) await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
await setText('请输入适用场景或核算说明', ' 新说明 ') 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(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) expect(useUnsavedWorkStore().entries).toHaveLength(0)
}) })
it('validates required fields and payable supplier category without losing entered values', async () => { it('validates required fields and payable supplier category without losing entered values', async () => {
const wrapper = await openEditor(true) 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() 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 setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
await check(wrapper, '提货环节应付', true) 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(api.createPlatformFeeItem).not.toHaveBeenCalled()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用') 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 () => { 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 } record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
const wrapper = await openEditor() const wrapper = await openEditor()
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费') expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑费用项目 · 测试操作费')
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId) expect(document.querySelector('.el-dialog')?.textContent).not.toContain(privateId)
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null) wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
await check(wrapper, '提货环节应付', false) await check(wrapper, '提货环节应付', false)
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull() expect(document.querySelector('.el-dialog [placeholder="请选择供应商类别"]')).toBeNull()
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false })) 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 })) vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用') const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' }) expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises() button('保存费用项目', '.el-dialog__footer button').click(); await flushPromises()
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false }) expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true) expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
rejectSave(new Error('保存失败')); await flushPromises() rejectSave(new Error('保存失败')); await flushPromises()
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用') expect(document.querySelector<HTMLInputElement>('.el-dialog [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true) expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record) 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(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 () => { it('keeps unsaved registration and the existing permission-change closure', async () => {
@@ -118,7 +119,7 @@ describe('fee item editing and confirmations', () => {
await setText('请输入费用名称', '权限变化前的修改') await setText('请输入费用名称', '权限变化前的修改')
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true) expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises() 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(useUnsavedWorkStore().entries).toHaveLength(0)
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled() 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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.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 DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue' import InlineHelp from '@/shared/components/InlineHelp.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -430,22 +429,22 @@ onMounted(() => {
</DataTable></section> </DataTable></section>
</ListPageLayout> </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" v-model="editorOpen"
:title="editorTitle" :title="editorTitle"
size="min(620px, 94vw)" width="min(760px, 94vw)" align-center
:loading="editorLoading" :loading="editorLoading"
:confirm-loading="saving" :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"> <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" data-tone="work" aria-label="费用基本信息"> <section class="density-dialog-section workbench-evidence" aria-label="费用基本信息">
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3> <h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
<table class="density-table density-table--editable fee-item-editor-table"><tbody> <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="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> </tbody></table>
</section> </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> <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> <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> <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> <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> </tbody></table>
</section> </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> <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> <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> </section>
@@ -465,7 +464,7 @@ onMounted(() => {
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button> <el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
</div> </div>
</template> </template>
</AppDrawer> </AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" <AppDialog class="admin-density-dialog admin-workbench is-dialog"
v-model="statusOpen" v-model="statusOpen"
+29 -1
View File
@@ -7,7 +7,6 @@
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); } .admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
.admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; } .admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; } .admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
.admin-density .statement-summary-section { padding: var(--admin-space-5); } .admin-density .statement-summary-section { padding: var(--admin-space-5); }
.admin-density .account-metrics .is-negative, .admin-density .account-metrics .is-negative,
.admin-density .density-detail-overview .is-danger, .admin-density .density-detail-overview .is-danger,
@@ -15,3 +14,32 @@
.admin-density .density-detail-overview .is-success, .admin-density .density-detail-overview .is-success,
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); } .admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); } .admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
/* Outrank AdminPage scoped spacing regardless of stylesheet load order; the toolbar supplies the gap. */
.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,
.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }
.admin-workbench .finance-metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
@media (max-width: 720px) {
.admin-workbench .finance-metric-grid { grid-template-columns: 1fr; }
}
/* The outer AdminPage owns the screen inset for every finance detail section. */
@media screen {
.admin-density .detail-page-layout.finance-density-detail > :is(.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer),
.admin-density .finance-density-detail > .detail-page-layout__header > .density-detail-header,
.admin-density .finance-density-detail > .detail-page-layout__summary > :is(.density-detail-overview, .statement-summary-section),
.admin-density .finance-density-detail > .detail-page-layout__content > .settlement-detail-content {
padding-inline: 0;
}
}
/* Detail sections share one vertical rhythm instead of stacking section padding. */
.admin-density .finance-density-detail > .detail-page-layout__content,
.admin-density .finance-density-detail .settlement-detail-content {
display: flex;
flex-direction: column;
gap: var(--admin-space-5);
}
.admin-density .finance-density-detail > .detail-page-layout__content .density-form-section {
padding-block: 0;
}
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('v-else-if="!detailOpen"') expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户') expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout') expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="density-detail-overview account-metrics"') expect(page).toContain('class="account-metrics"')
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric) for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
expect(page).toContain('class="ledger-query workbench-query"') expect(page).toContain('class="ledger-query workbench-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"') expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -83,18 +83,18 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => { it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker) for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('density-detail-overview account-metrics') expect(page).toContain('account-metrics')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column) for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)') expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start') expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"') expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"')
expect(page).toContain('class="density-detail-header"') expect(page).toContain('class="density-detail-header"')
expect(page).toContain('padding: var(--admin-space-4) 0') expect(page).toContain('padding: var(--admin-space-4) 0')
expect(page).toContain('class="detail-table-scroll workbench-table"') expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(page).toContain('class="ledger-identifier"') expect(page).toContain('class="ledger-identifier"')
expect(page).toContain('class="ledger-document"') expect(page).toContain('class="ledger-document"')
expect(page).toContain('text-overflow: ellipsis') expect(page).toContain('overflow-wrap: anywhere')
expect(page).toContain('white-space: nowrap') expect(page).not.toContain('text-overflow: ellipsis')
}) })
it('keeps account detail and term editing readable across the 1920 workspace', () => { it('keeps account detail and term editing readable across the 1920 workspace', () => {
@@ -186,7 +186,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('客户A') expect(wrapper.text()).toContain('客户A')
expect(wrapper.text()).toContain('C-001') const profile = wrapper.find('.customer-profile-section')
expect(profile.text()).toContain('客户名称客户A')
expect(profile.text()).not.toContain('客户编号')
expect(profile.text()).not.toContain('C-001')
expect(wrapper.text()).toContain('张三') expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000') expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics') const metrics = wrapper.find('.account-metrics')
@@ -194,7 +197,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(metrics.text()).toContain('实际余额¥126580.50 CNY') expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY') expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY') expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期') expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00 CNY') expect(metrics.text()).toContain('已用授信¥0.00 CNY')
expect(metrics.text()).toContain('可用授信¥20000.00 CNY') expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary) for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue' import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
@@ -100,13 +100,13 @@ const columns: DataTableColumn[] = [
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' }, { key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' }, { key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' }, { key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' }, { key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' }, { key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' }, { key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' }, { key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' }, { key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' }, { key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' }, { key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' }, { key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' }, { key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
] ]
@@ -123,18 +123,18 @@ const ledgerQueryFields: QueryField[] = [
const ledgerColumns: DataTableColumn[] = [ const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' }, { key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' }, { key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' }, { key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' }, { key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' }, { key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' }, { key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' }, { key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
] ]
const rechargeColumns: DataTableColumn[] = [ const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' }, { key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 }, { key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' }, { key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
@@ -516,18 +516,43 @@ onBeforeUnmount(clearVoucherPreview)
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template> <template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal"> <AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"> <section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'"> <DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template> <template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额">
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">实际余额</h2></div>
<div class="workbench-metric-value"><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><LockKeyhole aria-hidden="true" /></span><h2 class="workbench-metric-label">冻结金额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.frozenBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric 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>{{ money(selected.availableBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">授信额度</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditLimit) }}</strong> <span>CNY</span></div><div class="workbench-note" :class="{ 'credit-expired': selected.creditExpiresOn && !selected.creditActive }">{{ creditExpiryLabel(selected) }}</div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">已用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditUsed) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">可用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableCredit) }}</strong> <span>CNY</span></div>
</article>
</section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState> <LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected"> <template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection> <FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions account-profile" :column="3"><el-descriptions-item label="客户名称">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false"> <FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" /> <LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll workbench-table"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide"> <div v-else class="detail-table-scroll workbench-table is-page-surface"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template> <template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template> <template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template> <template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
@@ -535,8 +560,7 @@ onBeforeUnmount(clearVoucherPreview)
</DataTable></div> </DataTable></div>
</FormSection> </FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false"> <FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /> <div class="detail-table-scroll workbench-table is-page-surface"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" appearance="integrated" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #filters><QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /></template><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection> </FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection> <FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template> </template>
@@ -670,27 +694,26 @@ onBeforeUnmount(clearVoucherPreview)
object-fit: contain; object-fit: contain;
} }
.ledger-section { .account-metrics {
padding: var(--admin-space-4) 0 0; display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--admin-space-5);
padding-block: var(--admin-space-3) 0;
} }
.recharge-section { .account-metrics .workbench-metric-value > .is-negative,
padding: var(--admin-space-4) 0 0; .account-metrics .credit-expired {
border-bottom: 1px solid var(--workbench-border); color: var(--el-color-danger);
}
.account-profile :deep(.el-descriptions__table) {
table-layout: fixed;
} }
.recharge-table { .recharge-table {
min-width: 0; min-width: 0;
} }
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
}
.detail-table-scroll { .detail-table-scroll {
overflow-x: auto; overflow-x: auto;
} }
@@ -702,9 +725,7 @@ onBeforeUnmount(clearVoucherPreview)
.ledger-identifier, .ledger-identifier,
.ledger-document { .ledger-document {
display: block; display: block;
overflow: hidden; overflow-wrap: anywhere;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -761,12 +782,6 @@ onBeforeUnmount(clearVoucherPreview)
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
border-left: 0;
}
.finance-account-detail__surface { .finance-account-detail__surface {
width: 100%; width: 100%;
} }
@@ -795,16 +810,7 @@ onBeforeUnmount(clearVoucherPreview)
gap: 12px; gap: 12px;
} }
.account-metric,
.account-metric:first-child,
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
}
.account-metric + .account-metric {
border-left: 0;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -127,6 +127,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
} }
}) })
it.each([['PAYABLE_BILLED', '已生成应付单'], ['PARTIALLY_PAID', '部分付款'], ['PAID', '已付款']])(
'shows payable progress and prevents editing occupied or paid sources: %s', async (status, label) => {
const row = { ...feeSummary, direction: 'PAYABLE', sourceSystem: 'FINANCE', sourceType: 'MANUAL',
reviewStatus: 'APPROVED', sourceStatus: status, status, statementGenerated: false } as FinanceFeeSourceSummary
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain(label)
expect(body.text()).not.toContain('可结算')
expect(body.text()).not.toContain('未生成')
expect(body.findAll('button').map(button => button.text().trim())).toEqual(['详情'])
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
await body.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('.arap-metrics').text()).toContain(label)
expect(wrapper.get('.arap-information').text()).toContain('不适用')
},
)
it('shows detail from the list permission but keeps G2 operations independent', async () => { it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list']) const wrapper = await mountView(['platform:finance:customer-arap:list'])
await loadRows(wrapper) await loadRows(wrapper)
@@ -253,7 +273,9 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.find('.arap-metrics').exists()).toBe(true) expect(wrapper.find('.arap-metrics').exists()).toBe(true)
expect(wrapper.findAll('.arap-metric')).toHaveLength(4) expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
expect(wrapper.find('.arap-information').text()).toContain('单据信息') expect(wrapper.find('.arap-information').text()).toContain('单据信息')
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算') expect(wrapper.find('.arap-settlement').exists()).toBe(false)
expect(wrapper.find('.arap-information').text()).toContain('已生成对账单')
expect(wrapper.find('.arap-information').text()).toContain('审核原因')
expect(wrapper.find('.operation-log').text()).toContain('操作记录') expect(wrapper.find('.operation-log').text()).toContain('操作记录')
expect(wrapper.find('.arap-information').text()).toContain('业务日期') expect(wrapper.find('.arap-information').text()).toContain('业务日期')
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00') expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
@@ -262,8 +284,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('keeps the fee-source detail readable across the 1920 workspace', () => { it('keeps the fee-source detail readable across the 1920 workspace', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('width: min(100%, 1920px)') expect(page).toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)') expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)') expect(page).not.toContain('arap-detail-grid')
expect(page).toContain('finance-density-detail') expect(page).toContain('finance-density-detail')
expect(page).not.toContain('max-width: 520px') expect(page).not.toContain('max-width: 520px')
}) })
@@ -126,7 +126,7 @@ const queryFields = computed<QueryField[]>(() => [
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
}, },
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] }, { key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] }, { key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已生成应付单', value: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions }, { key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] }, { key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 }, { key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
@@ -141,10 +141,17 @@ function closeCustomerSelectOnScroll() {
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) { function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG' return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
} }
// Eight fixed fields precede settlement status; optional source fields also occupy a cell.
const settlementStatusSpan = computed(() => {
const source = detail.value
const precedingFields = 8 + Number(source?.direction === 'PAYABLE') + Number(source?.quantity != null) + Number(Boolean(source?.periodStart))
return 3 - precedingFields % 3
})
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', align: 'right', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' }, { key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' }, { key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
] ]
@@ -191,7 +198,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
} }
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' } function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
function sourceStatusDisplay(value: unknown) { function sourceStatusDisplay(value: unknown) {
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' } const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
return labels[String(value || '').toUpperCase()] || '状态待确认' return labels[String(value || '').toUpperCase()] || '状态待确认'
} }
function formatDateTime(value: unknown) { function formatDateTime(value: unknown) {
@@ -414,17 +421,17 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template> <template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template> <template #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template> <template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal"> <AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface"> <section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情"> <DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template> <template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template> <template v-else-if="detail"><FormSection class="arap-information density-form-section" title="单据信息与审核结算" :bordered="false"><el-descriptions border :column="3" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单" :span="settlementStatusSpan">{{ detail.direction === 'PAYABLE' ? '不适用' : detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="3">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table is-page-surface"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
</DetailPageLayout> </DetailPageLayout>
</section> </section>
</AdminPage> </AdminPage>
@@ -522,23 +529,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted); color: var(--workbench-muted);
} }
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: var(--admin-space-6);
border-bottom: 1px solid var(--workbench-border);
}
.arap-information, .arap-information,
.arap-settlement,
.operation-log { .operation-log {
padding: var(--admin-space-4) 0; padding: var(--admin-space-4) 0;
} }
.arap-settlement {
padding-left: var(--admin-space-6);
border-left: 1px solid var(--workbench-border);
}
@@ -566,16 +562,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted); color: var(--workbench-muted);
} }
@media (min-width: 1920px) {
.arap-detail-grid {
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: var(--admin-space-4);
}
}
@media (max-width: 1080px) { @media (max-width: 1080px) {
.finance-arap-detail__surface { .finance-arap-detail__surface {
@@ -591,16 +577,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
border-left: 0; border-left: 0;
} }
.arap-detail-grid {
grid-template-columns: 1fr;
gap: 0;
}
.arap-settlement {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--workbench-border);
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -108,13 +108,37 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull() expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull() expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20) expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00') expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501') expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy() expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull() expect(document.querySelector('.el-drawer')).toBeNull()
expect(document.querySelector('.statement-detail-page .el-tabs')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户A')
expect(wrapper.find('[aria-label="调整记录"]').isVisible()).toBe(true)
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
const toolbar = () => wrapper.find('#statement-query-filters')
const toggle = () => toolbar().find('button[aria-controls="statement-query-filters"]')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(3)
await toggle().trigger('click')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(9)
vm.filters.containerNo = 'CONT-31'
vm.filters.createdRange = ['2026-08-01', '2026-08-31']
await toggle().trigger('click')
expect(vm.filters.containerNo).toBe('CONT-31')
const reset = toolbar().findAll('button').find((button) => button.text() === '重置')!
await reset.trigger('click')
await flushPromises()
expect(vm.filters.containerNo).toBe('')
expect(vm.filters.createdRange).toEqual([])
expect(vm.appliedFilters.containerNo).toBe('')
}) })
it('uses the shared four-group summary grid with long remarks spanning two groups', () => { it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
@@ -163,9 +187,8 @@ describe('FinanceCustomerStatementsView', () => {
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' } const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement) await vm.openDetail(statement)
vm.activeDetailTab = 'payments'
await flushPromises() await flushPromises()
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701') expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus } from '@lucide/vue' import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -15,6 +15,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue' import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -65,7 +66,7 @@ const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null) const detail = ref<FinanceReconciliationDetail | null>(null)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null) const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines') const showAdvancedFilters = ref(false)
const detailCache = new Map<string, FinanceReconciliationDetail>() const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false) const createOpen = ref(false)
const createBusy = ref(false) const createBusy = ref(false)
@@ -118,6 +119,13 @@ const fields = computed<QueryField[]>(() => [
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, { key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } }, { key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
]) ])
const visibleFields = computed(() => showAdvancedFilters.value ? fields.value : fields.value.slice(0, 3))
function resetFilters() {
filters.value = { status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [] }
void load()
}
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' }, { key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
@@ -126,15 +134,15 @@ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 }, { key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 }, { key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 }, { key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 }, { key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 }, { key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 }, { key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 }, { key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 }, { key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 }, { key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 }, { key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 }, { key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' }, { key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' }, { key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 180 },
@@ -157,10 +165,10 @@ const detailLineColumns: DataTableColumn[] = [
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' }, { key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' }, { key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' }, { key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' }, { key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 }, { key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 }, { key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 }, { key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' }, { key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' }, { key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
@@ -169,7 +177,7 @@ const detailLineColumns: DataTableColumn[] = [
const paymentColumns: DataTableColumn[] = [ const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' }, { key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' }, { key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 }, { key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' }, { key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 }, { key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' }, { key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
@@ -612,9 +620,10 @@ watch(detailRouteId, (statementId) => {
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情"> <DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template> <template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary> <template v-if="detail" #summary>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要"> <FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid"> <dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div>
<div class="statement-summary-item"><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div>
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div> <div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
@@ -638,12 +647,11 @@ watch(detailRouteId, (statementId) => {
</dl> </dl>
</FormSection> </FormSection>
</template> </template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated /> <el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false"> <template v-else-if="detail">
<div class="detail-table-scroll workbench-table"> <FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细"> <DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template> <template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template> <template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
@@ -658,12 +666,9 @@ watch(detailRouteId, (statementId) => {
<template #periodEnd>{{ detail.periodEnd || '—' }}</template> <template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable> </DataTable>
</div> </div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
</FormSection> </FormSection>
</el-tab-pane> <FormSection class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template> <div class="detail-table-scroll workbench-table is-page-surface">
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll workbench-table">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史"> <DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template> <template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template> <template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
@@ -674,15 +679,27 @@ watch(detailRouteId, (statementId) => {
</DataTable> </DataTable>
</div> </div>
</FormSection> </FormSection>
</el-tab-pane> <FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
</el-tabs> <div class="workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe>
<el-table-column prop="revisionNo" label="修订" width="72" />
<el-table-column label="动作" width="120"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前" align="right" width="130" />
<el-table-column prop="afterAmount" label="调整后" align="right" width="130" />
<el-table-column prop="reason" label="原因" min-width="280"><template #default="{ row }"><LineClampText :content="row.reason" :lines="1" /></template></el-table-column>
<el-table-column label="操作人" width="170"><template #default="{ row }"><LineClampText :content="operatorDisplayName(row.operatorName)" /></template></el-table-column>
<el-table-column label="发生时间" width="180"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
</el-table>
</div>
</FormSection>
</template>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单"> <ListPageLayout v-else aria-label="客户对账单">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail"> <section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template> <template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template> <template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template> <template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
@@ -751,12 +768,8 @@ watch(detailRouteId, (statementId) => {
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; } .statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); } .statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); } .statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; } .statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; } .detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); } :deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) { @media (min-width: 1920px) {
@@ -765,13 +778,6 @@ watch(detailRouteId, (statementId) => {
padding: var(--admin-space-3) var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-5);
} }
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: var(--admin-space-3);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -6,11 +6,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8') const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => { describe('Finance formal settlement desktop presentation', () => {
it('keeps the title and actions in the toolbar and settlement totals before results', () => { it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
expect(source).toContain('<template #toolbar-start>') expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<h1>{{ title }}</h1>') expect(source).toContain('<h1>{{ title }}</h1>')
expect(source).toContain('<template #before-results>') expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">') expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
expect(source).toContain('<template #toolbar-actions>') expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">') expect(source).toContain('<div class="settlement-list-actions">')
expect(source).not.toContain('class="settlement-list-toolbar"') expect(source).not.toContain('class="settlement-list-toolbar"')
@@ -44,7 +44,7 @@ describe('Finance formal settlement desktop presentation', () => {
it('uses a continuous detail layout with locally scrollable wide tables', () => { it('uses a continuous detail layout with locally scrollable wide tables', () => {
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"') expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"')
expect(source).toContain('class="settlement-detail-content"') expect(source).toContain('class="settlement-detail-content"')
expect(source).toContain('class="detail-table-scroll workbench-table"') expect(source.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }') expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(source).not.toContain('class="settlement-detail-card"') expect(source).not.toContain('class="settlement-detail-card"')
}) })
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue' import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads' import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -142,9 +142,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' }, { key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' }, { key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 }, { key: 'currency', label: '币种', width: 80 },
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' }, { key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' }, { key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' }, { key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' }, { key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' }, { key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' }, { key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
@@ -162,9 +162,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' }, { key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' }, { key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 }, { key: 'currency', label: '币种', width: 80 },
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' }, { key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' }, { key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' }, { key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' }, { key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' }, { key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' }, { key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
@@ -520,7 +520,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal"> <AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" /> <OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" /> <OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" /> <OperationErrorToast :details="receiptError" />
@@ -534,17 +534,25 @@ onMounted(() => {
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button> <el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div></div></template> </div></div></template>
<template v-if="detail" #summary> <template v-if="detail" #summary>
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总"> <section class="finance-metric-grid" aria-label="结算金额汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div> <article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div> <div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</h2></div>
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div> <div class="workbench-metric-value"><strong>{{ money(detail.amount) }}</strong><span>CNY</span></div>
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div> </article>
</dl> <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">{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.paidAmount) }}</strong><span>CNY</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"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">未结余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.outstandingAmount) }}</strong><span>CNY</span></div>
</article>
</section>
</template> </template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState> <LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated /> <el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content"> <section v-else-if="detail" class="settlement-detail-content">
<FormSection class="density-form-section" title="基本信息"> <FormSection class="density-form-section" title="基本信息" :bordered="false">
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions"> <el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item> <el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item> <el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
@@ -562,8 +570,8 @@ onMounted(() => {
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item> <el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="费用明细"> <FormSection class="density-form-section" title="费用明细" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细"> <el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" /> <el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column> <el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
@@ -575,8 +583,8 @@ onMounted(() => {
</el-table> </el-table>
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'"> <FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'"> <el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" /> <el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column> <el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
@@ -590,7 +598,7 @@ onMounted(() => {
</div> </div>
</FormSection> </FormSection>
<FormSection class="density-form-section" title="修订记录" :bordered="false"> <FormSection class="density-form-section" title="修订记录" :bordered="false">
<div class="detail-table-scroll workbench-table"> <div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录"> <el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column> <el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column> <el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
@@ -603,7 +611,22 @@ onMounted(() => {
</FormSection> </FormSection>
</section> </section>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else :aria-label="title"> <template v-else>
<section class="finance-metric-grid" aria-label="结算汇总" v-if="!customerError && !error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.amount) }}</strong><span>CNY</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">{{ kind === 'receivable' ? '已收' : '已付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.paid) }}</strong><span>CNY</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"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '待收' : '待付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.outstanding) }}</strong><span>CNY</span></div>
</article>
</section>
<ListPageLayout :aria-label="title">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单"> <section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
@@ -614,13 +637,7 @@ onMounted(() => {
<template #notices><template v-if="customerError || error"> <template #notices><template v-if="customerError || error">
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState> <LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template></template> </template></template>
<template #before-results>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl>
</template>
<template #toolbar-actions> <template #toolbar-actions>
@@ -654,6 +671,7 @@ onMounted(() => {
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template> <template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate"> <AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3> <section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
@@ -700,8 +718,8 @@ onMounted(() => {
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr> <tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
</tbody></table> </tbody></table>
</section> </section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3> <section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody> <el-form class="settlement-operation-form density-form density-form--outlined" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr> <tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr> <tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
@@ -717,8 +735,7 @@ onMounted(() => {
<style scoped> <style scoped>
.finance-settlement-page { min-width: 0; } .finance-settlement-page { min-width: 0; }
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; } .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-summary { gap: var(--admin-space-2) 0; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); } .settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; } .settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; } .settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
@@ -754,21 +771,11 @@ onMounted(() => {
padding: 0 var(--admin-space-5) var(--admin-space-4); padding: 0 var(--admin-space-5) var(--admin-space-4);
} }
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.settlement-detail-summary { grid-template-columns: 1fr; }
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
.settlement-detail-content { padding: 0 16px 14px; } .settlement-detail-content { padding: 0 16px 14px; }
.settlement-detail-state { margin: 14px; } .settlement-detail-state { margin: 14px; }
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
.settlement-list-actions { width: 100%; justify-content: flex-start; } .settlement-list-actions { width: 100%; justify-content: flex-start; }
.summary-item strong { font-size: 21px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; } .settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.settlement-detail-actions { width: 100%; } .settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; } .settlement-detail-actions :deep(.el-button) { min-height: 40px; }
@@ -39,21 +39,21 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('class="settlement-detail-info density-descriptions"') expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"') for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"') expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3) expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }') expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"') expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2) expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2) expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
expect(settlement).toContain('density-table density-table--editable settlement-operation-table') expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }') expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
}) })
it('keeps settlement totals and list actions in the table toolbar', () => { it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>') expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"') expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>') expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"') expect(settlement).toContain('class="settlement-list-actions"')
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }') expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
expect(settlement).not.toContain('class="settlement-list-toolbar"') expect(settlement).not.toContain('class="settlement-list-toolbar"')
}) })
@@ -148,15 +148,13 @@ describe('finance presentation governance', () => {
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。') expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
}) })
it('uses a shared compact financial summary row for customer accounts', () => { it('uses shared compact financial cards for customer accounts', () => {
expect(accounts).toContain('class="density-detail-overview account-metrics"') expect(accounts).toContain('class="account-metrics"')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }') expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(density).toContain('.density-detail-overview') expect(density).toContain('.density-detail-overview')
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)') expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(accounts).toContain('padding: 0;') expect(financeDensity).toContain('padding-block: 0;')
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1] expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
expect(summaryStyles).toContain('display: flex;')
expect(summaryStyles).toContain('flex-wrap: wrap;')
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1] const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
expect(accounts).toContain('class="ledger-query workbench-query"') expect(accounts).toContain('class="ledger-query workbench-query"')
expect(queryStyles).toContain('border-color: var(--workbench-border);') expect(queryStyles).toContain('border-color: var(--workbench-border);')
@@ -168,9 +166,11 @@ describe('finance presentation governance', () => {
it('keeps fee-source details continuous without nested metric and section cards', () => { it('keeps fee-source details continuous without nested metric and section cards', () => {
expect(arap).toContain('class="density-detail-overview arap-metrics"') expect(arap).toContain('class="density-detail-overview arap-metrics"')
expect(arap).toContain('class="arap-information density-form-section"') expect(arap).toContain('class="arap-information density-form-section"')
expect(arap).toContain('class="arap-settlement density-form-section"') expect(arap).toContain('title="单据信息与审核结算"')
expect(arap).toContain('padding-left: var(--admin-space-6);') expect(arap).toContain('label="审核原因" :span="3"')
expect(arap).toContain('border-left: 1px solid') expect(arap).not.toContain('arap-detail-grid')
expect(arap).not.toContain('arap-settlement')
expect(arap).not.toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:') expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"') for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
}) })
@@ -9,6 +9,9 @@ describe('receivable statistics page', () => {
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label) for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'") expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
expect(page).toContain('window.print()') expect(page).toContain('window.print()')
expect(page).toContain('aria-label="统计月份"')
expect(page).toContain('aria-label="应收单位"')
for (const removed of ['<span>统计月份</span>', '<span>应收单位</span>', '#before-results', '应收款明细', '仅统计CNY正式应收结算单']) expect(page).not.toContain(removed)
expect(page).toContain('displayCustomerName(row)') expect(page).toContain('displayCustomerName(row)')
expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)') expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)')
expect(page).toContain("return masterName || '—'") expect(page).toContain("return masterName || '—'")
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import '../financeDensity.css' import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue' import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' }, { key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
{ key: 'currency', label: '币种', width: 100 }, { key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' }, { key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' }, { key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' }, { key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
] ]
async function load() { async function load() {
@@ -90,22 +90,31 @@ onMounted(() => { void loadCustomers(); void load() })
</script> </script>
<template> <template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal"> <AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" /> <el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else> <template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</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>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</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"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" 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="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()"> <div class="workbench-table is-page-surface"><DataTable class="statistics-table" 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="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template> <template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选"> <template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div> <div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div> <div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div> <div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section></template> </section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template> <template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template> <template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
@@ -117,20 +126,17 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped> <style scoped>
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); } .statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); } .query-field { min-width: 220px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; } .query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); } .query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; } .report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; } .query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; } .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; } .statistics-table { overflow: hidden; }
.statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-report-heading span { color: var(--workbench-muted); font-size: var(--admin-font-body); }
@media print { @media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; } .statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; } .receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; } .workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; } .finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
} }
</style> </style>
+4 -4
View File
@@ -434,8 +434,8 @@ onMounted(loadInitialData)
</ListPageLayout> </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"> <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"> <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" data-tone="work" aria-label="用户基本资料"> <section class="density-dialog-section workbench-evidence" aria-label="用户基本资料">
<h3 class="density-dialog-section__title">用户资料</h3> <h3 class="density-dialog-section__title">用户资料</h3>
<table class="density-table density-table--editable user-editor-table"><tbody> <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> <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> <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> </tbody></table>
</section> </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> <h3 class="density-dialog-section__title">作业仓库</h3>
<table class="density-table density-table--editable"><tbody> <table class="density-table density-table--editable"><tbody>
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required> <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> </el-form-item></td></tr>
</tbody></table> </tbody></table>
</section> </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> <h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
<table class="density-table density-table--editable user-editor-table"><tbody> <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> <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', () => { it('keeps the shared status badge prop defined during table slot initialization', () => {
expect(permissionsSource).toContain("(row as Permission).status || '—'") 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("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400") expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300") expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'") expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true") 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('<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('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link') expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
expect(permissionsSource).not.toContain('canChangeStatusRow(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', () => { 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: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }") expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
expect(permissionsSource).toContain('aria-label="平台权限资源列表"') expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
+13 -28
View File
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../iamDensity.css' import '../iamDensity.css'
import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' 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 StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.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) ['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
))) )))
const permissionColumns: DataTableColumn[] = [ 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: '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: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }, { key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }, { key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
@@ -128,25 +129,16 @@ function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
return ids return ids
} }
function permissionSummary(row: PermissionTreeNode) { function permissionCounts(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? []) const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id)) .map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item)) .filter((item): item is Permission => Boolean(item))
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限' return { menuCount, actionCount }
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
} }
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) { function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId }) Object.assign(form, emptyForm(), { parentId })
@@ -235,7 +227,6 @@ async function removePermission(row: Permission) {
rowActionId.value = '' rowActionId.value = ''
} }
} }
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
function canAddChild(row: Permission) { function canAddChild(row: Permission) {
return canCreate.value && ['菜单', '页面'].includes(row.type) return canCreate.value && ['菜单', '页面'].includes(row.type)
@@ -287,13 +278,11 @@ onMounted(loadPermissions)
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template></template> </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 #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 #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 #name="{ row }"><strong>{{ (row as Permission).name }}</strong></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template> <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 #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
@@ -317,23 +306,19 @@ onMounted(loadPermissions)
</template> </template>
<style scoped> <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 { table-layout: fixed; }
.permission-editor-table td:not([colspan="2"]) { width: 50%; } .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); } :deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) { @media (max-width: 720px) {
.permission-editor-table tr { display: grid; } .permission-editor-table tr { display: grid; }
.permission-editor-table td:not([colspan="2"]) { width: auto; } .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> </style>
@@ -68,7 +68,7 @@ beforeEach(() => {
describe('roles and permission dialogs', () => { describe('roles and permission dialogs', () => {
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => { 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) 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(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
expect(tree.vm.getCheckedKeys()).toContain(childId) expect(tree.vm.getCheckedKeys()).toContain(childId)
expect(tree.vm.getNode(hiddenId)).toBeTruthy() expect(tree.vm.getNode(hiddenId)).toBeTruthy()
+2 -2
View File
@@ -117,11 +117,11 @@ describe('RolesView', () => {
expect(rolesSource).toContain('aria-label="平台角色列表"') 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('<AppDialog v-model="editorOpen"')
expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"') expect(rolesSource).toContain('width="min(860px, calc(100vw - 28px))"')
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"') 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('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"') expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))') 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' }], name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ 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 canEdit = computed(() => access.can('platform:role:edit'))
const canGrant = computed(() => access.can('platform:role:grant')) 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') 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> </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"> <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"> <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" data-tone="work" aria-label="角色基本资料"> <section class="density-dialog-section workbench-evidence" aria-label="角色基本资料">
<h3 class="density-dialog-section__title">基本信息</h3> <h3 class="density-dialog-section__title">基本信息</h3>
<table class="density-table density-table--editable"><tbody> <table class="density-table density-table--editable role-basic-table"><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 :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>
<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> <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><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> <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> </tbody></table>
</section> </section>
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限"> <section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
@@ -282,10 +282,12 @@ onMounted(loadRoles)
</template> </template>
<style scoped> <style scoped>
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; } .role-editor-layout { grid-template-columns: minmax(0, 1fr); }
.role-editor-layout:not(:has(.role-editor-permissions)) { 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 span { min-width: 0; }
.permission-node.is-navigation-group span { font-weight: 650; } .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)); } :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> </style>
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business' const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null } const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => { describe('outsourced business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OutsourcedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => { it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail }) vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -44,7 +44,7 @@ export async function saveOutsourcedBusinessOperations(no: string, body: Outsour
} }
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) { export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot) requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片') if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form)) const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致') if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -68,7 +68,7 @@ export async function getOutsourcedBusinessPhotoContent(no: string, photo: Outso
try { try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } }) const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200) await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效') if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause } } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
} }
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business' const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null } const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => { describe('owned business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OwnedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOwnedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => { it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail }) vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -38,7 +38,7 @@ export async function saveOwnedBusinessOperations(no: string, body: OwnedBusines
return value return value
} }
export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) { export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) {
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片') if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form)) const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致') if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -60,7 +60,7 @@ export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusin
try { try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } }) const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200) await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效') if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause } } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
} }
@@ -53,8 +53,8 @@ async function save() {
<h3 class="density-dialog-section__title">报价资料</h3> <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> <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> </section>
<el-form v-else label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="saving"> <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" data-tone="work" aria-label="报价路线与归属"> <section class="density-dialog-section" aria-label="报价路线与归属">
<h3 class="density-dialog-section__title">基础信息</h3> <h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody> <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> <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> <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> </tbody></table>
</section> </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> <h3 class="density-dialog-section__title">报价金额<InlineHelp title="报价金额说明"><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p></InlineHelp></h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody> <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><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 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> </tbody></table>
</section> </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> <h3 class="density-dialog-section__title">有效期与备注</h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody> <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> <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>
@@ -57,7 +57,7 @@ describe('outsourced business per-slot photo save',()=>{
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}] const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises() const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled() expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises() await wrapper.findAll('button').find(button=>button.text()==='查看资料')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading') expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount() await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
}) })
@@ -42,7 +42,7 @@ async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFi
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = '' const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return if (!file || busy.value || !uploadable.value) return
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return } if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null busy.value = true; error.value = null
try { try {
ensureScope() ensureScope()
@@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template> <template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]> <template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId"> <div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" /> <a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button> <img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
<label v-if="uploadable" class="outsourced-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label> <p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="outsourced-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div> </div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p> <p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label> <label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template> </template>
</OutsourcedBusinessPhotosDialog> </OutsourcedBusinessPhotosDialog>
</template> </template>
@@ -42,7 +42,7 @@ async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = '' const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return if (!file || busy.value || !uploadable.value) return
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return } if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null busy.value = true; error.value = null
try { try {
ensureScope() ensureScope()
@@ -62,12 +62,13 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template> <template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]> <template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId"> <div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<img v-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" /> <a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看图片</el-button> <img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
<label v-if="uploadable" class="owned-business-upload">替换图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label> <p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="owned-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div> </div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p> <p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传图片<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label> <label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template> </template>
</OwnedBusinessPhotosDialog> </OwnedBusinessPhotosDialog>
</template> </template>
@@ -5,7 +5,7 @@ export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PR
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string } export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null } export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] } export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null } export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null } export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow { export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -26,7 +26,7 @@ export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIN
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] } export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [ export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
@@ -5,7 +5,7 @@ export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEP
export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string } export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null } export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] } export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null } export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null } export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OwnedBusinessRow { export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -26,7 +26,7 @@ export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHO
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] } export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [ export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' }, { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' }, { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import LoadErrorState from '@/shared/components/LoadErrorState.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { 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 { overseasMasterDataPermissions as permission } from '../permissions'
import { masterDataErrorMessage } from '../apiError' import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types' 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: '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 }, { 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 }) 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 } } 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 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 } 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, '汇率详情加载失败')) } } 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 #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 #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 #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> <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> <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> </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"> <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> <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="汇率月份"><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> <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> </tbody></table>
<p class="density-dialog-note">汇率方向:1 USD = X CNY</p> <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 #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 #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 #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 #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 #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> <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> </div>
</AppDialog> </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"> <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"> <el-form class="density-form density-form--outlined density-dialog-sections" label-position="left" label-width="90px" :disabled="saving">
<section class="density-dialog-section" data-tone="work" aria-label="报价基础信息"> <section class="density-dialog-section" aria-label="报价基础信息">
<h3 class="density-dialog-section__title">基础信息</h3> <h3 class="density-dialog-section__title">基础信息</h3>
<table class="density-table density-table--editable quote-basics-table"><tbody> <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> <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> <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> </tbody></table>
</section> </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> <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> <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-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> <tr v-if="!form.warehouseFees.length"><td colspan="5">暂无仓储作业费用</td></tr>
</tbody></table></div> </tbody></table></div>
</section> </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> <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> <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> <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>
@@ -5,14 +5,14 @@ export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTUR
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number] export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' } export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null } export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] } export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; reviewRound: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; uploadKey: string; replaceFileId?: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents` const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
export async function getLoadingDocuments(no: string) { export async function getLoadingDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no))) return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
} }
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) { export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string, reviewRound: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key); form.append('reviewRound', reviewRound)
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form)) return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
} }
export async function recoverLoadingPhoto(no: string, key: string) { export async function recoverLoadingPhoto(no: string, key: string) {
@@ -23,6 +23,6 @@ export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body:
} }
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) { export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' }) const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对') if (!(blob instanceof Blob) || !(['image/jpeg', 'image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料读取失败,请重新核对')
return blob return blob
} }
@@ -10,7 +10,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operato
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() })) vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() }))
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null } const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra }) const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) } function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) { async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) {
const input = wrapper.get('input[aria-label="上传司机驾驶证"]') const input = wrapper.get('input[aria-label="上传司机驾驶证"]')
@@ -24,34 +24,102 @@ beforeEach(() => {
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] })) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] }))
vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable')) vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable'))
}) })
it('refreshes photos added or replaced by PDA without writing and notifies the parent to reload the stage', async () => {
can.mockReturnValue(false)
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [photo] }))
const wrapper = setup(); await flushPromises()
const photos = api.loadingPhotoSlots.flatMap((slot, category) => Array.from({ length: 5 }, (_, index) => ({
...photo, slot, fileId: `${category + 2}${index}`, fileName: `pda-${category}-${index}.png`,
})))
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos }))
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
expect(wrapper.text()).not.toContain(photo.fileName)
expect(wrapper.findAll('.loading-document-heading').map(row => row.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}5/5 张`))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
vi.mocked(api.getLoadingDocuments).mockRejectedValueOnce(new Error('刷新失败'))
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => { it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
const wrapper = setup(); await flushPromises() const wrapper = setup(); await flushPromises()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5) expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
const table = wrapper.get('table[aria-label="装车照片"]') const list = wrapper.get('[aria-label="装车照片"]')
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间']) expect(wrapper.find('table').exists()).toBe(false)
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`)) expect(list.findAll('h3').map(heading => heading.text())).toEqual(Object.values(api.loadingPhotoLabels))
expect(list.findAll('.photo-count').map(count => count.text())).toEqual(Array(5).fill('0/5 张'))
expect(list.findAll('.photo-empty')).toHaveLength(5)
await upload(wrapper) await upload(wrapper)
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String)) expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1')
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' })) expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3])
expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('图片读取失败') expect(wrapper.text()).toContain('图片读取失败')
expect(table.get('tbody tr').text()).toContain('loading.png') expect(list.get('.loading-photo-row').attributes('aria-label')).toBe('司机驾驶证第1张')
expect(list.text()).not.toContain('loading.png')
wrapper.unmount() wrapper.unmount()
}) })
it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => {
const wrapper = setup(); await flushPromises()
vi.mocked(api.uploadLoadingPhoto).mockImplementationOnce(async () => {
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '8', dispatchRecordVersion: '12', reviewRound: '2' }))
return photo
})
await upload(wrapper, [new File(['a'], 'old-driver.png', { type: 'image/png' }), new File(['b'], 'old-driver-2.png', { type: 'image/png' })])
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![4]).toBe('1')
expect(wrapper.text()).toContain('审核轮次已变化')
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [license] }))
vi.mocked(api.readLoadingPhoto).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' }))
const wrapper = setup()
try {
await flushPromises()
expect(wrapper.get('a').attributes('href')).toBe('blob:license')
expect(wrapper.findComponent({ name: 'ElImage' }).exists()).toBe(false)
const input = wrapper.get('input[aria-label="更换司机驾驶证第1张"]')
expect(input.attributes('accept')).toContain('application/pdf')
expect(wrapper.get('input[aria-label="上传车牌照片"]').attributes('accept')).not.toContain('application/pdf')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['%PDF-1.4'], 'new.pdf', { type: 'application/pdf' })] })
await input.trigger('change'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ replaceFileId: '101' }))
} finally { wrapper.unmount(); vi.restoreAllMocks() }
})
it('shows every photo in a category, stops at five and replaces the selected image only', async () => { it('shows every photo in a category, stops at five and replaces the selected image only', async () => {
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png` })) const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png`, savedAt: `2026-10-26T11:49:0${index}.978960362Z` }))
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos })) vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos }))
const replacement = { ...photo, fileId: '999' } const replacement = { ...photo, fileId: '999' }
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement) vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement)
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) })) vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) }))
const wrapper = setup(); await flushPromises() const wrapper = setup(); await flushPromises()
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined() expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined()
for (const item of photos) expect(wrapper.text()).toContain(item.fileName) const rows = wrapper.findAll('.loading-photo-row')
const input = wrapper.get('input[aria-label="更换photo-2.png"]') expect(rows).toHaveLength(5)
rows.forEach((row, index) => {
expect(row.attributes('aria-label')).toBe(`司机驾驶证第${index + 1}张`)
expect(row.get('.photo-time').text()).toMatch(new RegExp(`保存时间:2026-10-26 \\d{2}:49:0${index}$`))
expect(row.findAll('input[type="file"]')).toHaveLength(1)
})
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
const input = rows[2]!.get('input[aria-label="更换司机驾驶证第3张"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] }) Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] })
await input.trigger('change'); await flushPromises() await input.trigger('change'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' })) expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' }))
expect(wrapper.text()).toContain('photo-0.png'); expect(wrapper.text()).toContain('photo-4.png') expect(wrapper.findAll('.loading-photo-row')).toHaveLength(5)
expect(wrapper.findAll('.loading-photo-row')[0]!.get('.photo-time').text()).toMatch(/:49:00$/)
expect(wrapper.findAll('.loading-photo-row')[2]!.get('.photo-time').text()).toBe('保存时间:—')
expect(wrapper.findAll('.loading-photo-row')[4]!.get('.photo-time').text()).toMatch(/:49:04$/)
wrapper.unmount() wrapper.unmount()
}) })
it('saves a multiple selection with fresh versions and notifies the parent once', async () => { it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { useAuthStore } from '@/core/auth/auth' import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -35,28 +36,35 @@ async function previews() {
async function load() { async function load() {
if (busy.value) return if (busy.value) return
busy.value = true; error.value = null busy.value = true; error.value = null
try { accept(await getLoadingDocuments(number)); await previews() } try { accept(await getLoadingDocuments(number)); await previews(); return true }
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } } catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
finally { if (active) busy.value = false } finally { if (active) busy.value = false }
} }
async function refresh() { if (await load()) emit('saved') }
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] } function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
function savedTime(value?: string | null) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) { async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = '' const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
if (!files.length || busy.value || !editable.value) return if (!files.length || busy.value || !editable.value) return
const reviewRound = snapshot.value!.reviewRound
const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length
if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return } if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return }
if (files.some(file => !['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return } if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return }
busy.value = true; error.value = null busy.value = true; error.value = null
let saved = 0, reconciled = false, bindingFailed = false let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
try { try {
ensureScope() ensureScope()
for (const file of files) { for (const file of files) {
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope() const uploadKey = crypto.randomUUID()
const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope()
const latest = await getLoadingDocuments(number); accept(latest) const latest = await getLoadingDocuments(number); accept(latest)
if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!latest.editable) throw new Error('当前状态不允许修改照片') if (!latest.editable) throw new Error('当前状态不允许修改照片')
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致') if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
try { try {
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) })) accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }))
} catch (cause) { } catch (cause) {
// 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。 // 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。
bindingFailed = true bindingFailed = true
@@ -72,7 +80,7 @@ async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: stri
} }
ElMessage.success(`已保存${saved}张照片`) ElMessage.success(`已保存${saved}张照片`)
} catch (cause) { } catch (cause) {
if (active) error.value = getApiErrorDetails(cause, bindingFailed if (active) error.value = getApiErrorDetails(cause, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续' ? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
: '图片上传失败,已保存照片保留,请重新选择图片') : '图片上传失败,已保存照片保留,请重新选择图片')
} }
@@ -84,33 +92,59 @@ onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url
defineExpose({ busy }) defineExpose({ busy })
</script> </script>
<template> <template>
<div v-loading="busy" class="density-dialog-sections"> <div v-loading="busy" class="loading-documents">
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p> <p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p> <p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody> <div class="loading-document-list" aria-label="装车照片">
<tr v-for="slot in loadingPhotoSlots" :key="slot"> <section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传图片' }}<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" /></label><span v-else>—</span></td> <header class="loading-document-heading">
<td>{{ loadingPhotoLabels[slot] }}<div>{{ photosFor(slot).length }}/5 张</div></td> <h3>{{ loadingPhotoLabels[slot] }}</h3>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td> <span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId"> <label v-if="editable" class="photo-upload category-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported /> {{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button> <input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" />
<label v-if="editable" class="photo-upload">更换<input type="file" accept="image/jpeg,image/png" :aria-label="`更换${photo.fileName}`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" /></label> </label>
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td> </header>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td> <ol v-if="photosFor(slot).length" class="loading-photo-list">
</tr> <li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
</tbody></table> <span class="photo-index">{{ index + 1 }}</span>
<div class="photo-preview">
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
</div>
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
更换
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
</label>
</li>
</ol>
<p v-else class="photo-empty">未上传</p>
</section>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.loading-documents-table { table-layout: fixed; } .loading-documents { display: flex; flex-direction: column; gap: 12px; }
.loading-documents-table td { overflow-wrap: anywhere; } .loading-documents > p { margin: 0; }
.photo-actions { width: 110px; } .loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
.photo-category { width: 120px; } .loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
.photo-preview { width: 140px; } .loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
.photo-time { width: 190px; } .loading-document-heading h3 { margin: 0; font-size: 14px; }
.loading-photo-image { display: block; width: 100px; height: 64px; } .photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
.category-upload { margin-left: auto; }
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
.photo-index { width: 20px; text-align: center; }
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
.loading-photo-image { display: block; width: 160px; height: 96px; }
.photo-time { font-variant-numeric: tabular-nums; }
.photo-empty { margin: 0; padding: 12px; }
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; } .photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } .photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; } .photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
@@ -3,9 +3,10 @@ import { ApiResponseError } from '@/shared/api/http'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest' import { beforeEach, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue' import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches' import * as api from '../api/dispatches'
import { command, detail, loadingPreview } from '../dispatchTestFixtures' import { command, detail, loadingPreview } from '../dispatchTestFixtures'
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts' import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' })) const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
@@ -280,3 +281,45 @@ it('saves a cleared unavailable master vehicle as an empty draft resource', asyn
expect(api.submitDispatchReview).not.toHaveBeenCalled() expect(api.submitDispatchReview).not.toHaveBeenCalled()
wrapper.unmount() wrapper.unmount()
}) })
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
const { wrapper, state } = await open('EDIT')
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
await flushPromises()
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).not.toContain('请先在“排车信息”')
// A pending reference must also be guarded at save time, rather than being converted to null.
card.vm.$emit('update:modelValue', ['301'])
await state.submit(); await flushPromises()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(card.props('modelValue')).toEqual(['301'])
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
wrapper.unmount()
})
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
const { wrapper, state } = await open('EDIT')
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
let saved: DispatchDetail | undefined
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
return command('EDIT', body.idempotencyKey)
})
await state.submit(); await flushPromises()
expect(wrapper.emitted('saved')).toHaveLength(1)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
wrapper.unmount()
})
@@ -39,6 +39,8 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }) const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } }) const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver()) const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const missingVehicle = computed(() => vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId)
const missingDriver = computed(() => driver.value.mode === 'MASTER' && !driver.value.driverId)
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } }) const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。 // 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null) const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
@@ -125,7 +127,10 @@ function payload(): object {
case 'MARK_EXCEPTION': case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型') if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch case 'EDIT':
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW': case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value) if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因') if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
@@ -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' })) 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() 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(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409)) vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = open(); await flushPromises(); await fill(wrapper) const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises() await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('版本变化'); expect(commandException).toHaveBeenCalledTimes(1); expect(getExceptionDetail).toHaveBeenCalledTimes(1) 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() 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].expectedVersion).toBe(7)
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey) 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']) expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
wrapper.unmount() 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 { overseasWarehousePermissions as permission } from '../permissions'
import { returnSourceKey, returnSourceReference } from '../returnSource' 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 emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process')) const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
@@ -156,9 +156,8 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<template> <template>
<section v-loading="loading" aria-label="异常处理详情"> <section v-loading="loading" aria-label="异常处理详情">
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p> <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"> <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="异常单号">{{ 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="处理状态">{{ ({ 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> <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"> <section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history workbench-evidence">
<h3>退回实收与重派关联</h3> <h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch"> <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.receiptNo }}</el-descriptions-item>
<el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item> <el-descriptions-item label="本批实收">{{ receipt.receivedPackages }} 箱</el-descriptions-item>
<el-descriptions-item label="实收时间">{{ formatOverseasDateTime(receipt.receivedAt) }}</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> </section>
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p> <p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess"> <template v-if="canProcess">
<el-form label-position="top" :disabled="locked"> <el-form class="density-form density-form--outlined exception-processing-form" label-position="left" label-width="140px" :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-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')"> <template v-if="allowed('COMPLETE_EXCEPTION')">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label> <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> <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> <p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template> </template>
</el-form> </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> <Teleport :to="actionsTarget || 'body'" :disabled="!actionsTarget" defer>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ '取消异常' }}</el-button> <div class="density-actions density-actions--footer">
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button> <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>
</template> </template>
</section> </section>
</template> </template>
<style scoped> <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 { margin: 12px 0; }
.exception-return-history h3 { margin: 0 0 8px; font-size: 14px; } .exception-return-history h3 { margin: 0 0 8px; font-size: 14px; }
.exception-return-batch + .exception-return-batch { margin-top: 12px; } .exception-return-batch + .exception-return-batch { margin-top: 12px; }
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css' import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue' 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 { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
@@ -158,14 +158,27 @@ onMounted(load)
</script> </script>
<template> <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="客户回访列表"> <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()"> <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 #toolbar-start><h1>客户回访</h1></template>
<template #filters> <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" /> <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>
<template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></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> </template>
<style scoped> <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); } .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-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; } .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, .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 > 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); } .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> </style>
@@ -110,7 +110,8 @@ onMounted(load)
</el-form> </el-form>
</AppDialog> </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="关闭"> <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> </AppDialog>
</template> </template>
@@ -8,6 +8,7 @@ const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { custom
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() })) vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null })) vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: { vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>', name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} })) } }))
@@ -220,7 +220,7 @@ onMounted(initialize)
</template> </template>
<style scoped> <style scoped>
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); } .admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
.pallet-editor-table { table-layout: fixed; } .pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; } .pallet-editor-table td:not([colspan]) { width: 50%; }
@@ -38,6 +38,7 @@ function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
overCapacity: Boolean(raw?.overCapacity), overCapacity: Boolean(raw?.overCapacity),
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm), planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm), bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
availableBookedCapacityCbm: raw?.availableBookedCapacityCbm == null ? null : number(raw.availableBookedCapacityCbm),
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm), catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm), lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
snapshotAt: raw?.snapshotAt ?? null, snapshotAt: raw?.snapshotAt ?? null,
@@ -19,13 +19,20 @@ vi.mock('../api/platformSailingCalendar', () => ({
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]), listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(), previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
})) }))
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false } const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
const calendar: SailingCalendarResponse = { const calendar: SailingCalendarResponse = {
nextRefreshAt: '2026-10-01T00:00:00',
month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 }, month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 },
filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] }, filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] },
records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }], records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }],
} }
let wrapper: VueWrapper | undefined let wrapper: VueWrapper | undefined
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
return { promise, resolve, reject }
}
async function mountView() { async function mountView() {
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } }) wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
@@ -33,7 +40,7 @@ async function mountView() {
} }
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] }) vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 8, 30, 12)) vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00'))
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(api.listSailingTemplates).mockResolvedValue([]) vi.mocked(api.listSailingTemplates).mockResolvedValue([])
vi.mocked(api.listSailingChannels).mockResolvedValue([]) vi.mocked(api.listSailingChannels).mockResolvedValue([])
@@ -43,6 +50,141 @@ beforeEach(() => {
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() }) afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() })
describe('SailingCalendarView integrated workspace', () => { describe('SailingCalendarView integrated workspace', () => {
it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => {
const view = await mountView()
const previous = deferred<SailingCalendarResponse>()
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise)
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
await keyword.trigger('keyup', { key: 'Enter' })
const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] }
vi.mocked(listSailingCalendar).mockResolvedValueOnce(october)
await view.get('button[aria-label="下个月"]').trigger('click')
await flushPromises()
if (outcome === 'success') previous.resolve(calendar)
else previous.reject(new Error('旧月份请求失败'))
await flushPromises()
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER')
expect(view.find('.load-error-state').exists()).toBe(false)
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
})
it('keeps the latest calendar request loading when an earlier request finishes first', async () => {
const view = await mountView()
const previous = deferred<SailingCalendarResponse>()
const current = deferred<SailingCalendarResponse>()
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise)
await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' })
await view.get('button[aria-label="下个月"]').trigger('click')
previous.resolve(calendar)
await flushPromises()
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true')
current.resolve({ ...calendar, month: '2026-10', records: [] })
await flushPromises()
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
})
it('keeps the copied sailing selected when its date is later in the current month', async () => {
vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00'))
const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' }
const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' }
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] })
const view = await mountView()
await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click')
await view.get('.sailing-card').trigger('click')
vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] })
await clickAction(view, '复制下一班')
expect(view.get('.detail-header h2').text()).toBe('2026-09-28')
expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED')
expect(view.get('.detail-actions').text()).toContain('订柜建议')
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
await flushPromises()
expect(view.get('.detail-header h2').text()).toBe('2026-09-20')
})
it('uses the Shanghai calendar date at the UTC day boundary', async () => {
vi.setSystemTime(new Date('2026-09-29T16:00:00Z'))
const view = await mountView()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' }))
expect(view.get('.detail-header h2').text()).toBe('2026-09-30')
expect(view.get('.calendar-note').text()).toContain('仅今天及以后')
})
it('refreshes at cutoff so the current cargo reference can roll forward', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 }
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] })
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
expect(view.get('.volume-line').text()).toContain('建议 0 柜')
expect(view.find('.detail-actions').exists()).toBe(false)
})
it('refreshes across midnight and month end and removes expired selection', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
const view = await mountView()
await view.get('.sailing-card').trigger('click')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } })
await vi.advanceTimersByTimeAsync(1001)
await flushPromises()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
expect(view.find('.sailing-card').exists()).toBe(false)
expect(view.find('.detail-actions').exists()).toBe(false)
expect(view.get('.calendar-note').text()).toContain('共 0 个船期')
const calls = vi.mocked(listSailingCalendar).mock.calls.length
view.unmount(); wrapper = undefined
await vi.advanceTimersByTimeAsync(86_400_000)
expect(listSailingCalendar).toHaveBeenCalledTimes(calls)
})
it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] })
const view = await mountView()
const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] }
vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth)
await view.get('button[aria-label="下个月"]').trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' })
await vi.advanceTimersByTimeAsync(3_600_002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
})
it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
await mountView()
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误'))
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(59_000)
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1001)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
})
it('reloads today and clears actions when the selected sailing is no longer returned', async () => {
const view = await mountView()
await view.get('.sailing-card').trigger('click')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } })
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
expect(view.find('.detail-actions').exists()).toBe(false)
})
it('keeps one create entry and defaults the sailing to the selected calendar date', async () => { it('keeps one create entry and defaults the sailing to the selected calendar date', async () => {
const view = await mountView() const view = await mountView()
expect(view.find('.detail-header button').exists()).toBe(false) expect(view.find('.detail-header button').exists()).toBe(false)
@@ -129,6 +271,44 @@ async function editWeeklyTemplate() {
} }
describe('SailingCalendarView dialog workflows', () => { describe('SailingCalendarView dialog workflows', () => {
it.each([
['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true],
] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => {
const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' }
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] })
let finishOld!: () => void
if (label === '货量明细') {
const previous = deferred<Awaited<ReturnType<typeof api.getSailingVolumeDetail>>>()
const result = (code: string): Awaited<ReturnType<typeof api.getSailingVolumeDetail>> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
} else if (label === '变更历史') {
const previous = deferred<Awaited<ReturnType<typeof api.getSailing>>>()
const result = (reason: string): Awaited<ReturnType<typeof api.getSailing>> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] })
vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
} else {
const previous = deferred<Awaited<ReturnType<typeof api.getSailingBookingAdvice>>>()
const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 })
vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
}
const view = await mountView()
await view.findAll('.sailing-card')[0].trigger('click')
await clickAction(view, label)
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))!
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click')
await clickAction(view, label)
expect(document.body.textContent).toContain('NEW-ONLY')
finishOld()
await flushPromises()
expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST')
expect(document.body.textContent).toContain('NEW-ONLY')
expect(document.body.textContent).not.toContain('OLD-ONLY')
})
it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => { it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => {
const view = await mountView() const view = await mountView()
await clickAction(view, '新增单个船次') await clickAction(view, '新增单个船次')
@@ -194,6 +374,49 @@ describe('SailingCalendarView dialog workflows', () => {
})) }))
}) })
it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
await clickAction(view, '编辑')
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' })
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(view.find('.detail-actions').exists()).toBe(false)
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button').forEach(button => {
if (button.textContent?.trim() === '保存') button.click()
})
await flushPromises()
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({
expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00',
}))
expect(api.createSailing).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it('keeps the edited form when the server rejects a save after automatic refresh', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
await clickAction(view, '编辑')
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突'))
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 }))
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
})
it('loads volume, history and booking advice as read-only data for the business-number title', async () => { it('loads volume, history and booking advice as read-only data for the business-number title', async () => {
vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] }) vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] }) vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] })
File diff suppressed because one or more lines are too long
+2
View File
@@ -13,6 +13,7 @@ export interface SailingVolumeSummary {
overCapacity: boolean overCapacity: boolean
planningCapacityCbm?: number | null planningCapacityCbm?: number | null
bookedPlanningCapacityCbm?: number | null bookedPlanningCapacityCbm?: number | null
availableBookedCapacityCbm?: number | null
catchableReceivedCbm?: number | null catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null lateReceivedCbm?: number | null
snapshotAt?: string | null snapshotAt?: string | null
@@ -63,6 +64,7 @@ export interface SailingCalendarSummary extends SailingVolumeSummary {
export interface SailingCalendarResponse { export interface SailingCalendarResponse {
month: string month: string
timezone: string timezone: string
nextRefreshAt: string
records: SailingCalendarEntry[] records: SailingCalendarEntry[]
summary: SailingCalendarSummary summary: SailingCalendarSummary
filterOptions: SailingCalendarFilterOptions filterOptions: SailingCalendarFilterOptions
@@ -96,7 +96,7 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
</script> </script>
<template> <template>
<AdminPage class="admin-density admin-workbench" > <AdminPage class="supplier-form-page admin-density admin-workbench" >
<OperationErrorToast :details="submitError" /> <OperationErrorToast :details="submitError" />
<DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" :aria-label="editing ? '编辑供应商' : '新增供应商'"> <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> <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> </template>
<style scoped> <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; } .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-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; } .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; }
@@ -488,7 +488,7 @@ onMounted(() => {
<style scoped> <style scoped>
.pallet-page { display: grid; min-width: 0; } .pallet-page { display: grid; min-width: 0; }
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); } .admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); } .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.table-panel { min-width: 0; } .table-panel { min-width: 0; }
.context-empty { padding: var(--admin-space-6, 16px); color: var(--workbench-muted); text-align: center; } .context-empty { padding: var(--admin-space-6, 16px); color: var(--workbench-muted); text-align: center; }
@@ -774,8 +774,8 @@ onMounted(() => void loadWarehouses())
</ListPageLayout> </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> <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"> <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" 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> <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> <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> <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> <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> </tbody></table>
</section> </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> <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 }"> <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> <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> <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> </tbody></table>
</section> </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> <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> <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> </tbody></table>
</section> </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> <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><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> <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>
+2
View File
@@ -453,6 +453,8 @@ function setPage(value: number) {
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button), .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button),
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button) { margin-left: 0; } .data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); } .data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); }
/* Element Plus already supplies the table bottom border. */
.data-table > .el-table + .data-table__pagination { border-top: 0; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; } .data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; } .data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; } .data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
+1 -1
View File
@@ -20,7 +20,7 @@ withDefaults(defineProps<{
</template> </template>
<style scoped> <style scoped>
.form-section { min-width: 0; padding: 20px 0; } .form-section { min-width: 0; padding: 20px 0; border-bottom: 0; }
.form-section.is-bordered { border-bottom: 1px solid var(--cc-border, #e3e8ef); } .form-section.is-bordered { border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.form-section:first-child { padding-top: 0; } .form-section:first-child { padding-top: 0; }
.form-section:last-child { padding-bottom: 0; border-bottom: 0; } .form-section:last-child { padding-bottom: 0; border-bottom: 0; }
+144 -10
View File
@@ -221,16 +221,44 @@ const cssRoot = (css: string) => {
} }
describe('approved Admin density ownership', () => { describe('approved Admin density ownership', () => {
it('keeps pallet overview spacing and metric appearance owned by shared primitives', () => { it('draws only one separator between an Element table and pagination', () => {
const style = parse(read('shared/components/DataTable.vue')).descriptor.styles[0]!
const compiled = compileStyle({ source: style.content, filename: 'DataTable.vue', id: 'data-v-border-test', scoped: true })
expect(compiled.errors).toHaveLength(0)
const selectors: string[] = []
compiled.rawResult!.root.walkRules(rule => {
if (rule.selector.includes('.el-table + .data-table__pagination')) {
selectors.push(rule.selector)
expect(rule.nodes.some(node => node.type === 'decl' && node.prop === 'border-top' && node.value === '0')).toBe(true)
}
})
expect(selectors).toHaveLength(1)
const host = document.createElement('div')
host.innerHTML = `<div class="data-table"><div class="el-table"><div class="el-table__inner-wrapper"></div></div><footer id="standard" class="data-table__pagination" data-v-border-test></footer></div>
<div class="data-table"><section class="custom-results"></section><footer id="custom" class="data-table__pagination" data-v-border-test></footer></div>`
expect(host.querySelector('#standard')!.matches(selectors[0]!)).toBe(true)
expect(host.querySelector('#custom')!.matches(selectors[0]!)).toBe(false)
expect(style.content).not.toMatch(/inner-wrapper::before\s*\{[^}]*display:\s*none/)
})
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']) { for (const file of ['warehouse-resources/pallets/pages/PalletsView.vue', 'overseas-warehouse/pages/PalletsView.vue']) {
const page = parse(read(`modules/${file}`)).descriptor const page = parse(read(`modules/${file}`)).descriptor
const css = page.styles.map(style => style.content).join('\n') const css = page.styles.map(style => style.content).join('\n')
expect(page.template!.content, file).toMatch(/<AdminPage\b[^>]*spacing="compact"/) 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(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(/\.summary-grid\s+(?:article|span|strong|\.idle|\.occupied)\b/)
expect(css, file).not.toMatch(/\.workbench-metric[\s.{]/) 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', () => { it('lets local transport editors fill the workspace and keeps form density in shared primitives', () => {
@@ -587,6 +615,8 @@ describe('approved Admin density ownership', () => {
it('keeps dashboard visualizations outside detail-field table styling', () => { it('keeps dashboard visualizations outside detail-field table styling', () => {
const dashboard = read('modules/dashboard/pages/DashboardView.vue') 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')) const metrics = elementsOf(dashboard, 'div').filter(node => classesOf(node).includes('dashboard-metrics'))
expect(metrics).toHaveLength(1) expect(metrics).toHaveLength(1)
const cards = elementsOf(dashboard, 'article').filter(node => classesOf(node).includes('dashboard-metric')) const cards = elementsOf(dashboard, 'article').filter(node => classesOf(node).includes('dashboard-metric'))
@@ -676,10 +706,13 @@ describe('approved Admin density ownership', () => {
it('keeps warehouse editors and import results on shared sections and cell editors', () => { it('keeps warehouse editors and import results on shared sections and cell editors', () => {
const source = read('modules/warehouses/pages/WarehousesView.vue') 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') expect(source).toContain('density-table density-table--editable warehouse-editor-table')
const importer = expectWorkbenchDialogs(source).find(node => classesOf(node).includes('warehouse-import-dialog')) const importer = expectWorkbenchDialogs(source).find(node => classesOf(node).includes('warehouse-import-dialog'))
expect(importer).toBeDefined() 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-table warehouse-import-summary"')
expect(source).toContain('class="density-actions density-actions--footer"') expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).not.toContain('<FormSection') expect(source).not.toContain('<FormSection')
@@ -720,26 +753,30 @@ describe('approved Admin density ownership', () => {
expect(css).toContain(':not(.is-fullscreen)') 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 source = read('modules/fee-items/pages/FeeItemsView.vue')
const ast = parse(source).descriptor.template!.ast! const ast = parse(source).descriptor.template!.ast!
let editorFound = false let editorFound = false
const visit = (nodes: typeof ast.children, inEditor = false) => { for (const node of nodes) { const visit = (nodes: typeof ast.children, inEditor = false) => { for (const node of nodes) {
if (node.type !== 1) continue if (node.type !== 1) continue
const inside = inEditor || node.tag === 'AppDrawer' && classesOf(node).includes('admin-density-drawer--editor') const inside = inEditor || node.tag === 'AppDialog' && classesOf(node).includes('fee-item-editor')
if (node.tag === 'AppDrawer') { if (node.tag === 'AppDialog' && classesOf(node).includes('fee-item-editor')) {
editorFound = true 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) if (classesOf(node).some(name => ['density-dialog-section', 'density-table--editable'].includes(name))) expect(inside).toBe(true)
visit(node.children, inside) visit(node.children, inside)
} } } }
visit(ast.children) visit(ast.children)
expect(editorFound).toBe(true) 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('<FormSection')
expect(source).not.toContain('label-position="top"') expect(source).not.toContain('label-position="top"')
expect(source).toContain('class="density-actions density-actions--footer"') 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)') expect(shared).toContain(':where(.admin-density-drawer--detail, .admin-density-drawer--editor)')
const css = parse(source).descriptor.styles.map(style => style.content).join('') const css = parse(source).descriptor.styles.map(style => style.content).join('')
expect(css).not.toContain('direction-grid') expect(css).not.toContain('direction-grid')
@@ -760,6 +797,14 @@ describe('approved Admin density ownership', () => {
expect(css).not.toMatch(/\.task-(?:detail-summary|failure)\b|\.detail-load-error\b/) 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', () => { 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 list = read('modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
const detail = read('modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue') const detail = read('modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue')
@@ -783,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 === 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(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('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('data-tone="source"')
expect(source).toContain('class="density-actions density-actions--footer"') expect(source).toContain('class="density-actions density-actions--footer"')
expect(source).not.toContain('label-position="top"') expect(source).not.toContain('label-position="top"')
@@ -791,6 +836,9 @@ describe('approved Admin density ownership', () => {
expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/) expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/)
} }
expect(editor).toContain('v-if="readonly"') 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"') expect(editor).toContain('class="density-fields fleet-quote-facts"')
const confirmations = confirmationClasses(read('modules/overseas-master-data/pages/FleetQuotesView.vue')) const confirmations = confirmationClasses(read('modules/overseas-master-data/pages/FleetQuotesView.vue'))
expect(confirmations.length).toBeGreaterThan(0) expect(confirmations.length).toBeGreaterThan(0)
@@ -799,6 +847,7 @@ describe('approved Admin density ownership', () => {
it('keeps pricing dialogs on shared tables and sections without local form paint', () => { it('keeps pricing dialogs on shared tables and sections without local form paint', () => {
const rate = read('modules/overseas-master-data/pages/ExchangeRatesView.vue') 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') const quote = read('modules/overseas-master-data/pages/OverseasQuotesView.vue')
for (const source of [rate, quote]) { for (const source of [rate, quote]) {
const dialogs = expectWorkbenchDialogs(source) const dialogs = expectWorkbenchDialogs(source)
@@ -814,7 +863,8 @@ describe('approved Admin density ownership', () => {
expect(quote.match(/fullscreenable/g)).toHaveLength(2) expect(quote.match(/fullscreenable/g)).toHaveLength(2)
expect(quote).toContain('data-tone="summary"') expect(quote).toContain('data-tone="summary"')
expect(quote).toContain('data-tone="source"') 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('') const css = parse(quote).descriptor.styles.map(style => style.content).join('')
expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/) expect(css).not.toMatch(/font-size|background|border|el-dialog__body|max-height/)
}) })
@@ -1665,6 +1715,90 @@ describe('approved Admin density ownership', () => {
expect(popups).not.toContain('label-position="top"') expect(popups).not.toContain('label-position="top"')
expect(statements).not.toContain('.create-period-row') 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', () => { it('keeps wallet dialogs compact while preserving financial warnings and preview space', () => {
const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue') const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
const popups = content.slice(content.indexOf('<AppDialog')) const popups = content.slice(content.indexOf('<AppDialog'))