修复客户订单列设置持久化并统一最新计费量展示

This commit is contained in:
2026-10-28 11:56:49 +08:00
parent 3ce0f2e234
commit c187544a94
6 changed files with 260 additions and 24 deletions
@@ -443,18 +443,16 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
</div>
</section>
</template>
<template #footer><el-button @click="close">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
:global(.customs-documents-dialog.el-dialog) { display: flex; max-height: 70vh; flex-direction: column; overflow: hidden; padding: 0; border: 1px solid var(--client-border); border-radius: var(--client-radius); }
:global(.customs-documents-dialog .el-dialog__header), :global(.customs-documents-dialog .el-dialog__footer) { flex: 0 0 auto; }
:global(.customs-documents-dialog .el-dialog__header) { flex: 0 0 auto; }
:global(.customs-documents-dialog .el-dialog__header) { margin: 0; padding: 18px 48px 18px 20px; border-bottom: 1px solid var(--client-border); }
:global(.customs-documents-dialog .el-dialog__title) { color: var(--client-text); font-size: 16px; font-weight: 600; line-height: 24px; }
:global(.customs-documents-dialog .el-dialog__headerbtn) { top: 8px; right: 8px; }
:global(.customs-documents-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px; color: var(--client-text); line-height: 1.6; overscroll-behavior: contain; }
:global(.customs-documents-dialog .el-dialog__footer) { padding: 14px 20px; border-top: 1px solid var(--client-border); background: var(--client-header-bg); }
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
.customs-summary { display: grid; grid-template-columns: minmax(0, 1.6fr) repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--client-header-bg); }
@@ -1,8 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { compile, computed, createSSRApp, h, inject, provide } from 'vue'
import { renderToString } from 'vue/server-renderer'
import ts from 'typescript'
const source = readFileSync(new URL('./InboundBillingPanel.vue', import.meta.url), 'utf8')
const confirmationBody = source.slice(source.indexOf('<dl class="fee-dialog-summary"'), source.indexOf('<div class="fee-confirm-notice"'))
const formatterSource = readFileSync(new URL('../utils/inboundBilling.ts', import.meta.url), 'utf8')
const formatters = await import(`data:text/javascript;base64,${Buffer.from(ts.transpileModule(formatterSource, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText).toString('base64')}`)
async function renderConfirmation(data) {
const app = createSSRApp({
render: compile(`<div>${confirmationBody}</div>`),
setup: () => ({ props: { data }, ...formatters }),
})
app.component('ElTable', {
props: ['data'],
setup(tableProps, { slots }) {
provide('billingRows', computed(() => tableProps.data))
return () => h('table', slots.default?.())
},
})
app.component('ElTableColumn', {
props: ['label', 'prop'],
setup(columnProps, { slots }) {
const rows = inject('billingRows')
return () => h('tr', [h('th', columnProps.label), ...rows.value.map(row =>
h('td', slots.default?.({ row }) ?? row[columnProps.prop] ?? ''))])
},
})
return renderToString(app)
}
test('renders loading, pending, isolated error, and published states', () => {
assert.match(source, /v-if="props\.loading"/)
@@ -45,17 +76,55 @@ test('shows the published merchant inbound, measurement details and customer con
assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/)
})
test('matches the confirmed billing prototype summary, table, notice and footer', () => {
test('keeps the billing summary, confirmation table, notice and footer', () => {
assert.match(source, /width="min\(1180px, calc\(100vw - 40px\)\)"/)
assert.match(source, /class="fee-dialog-summary" aria-label="计费汇总"/)
for (const label of ['计算重', '单价', '总金额', '箱规长(cm)', '箱规宽(cm)', '箱规高(cm)', '每箱体积', '总体积', '总材积重']) assert.ok(source.includes(label), `missing prototype label: ${label}`)
for (const label of ['计算重', '单价', '总金额', '计费毛重', '计费体积', '计费方式', '最终计费量']) assert.ok(confirmationBody.includes(label), `missing billing label: ${label}`)
assert.match(source, /class="fee-confirm-notice"/)
assert.match(source, /请核对计算重、单价和总金额。确认后当前费用版本将锁定。/)
assert.match(source, /请核对计费毛重、计费体积、计费量、单价和总金额。确认后当前费用版本将锁定。/)
assert.match(source, /确认计费量和费用/)
assert.match(source, /\.fee-dialog-summary \{ display:grid; grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/)
assert.match(source, /\.fee-dialog-table :deep\(\.el-table__body td\.el-table__cell\)/)
})
test('confirms the selected billing weight and volume even when outer carton totals differ', async () => {
const html = await renderConfirmation({
latestFee: {
actualWeightKg: 570.5, actualVolumeCbm: 3.6894,
chargeableQuantity: 616.1298, chargeMode: 'KG', chargeUnit: 'KG',
unitPrice: 13.6, totalAmount: 8379.37, currency: 'CNY',
},
billingBasis: { chargeableWeightKg: 650.298 },
measurementItems: [{ lengthCm: 63, widthCm: 53, heightCm: 53, packageCount: 22,
unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298 }],
})
assert.match(html, /570\.5 kg/)
assert.match(html, /3\.6894 m³/)
assert.match(html, /616\.13 kg/)
assert.match(html, /8,379\.37/)
assert.doesNotMatch(html, /3\.894|650\.298|0\.177/)
assert.doesNotMatch(confirmationBody, /measurementItems|billingBasis|箱规长|每箱体积/)
})
test('uses the published volume charge quantity and retains six decimal places', async () => {
const html = await renderConfirmation({
latestFee: {
actualWeightKg: 600, actualVolumeCbm: 1.234567,
chargeableQuantity: 1.652893, chargeMode: 'CBM', chargeUnit: 'CBM',
unitPrice: 100, totalAmount: 165.29, currency: 'CNY',
},
billingBasis: { chargeableWeightKg: 600 }, measurementItems: [],
})
assert.match(html, /计费量/)
assert.match(html, /按体积计费/)
assert.match(html, /600 kg/)
assert.match(html, /1\.234567 m³/)
assert.match(html, /1\.652893 m³/)
assert.doesNotMatch(html, /1\.652893 kg/)
})
test('stacks core information for 720 and 360 pixel layouts', () => {
assert.match(source, /@media \(max-width: 720px\)/)
assert.match(source, /\.inbound-overview, \.inbound-measurements, \.fee-calculation-grid \{ grid-template-columns: 1fr;/)
@@ -172,8 +172,8 @@ async function confirmFee() {
props.data.latestFee.roundingMode,
) }}</dd>
</div>
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>计费毛重</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³', 6) }}</dd></div>
<div><dt>体积重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>最终计费量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
@@ -214,26 +214,20 @@ async function confirmFee() {
</div>
<template v-else>
<dl class="fee-dialog-summary" aria-label="计费汇总">
<div><dt>计算重</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
<div><dt>{{ props.data.latestFee.chargeUnit === 'KG' ? '计算重' : '计费量' }}</dt><dd>{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit === 'KG' ? 'kg' : 'm³', props.data.latestFee.chargeUnit === 'KG' ? 3 : 6) }}</dd></div>
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit.toUpperCase() }}</dd></div>
<div><dt>总金额</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
</dl>
<el-table class="measurement-detail-table fee-dialog-table" :data="props.data.measurementItems" border empty-text="暂无箱规测量明细">
<el-table-column type="index" label="#" width="54" />
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
<el-table-column prop="packageCount" label="箱数" min-width="76" />
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column label="箱规规则" min-width="170"><template #default="{ row }">{{ getCalculationRuleLabel(row.calculationRuleVersion) }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
<el-table class="measurement-detail-table fee-dialog-table" :data="[props.data.latestFee]" border>
<el-table-column label="计费毛重" min-width="180"><template #default="{ row }">{{ formatInboundMeasure(row.actualWeightKg, 'kg') }}</template></el-table-column>
<el-table-column label="计费体积" min-width="180"><template #default="{ row }">{{ formatInboundMeasure(row.actualVolumeCbm, 'm³', 6) }}</template></el-table-column>
<el-table-column label="计费方式" min-width="150"><template #default="{ row }">{{ getChargeModeLabel(row.chargeMode) }}</template></el-table-column>
<el-table-column label="最终计费量" min-width="180"><template #default="{ row }">{{ formatInboundMeasure(row.chargeableQuantity, row.chargeUnit === 'KG' ? 'kg' : 'm³', row.chargeUnit === 'KG' ? 3 : 6) }}</template></el-table-column>
</el-table>
<div class="fee-confirm-notice" :class="{ 'is-confirmed': props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' }" role="status">
<CircleAlert :size="21" aria-hidden="true" />
<span v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'">客户已于 {{ formatInboundDateTime(props.data.latestFee.customerConfirmedAt) }} 确认,费用已锁定,不允许修改。</span>
<span v-else>请核对计算重、单价和总金额。确认后当前费用版本将锁定。</span>
<span v-else>请核对计费毛重、计费体积、计费量、单价和总金额。确认后当前费用版本将锁定。</span>
</div>
</template>
<template #footer>
@@ -283,7 +277,7 @@ async function confirmFee() {
.fee-dialog-table { margin-top:16px; overflow:hidden; border-radius:10px; box-shadow:0 5px 18px rgba(51,65,85,.04); }
.fee-dialog-table :deep(.el-table__header th.el-table__cell) { height:42px; padding:9px 0; background:linear-gradient(180deg,#f8f9fc,#f3f5f9); color:#697386; font-size:13px; font-weight:650; }
.fee-dialog-table :deep(.el-table__body td.el-table__cell) { height:82px; padding:9px 0; color:#515a6c; font-size:14px; font-variant-numeric:tabular-nums; }
.fee-dialog-table :deep(.el-table__body td:nth-child(7)), .fee-dialog-table :deep(.el-table__body td:nth-child(8)) { color:#34466b; font-weight:650; }
.fee-dialog-table :deep(.el-table__body td:nth-child(2)), .fee-dialog-table :deep(.el-table__body td:nth-child(4)) { color:#34466b; font-weight:650; }
.fee-dialog-table :deep(.cell) { line-height:1.6; }
.fee-confirm-notice { display:flex; align-items:center; gap:10px; margin-top:14px; padding:10px 14px; border:1px solid #f5dfba; border-radius:9px; background:linear-gradient(90deg,#fff9ef,#fffaf2); color:#c6790c; font-size:14px; font-weight:500; }
.fee-confirm-notice > svg { flex:0 0 auto; fill:#eda332; color:#fff; filter:drop-shadow(0 2px 3px rgba(233,154,37,.25)); }
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
import { computed, effectScope, ref, watch } from 'vue'
import { readOrderColumnPreferences, saveOrderColumnPreferences } from './orderColumnPreferences.ts'
const page = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
const columnSource = page.slice(page.indexOf('const fixedOrderColumns:'), page.indexOf('const tablePage ='))
const columnScript = ts.transpileModule(columnSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const createColumns = new Function('auth', 'ref', 'computed', 'watch', 'readOrderColumnPreferences', 'saveOrderColumnPreferences', `${columnScript}
return { visibleColumnKeys, orderedColumnKeys, visibleOrderColumns, draggedColumnKey, moveOrderColumn, dropOrderColumn, resetOrderColumns }
`)
function mockStorage(t) {
const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
const entries = new Map()
const storage = {
getItem: key => entries.get(key) ?? null,
setItem: (key, value) => entries.set(key, value),
}
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage })
t.after(() => {
if (original) Object.defineProperty(globalThis, 'localStorage', original)
else delete globalThis.localStorage
})
return storage
}
function openColumns(t, ownerId = 'customer-one') {
const scope = effectScope()
const columns = scope.run(() => createColumns({ user: { id: ownerId } }, ref, computed, watch, readOrderColumnPreferences, saveOrderColumnPreferences))
t.after(() => scope.stop())
return { ...columns, close: () => scope.stop() }
}
test('restores checked fields and both arrow and drag ordering after a fresh page setup', t => {
mockStorage(t)
const columns = openColumns(t)
columns.visibleColumnKeys.value.push('destinationWarehouse', 'grossWeight', 'volume')
columns.visibleColumnKeys.value.splice(columns.visibleColumnKeys.value.indexOf('createdAt'), 1)
columns.moveOrderColumn('destinationWarehouse', -1)
columns.draggedColumnKey.value = 'grossWeight'
columns.dropOrderColumn('status')
const visibleKeys = [...columns.visibleColumnKeys.value]
const orderedKeys = [...columns.orderedColumnKeys.value]
const renderedKeys = columns.visibleOrderColumns.value.map(column => column.key)
columns.close()
const refreshed = openColumns(t)
assert.deepEqual(refreshed.visibleColumnKeys.value, visibleKeys)
assert.deepEqual(refreshed.orderedColumnKeys.value, orderedKeys)
assert.deepEqual(refreshed.visibleOrderColumns.value.map(column => column.key), renderedKeys)
assert.deepEqual(renderedKeys.slice(0, 3), ['selection', 'actions', 'inboundCode'])
assert.equal(renderedKeys[3], 'grossWeight')
})
test('persists restore-default and intentionally hiding every selectable field', t => {
mockStorage(t)
const columns = openColumns(t)
const defaults = [...columns.visibleColumnKeys.value]
const defaultOrder = [...columns.orderedColumnKeys.value]
columns.visibleColumnKeys.value = []
columns.close()
const refreshed = openColumns(t)
assert.deepEqual(refreshed.visibleColumnKeys.value, [])
assert.deepEqual(refreshed.visibleOrderColumns.value.map(column => column.key), ['selection', 'actions', 'inboundCode'])
refreshed.moveOrderColumn('grossWeight', -1)
refreshed.resetOrderColumns()
refreshed.close()
const reset = openColumns(t)
assert.deepEqual(reset.visibleColumnKeys.value, defaults)
assert.deepEqual(reset.orderedColumnKeys.value, defaultOrder)
})
test('keeps the field selection separate for each signed-in account', t => {
mockStorage(t)
const first = openColumns(t)
first.visibleColumnKeys.value = ['volume']
first.close()
const second = openColumns(t, 'customer-two')
assert.notDeepEqual(second.visibleColumnKeys.value, ['volume'])
second.visibleColumnKeys.value = ['grossWeight']
second.close()
assert.deepEqual(openColumns(t).visibleColumnKeys.value, ['volume'])
assert.deepEqual(openColumns(t, 'customer-two').visibleColumnKeys.value, ['grossWeight'])
})
test('discards unknown, duplicate and invalid keys and includes newly available fields in ordering', t => {
mockStorage(t)
saveOrderColumnPreferences('customer-one', {
visibleColumnKeys: ['volume', 'removed', 'volume', null, 3],
orderedColumnKeys: ['volume', 'removed', 'volume', false],
})
assert.deepEqual(readOrderColumnPreferences('customer-one', ['status', 'volume', 'newField'], ['status']), {
visibleColumnKeys: ['volume'],
orderedColumnKeys: ['volume', 'status', 'newField'],
})
})
test('uses the original defaults for missing or damaged saved preferences', t => {
const storage = mockStorage(t)
const defaults = { visibleColumnKeys: ['status'], orderedColumnKeys: ['status', 'volume'] }
assert.deepEqual(readOrderColumnPreferences('customer-one', ['status', 'volume'], ['status']), defaults)
saveOrderColumnPreferences('customer-one', defaults)
const realGetItem = storage.getItem
for (const saved of ['{invalid', 'null', '[]', '42', '{"visibleColumnKeys":"status","orderedColumnKeys":[]}']) {
storage.getItem = () => saved
assert.deepEqual(readOrderColumnPreferences('customer-one', ['status', 'volume'], ['status']), defaults)
}
storage.getItem = realGetItem
})
test('keeps column controls usable when browser storage is blocked', t => {
const storage = mockStorage(t)
storage.getItem = () => { throw new Error('Storage denied') }
storage.setItem = () => { throw new Error('Storage denied') }
const columns = openColumns(t)
assert.ok(columns.visibleColumnKeys.value.includes('status'))
assert.doesNotThrow(() => { columns.visibleColumnKeys.value.push('volume') })
assert.doesNotThrow(() => columns.resetOrderColumns())
})
test('does not read or save preferences without an account', t => {
const storage = mockStorage(t)
let storageCalls = 0
storage.getItem = () => { storageCalls++; return null }
storage.setItem = () => { storageCalls++ }
const columns = openColumns(t, '')
columns.visibleColumnKeys.value = ['volume']
assert.equal(openColumns(t, '').visibleColumnKeys.value.includes('status'), true)
assert.equal(storageCalls, 0)
})
@@ -0,0 +1,35 @@
interface OrderColumnPreferences {
visibleColumnKeys: string[]
orderedColumnKeys: string[]
}
function storageKey(ownerId: string) {
return `cc:client:first-leg:order-columns:${ownerId}`
}
export function readOrderColumnPreferences(ownerId: string, selectableKeys: string[], defaultVisibleKeys: string[]): OrderColumnPreferences {
const defaults = { visibleColumnKeys: [...defaultVisibleKeys], orderedColumnKeys: [...selectableKeys] }
if (!ownerId) return defaults
try {
const saved: unknown = JSON.parse(localStorage.getItem(storageKey(ownerId)) ?? 'null')
if (!saved || typeof saved !== 'object') return defaults
const preferences = saved as Partial<OrderColumnPreferences>
if (!Array.isArray(preferences.visibleColumnKeys) || !Array.isArray(preferences.orderedColumnKeys)) return defaults
const validKeys = (keys: unknown[]) => [...new Set(keys.filter((key): key is string => typeof key === 'string' && selectableKeys.includes(key)))]
return {
visibleColumnKeys: validKeys(preferences.visibleColumnKeys),
orderedColumnKeys: [...new Set([...validKeys(preferences.orderedColumnKeys), ...selectableKeys])],
}
} catch {
return defaults
}
}
export function saveOrderColumnPreferences(ownerId: string, preferences: OrderColumnPreferences) {
if (!ownerId) return
try {
localStorage.setItem(storageKey(ownerId), JSON.stringify(preferences))
} catch {
// Column selection remains usable when browser storage is unavailable.
}
}
@@ -64,6 +64,7 @@ import { orderBillingMetric } from '../utils/orderDetailSummary'
import type { OrderQueue } from '../api/unifiedOrders'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
import { readOrderColumnPreferences, saveOrderColumnPreferences } from '@/modules/first-leg/utils/orderColumnPreferences'
const store = useFirstLegOrdersStore()
const tenantStore = useTenantAdministrationStore()
@@ -182,8 +183,13 @@ const selectableOrderColumns: DataTableColumn[] = [
]
const allSelectableColumnKeys = selectableOrderColumns.map((column) => String(column.key))
const defaultVisibleColumnKeys = ['status', 'progress', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt']
const visibleColumnKeys = ref<string[]>([...defaultVisibleColumnKeys])
const orderedColumnKeys = ref<string[]>([...allSelectableColumnKeys])
const columnPreferencesOwnerId = auth.user?.id ?? ''
const savedColumnPreferences = readOrderColumnPreferences(columnPreferencesOwnerId, allSelectableColumnKeys, defaultVisibleColumnKeys)
const visibleColumnKeys = ref<string[]>(savedColumnPreferences.visibleColumnKeys)
const orderedColumnKeys = ref<string[]>(savedColumnPreferences.orderedColumnKeys)
watch([visibleColumnKeys, orderedColumnKeys], ([visibleKeys, orderedKeys]) => {
saveOrderColumnPreferences(columnPreferencesOwnerId, { visibleColumnKeys: visibleKeys, orderedColumnKeys: orderedKeys })
}, { deep: true, flush: 'sync' })
const draggedColumnKey = ref('')
const orderedSelectableColumns = computed(() => orderedColumnKeys.value
.map((key) => selectableOrderColumns.find((column) => String(column.key) === key))