diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index 4c66e7f..dde7db9 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -1,5 +1,5 @@ @@ -54,6 +58,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`) v-for="child in node.children" :key="child.key" :node="child" + :badges="badges" :active-path="activePath" :opened-keys="openedKeys" :active-parent-keys="activeParentKeys" @@ -78,6 +83,14 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`) aria-hidden="true" /> {{ node.title }} + + {{ badge?.error ? '!' : badge!.count! > 99 ? '99+' : badge?.count }} + + + diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index 6b2d957..afe413a 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -283,3 +283,11 @@ export function merchantCustomsErrorMessage(error: unknown, fallback: string) { export function isMerchantCustomsUnavailable(error: unknown) { return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') } + + +export async function getMerchantCustomsPendingCount() { + const result = await apiRequest<{ pendingCount: string | number }>({ method: 'GET', url: '/merchant/v1/customs/pending-summary' }) + const count = Number(result.pendingCount) + if (result.pendingCount == null || result.pendingCount === '' || !Number.isSafeInteger(count) || count < 0) throw new Error('报关待办数量返回不完整') + return count +} diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs index 9c4860d..ddac9f8 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.test.mjs @@ -10,7 +10,7 @@ function harness(t) { const code = ts.transpileModule(source + '\nexport { loadDetail, detail, clearanceResults, loading, errorMessage, selectedInboundIds, clearanceError, clearanceLoading, retryClearanceResults, close }', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const props = vue.reactive({ modelValue: false, orderId: 'A', canEdit: true }), requests = [], clearances = [], updates = [], view = {} const unmounts = [] - const dependencies = { vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': { + const dependencies = { '../store/customsPending': { useCustomsPendingStore: () => ({ refresh: async () => {} }) }, vue: { ...vue, onBeforeUnmount: fn => unmounts.push(fn) }, 'element-plus': { ElMessage: {} }, '@/modules/first-leg/api/customs': { getMerchantCustomsForOrder: id => { const pending = deferred(); requests.push({ id, ...pending }); return pending.promise }, getMerchantClearanceResults: id => { const pending = deferred(); clearances.push({ id, ...pending }); return pending.promise }, merchantCustomsErrorMessage: error => error.message, diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 2f3ae9b..0e101dc 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -22,6 +22,9 @@ import { type MerchantClearanceResult, } from '@/modules/first-leg/api/customs' +import { useCustomsPendingStore } from '../store/customsPending' + +const customsPending = useCustomsPendingStore() const props = defineProps<{ modelValue: boolean orderId: string @@ -104,6 +107,7 @@ async function retryClearanceResults() { async function loadDetail() { if (disposed || !props.modelValue || !props.orderId) return + void customsPending.refresh(true) const request = ++detailRequest resetClearanceResults() const orderId = props.orderId diff --git a/src/modules/first-leg/public.ts b/src/modules/first-leg/public.ts index e0350de..4fa94e7 100644 --- a/src/modules/first-leg/public.ts +++ b/src/modules/first-leg/public.ts @@ -5,3 +5,4 @@ export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg' export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder' export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue' export { default as FirstLegOrdersView } from './views/FirstLegOrdersView.vue' +export { useCustomsPendingStore } from './store/customsPending' diff --git a/src/modules/first-leg/store/customsPending.test.mjs b/src/modules/first-leg/store/customsPending.test.mjs new file mode 100644 index 0000000..5e5862f --- /dev/null +++ b/src/modules/first-leg/store/customsPending.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import * as pinia from 'pinia' +const source = readFileSync(new URL('./customsPending.ts', import.meta.url), 'utf8') +const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText +function deferred() { let resolve, reject; const promise = new Promise((a,b) => { resolve=a; reject=b }); return { promise, resolve, reject } } +function harness(t, read) { + const context=pinia.createPinia();pinia.setActivePinia(context);t.after(()=>pinia.disposePinia(context)) + const deps={vue,pinia,'../api/customs':{getMerchantCustomsPendingCount:read}}, exports={} + new Function('require','exports',code)(id=>{assert.ok(id in deps,id);return deps[id]},exports) + return exports.useCustomsPendingStore() +} +test('does not request without a permitted identity; preserves counts while refreshing and accepts zero',async t=>{ + const pending=deferred();let calls=0 + const store=harness(t,()=>++calls===1?Promise.resolve(2):pending.promise) + await store.refresh();assert.equal(calls,0) + store.setScope('customer-a');await store.refresh();assert.equal(store.count,2) + const load=store.refresh();await store.refresh();assert.equal(calls,2);assert.equal(store.count,2) + pending.resolve(0);await load;assert.equal(store.count,0);assert.equal(store.loading,false) +}) +test('logout, permission removal and account switches discard old in-flight responses',async t=>{ + const pending=deferred();const store=harness(t,()=>pending.promise) + store.setScope('a');const load=store.refresh();store.setScope('') + pending.resolve(9);await load;assert.equal(store.count,null);assert.equal(store.error,'') + store.setScope('b');assert.equal(store.count,null) +}) +test('successful mutation can force a newer refresh without being overwritten by an older query',async t=>{ + const a=deferred(),b=deferred();let calls=0;const store=harness(t,()=>++calls===1?a.promise:b.promise) + store.setScope('a');const first=store.refresh();const second=store.refresh(true) + b.resolve(1);await second;a.resolve(2);await first;assert.equal(store.count,1) +}) +test('failure is distinguishable from zero and retry restores authoritative count',async t=>{ + let fail=true;const store=harness(t,()=>fail?Promise.reject(new Error('offline')):Promise.resolve(3)) + store.setScope('a');await store.refresh();assert.equal(store.count,null);assert.notEqual(store.error,'') + fail=false;await store.refresh();assert.equal(store.count,3);assert.equal(store.error,'') +}) diff --git a/src/modules/first-leg/store/customsPending.ts b/src/modules/first-leg/store/customsPending.ts new file mode 100644 index 0000000..940b9eb --- /dev/null +++ b/src/modules/first-leg/store/customsPending.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { getMerchantCustomsPendingCount } from '../api/customs' + +export const useCustomsPendingStore = defineStore('client-customs-pending', () => { + const count = ref(null) + const loading = ref(false) + const error = ref('') + let scope = '' + let requestVersion = 0 + + function setScope(value: string) { + if (scope === value) return + scope = value + requestVersion++ + count.value = null + error.value = '' + loading.value = false + } + + async function refresh(force = false) { + if (!scope || (loading.value && !force)) return + const request = ++requestVersion + loading.value = true + try { + const result = await getMerchantCustomsPendingCount() + if (request !== requestVersion) return + count.value = result + error.value = '' + } catch { + if (request !== requestVersion) return + count.value = null + error.value = '报关待办数量加载失败,请进入报关资料刷新' + } finally { + if (request === requestVersion) loading.value = false + } + } + + return { count, loading, error, setScope, refresh } +}) diff --git a/src/modules/first-leg/views/CustomsDocumentsView.test.mjs b/src/modules/first-leg/views/CustomsDocumentsView.test.mjs index 3390943..2ea9f67 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.test.mjs +++ b/src/modules/first-leg/views/CustomsDocumentsView.test.mjs @@ -46,6 +46,7 @@ function harness(t, orders, getDetail = async id => detail(id)) { vue: { ...vue, onMounted() {} }, '../utils/customsDocumentsReader': reader, '../api/customs': customsApi, + '../store/customsPending': { useCustomsPendingStore: () => ({ refresh: async () => {} }) }, })) t.after(() => scope.stop()) return { view, store, calls, pageCalls } diff --git a/src/modules/first-leg/views/CustomsDocumentsView.vue b/src/modules/first-leg/views/CustomsDocumentsView.vue index 69e5368..1542db8 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.vue +++ b/src/modules/first-leg/views/CustomsDocumentsView.vue @@ -8,6 +8,9 @@ import { MERCHANT_CUSTOMS_STATUS_LABELS, type MerchantCustomsContainer } from '. import { readCustomsDocuments, type CustomsDocumentRow } from '../utils/customsDocumentsReader' import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue' +import { useCustomsPendingStore } from '../store/customsPending' + +const customsPending = useCustomsPendingStore() const loading = ref(false) const errorMessage = ref('') const queryModel = ref({ orderNo: '', inboundCode: '', containerNo: '', status: '' }) @@ -68,6 +71,7 @@ function statusType(detail: MerchantCustomsContainer) { } async function load() { if (loading.value) return + void customsPending.refresh(true) loading.value = true; errorMessage.value = '' try { const { records: loadedRecords, failedCount } = await readCustomsDocuments()