fix(client): clear protected views on logout and show customer customs state

This commit is contained in:
2026-10-03 03:54:06 +08:00
parent b1f538b3ac
commit 5c53eba31b
7 changed files with 149 additions and 21 deletions
+17 -1
View File
@@ -35,7 +35,7 @@ function harness(t, expired = false) {
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
const module = compile('./auth.ts', {
vue, pinia, '@/api/session': session,
'@/modules/auth/api/auth': { logoutMerchant: async () => {} },
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) },
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
@@ -136,3 +136,19 @@ test('stale unauthorized context response cannot clear a newer account', async t
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.auth.sessionRestored, true)
})
test('store logout clears immediately and late revocation failure preserves a later cross-tab login', async t => {
const h = harness(t); await h.auth.restoreSession()
const remote = deferred(); let sentRefresh
h.behavior.logout = refresh => { sentRefresh = refresh; return remote.promise }
const pending = h.auth.logout()
const rejected = assert.rejects(pending, /offline/)
assert.equal(sentRefresh, 'refresh-a')
assert.equal(h.auth.isAuthenticated, false)
assert.equal(h.session.getAccessToken(), '')
h.rotate(token(2, 2, 2)); await flush()
remote.reject(new Error('offline')); await rejected
assert.equal(h.auth.user.id, '2')
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.session.getRefreshToken(), 'refresh-b')
})
@@ -14,6 +14,9 @@ function evaluate(source, dependencies = {}) {
return exports
}
const eligibility = evaluate(readFileSync(new URL('../utils/customsEligibility.ts', import.meta.url), 'utf8'))
const customsApi = evaluate(readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8'), {
'@/api/http': {}, '@/modules/first-leg/api/firstLeg': {},
})
const order = (id, status = 'LOADED', taxMode = 'TAX_REFUND') => ({ id, status, taxMode, orderNo: `ORDER-${id}`, businessSummary: { inboundCode: `IN-${id}` } })
const detail = id => ({ status: 'COMPLETED', merchantStatus: 'CUSTOMS_COMPLETED', inbounds: [{ orderId: id, taxMode: 'TAX_REFUND' }] })
function harness(t, orders, getDetail = async id => detail(id)) {
@@ -38,9 +41,10 @@ function harness(t, orders, getDetail = async id => detail(id)) {
})
const source = readFileSync(new URL('./CustomsDocumentsView.vue', import.meta.url), 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
const scope = vue.effectScope()
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords }', {
const view = scope.run(() => evaluate(source + '\nexport { load, records, loading, errorMessage, openDetail, selectedOrderId, selectedDetail, dialogOpen, search, queryModel, filteredRecords, queryFields, statusLabel, statusType, updateDetail }', {
vue: { ...vue, onMounted() {} },
'../utils/customsDocumentsReader': reader,
'../api/customs': customsApi,
}))
t.after(() => scope.stop())
return { view, store, calls, pageCalls }
@@ -128,7 +132,7 @@ test('tax-refund documents remain accessible through delivered and completed ord
assert.equal(view.selectedOrderId.value, 'COMPLETED')
assert.equal(view.selectedDetail.value.status, 'COMPLETED')
assert.equal(view.dialogOpen.value, true)
view.queryModel.value.status = 'COMPLETED'; view.search()
view.queryModel.value.status = 'CUSTOMS_COMPLETED'; view.search()
assert.equal(view.filteredRecords.value.length, 3)
assert.equal(calls.length, 3, 'local filtering does not refetch documents')
})
@@ -175,3 +179,46 @@ test('repeated refresh during an active load does not duplicate requests', async
resolve(detail('one')); await first
assert.equal(view.loading.value, false)
})
test('mixed-container labels and filters use only the current customer status', async t => {
const states = [
['uploaded', 'WAITING_CUSTOMER_DOCUMENTS', 'DOCUMENT_UPLOADED', 1, '资料已上传', 'success'],
['waiting', 'WAITING_CUSTOMER_DOCUMENTS', 'WAITING_UPLOAD', 0, '待上传资料', 'warning'],
['declaring', 'DECLARING', 'DECLARING', 1, '报关中', 'primary'],
['completed', 'COMPLETED', 'CUSTOMS_COMPLETED', 1, '报关完成', 'success'],
]
const { view, calls } = harness(t, states.map(([id]) => order(id)), async id => {
const [, status, merchantStatus, coveredTaxRefundCount] = states.find(row => row[0] === id)
return { ...detail(id), status, merchantStatus, coveredTaxRefundCount, taxRefundCount: 1 }
})
await view.load()
for (const [id, , merchantStatus, , label, tone] of states) {
const row = view.records.value.find(item => item.order.id === id)
assert.equal(view.statusLabel(row.detail), label)
assert.equal(view.statusType(row.detail), tone)
view.queryModel.value.status = merchantStatus; view.search()
assert.deepEqual(view.filteredRecords.value.map(item => item.order.id), [id])
}
assert.deepEqual(view.queryFields.find(field => field.key === 'status').options,
states.map(([, , value, , label]) => ({ value, label })).sort((a, b) =>
['WAITING_UPLOAD', 'DOCUMENT_UPLOADED', 'DECLARING', 'CUSTOMS_COMPLETED'].indexOf(a.value)
- ['WAITING_UPLOAD', 'DOCUMENT_UPLOADED', 'DECLARING', 'CUSTOMS_COMPLETED'].indexOf(b.value)))
view.queryModel.value.status = 'WAITING_CUSTOMER_DOCUMENTS'; view.search()
assert.equal(view.filteredRecords.value.length, 0, 'container statuses are not customer filter values')
view.queryModel.value.status = ''; view.search()
assert.equal(view.filteredRecords.value.length, 4)
assert.equal(calls.length, 4, 'filtering does not requery or infer status from coverage')
})
test('dialog update refreshes customer status and active filter without inferring from container status', async t => {
const { view } = harness(t, [order('one')], async id => ({ ...detail(id), status: 'WAITING_CUSTOMER_DOCUMENTS', merchantStatus: 'WAITING_UPLOAD' }))
await view.load(); view.openDetail(view.records.value[0])
view.queryModel.value.status = 'WAITING_UPLOAD'; view.search()
view.updateDetail({ ...detail('one'), status: 'WAITING_CUSTOMER_DOCUMENTS', merchantStatus: 'DOCUMENT_UPLOADED', coveredTaxRefundCount: 1, taxRefundCount: 1 })
assert.equal(view.filteredRecords.value.length, 0)
view.queryModel.value.status = 'DOCUMENT_UPLOADED'; view.search()
assert.equal(view.filteredRecords.value.length, 1)
assert.equal(view.statusLabel(view.filteredRecords.value[0].detail), '资料已上传')
assert.equal(view.statusLabel({ status: 'COMPLETED', coveredTaxRefundCount: 1, taxRefundCount: 1 }), '—')
assert.equal(view.statusType({ status: 'COMPLETED' }), 'info')
})
@@ -4,7 +4,7 @@ import { RefreshCcw } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import type { MerchantCustomsContainer } from '../api/customs'
import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '../api/customs'
import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader'
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
@@ -18,12 +18,7 @@ const queryFields: QueryField[] = [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
{ key: 'inboundCode', label: '入仓码', type: 'input', placeholder: '请输入入仓码' },
{ key: 'containerNo', label: '柜号', type: 'input', placeholder: '请输入柜号' },
{ key: 'status', label: '状态', type: 'select', placeholder: '全部', options: [
{ label: '待上传资料', value: 'WAITING_CUSTOMER_DOCUMENTS' },
{ label: '待报关', value: 'READY_TO_START' },
{ label: '报关中', value: 'DECLARING' },
{ label: '报关完成', value: 'COMPLETED' },
] },
{ key: 'status', label: '状态', type: 'select', placeholder: '全部', options: Object.entries(MERCHANT_CUSTOMS_STATUS_LABELS).map(([value, label]) => ({ label, value })) },
]
const columns: DataTableColumn[] = [
{ label: '操作', slotName: 'actions', width: 120, fixed: 'left' },
@@ -46,7 +41,7 @@ const filteredRecords = computed(() => {
matches(order.orderNo, 'orderNo')
&& matches(order.businessSummary.inboundCode, 'inboundCode')
&& (matches(detail.containerNo, 'containerNo') || matches(detail.cabinetNo, 'containerNo'))
&& (!query.status || detail.status === query.status),
&& (!query.status || detail.merchantStatus === query.status),
)
})
const pagedRecords = computed(() => filteredRecords.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
@@ -65,10 +60,11 @@ function rowActions(row: object): TableAction[] {
}
function statusLabel(detail: MerchantCustomsContainer) {
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
return detail.merchantStatus ? MERCHANT_CUSTOMS_STATUS_LABELS[detail.merchantStatus] ?? '—' : '—'
}
function statusType(detail: MerchantCustomsContainer) {
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
return detail.merchantStatus === 'CUSTOMS_COMPLETED' || detail.merchantStatus === 'DOCUMENT_UPLOADED'
? 'success' : detail.merchantStatus === 'DECLARING' ? 'primary' : detail.merchantStatus === 'WAITING_UPLOAD' ? 'warning' : 'info'
}
async function load() {
if (loading.value) return