From d19b1b2f8c0cea6eed3af0c670acfb76b3631a86 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 16:35:09 +0800 Subject: [PATCH 01/31] fix(client): keep customs dialog actions visible --- src/modules/first-leg/components/CustomsDocumentsDialog.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 996dd7f..5b05e43 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -383,6 +383,9 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index a862856..64932d0 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] { return { + transportFragments: [], customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false, totalPackageCount: summary?.totalPackageCount ?? null, actualGrossWeightKg: summary?.actualGrossWeightKg ?? null, @@ -357,7 +358,7 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P mapped.status = rawStatus === 'CANCELLED' || rawStatus === 'DRAFT' ? rawStatus : detail.summary.mainStatus mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode - mapped.businessSummary = businessSummary(detail.summary) + mapped.businessSummary = { ...businessSummary(detail.summary), transportFragments: detail.transportFragments } mapped.timeline = unifiedTimeline(detail) const summary = mapUnifiedOrderRow(detail.summary, warehouses) mapped.referenceNo = summary.referenceNo diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index 21bf849..7a96184 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -1,3 +1,4 @@ +import type { MerchantTransportFragment } from '../api/unifiedOrders' import type { FirstLegOrderStatus, FirstLegTransportMode, @@ -64,6 +65,7 @@ export interface FirstLegOrderTimelineItem { } export interface FirstLegOrderBusinessSummary { + transportFragments: MerchantTransportFragment[] customerFeeConfirmationRequired: boolean totalPackageCount: number | null actualGrossWeightKg: number | null diff --git a/src/modules/first-leg/utils/transportFacts.test.mjs b/src/modules/first-leg/utils/transportFacts.test.mjs new file mode 100644 index 0000000..9cb128a --- /dev/null +++ b/src/modules/first-leg/utils/transportFacts.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./transportFacts.ts', import.meta.url), 'utf8') +const javascript = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText +const exports = {} +runInNewContext(javascript, { exports }) +const rows = exports.customerTransportRows + +test('split inbound fragments retain separate cabinet facts and actual/estimated times', () => { + const result = rows([ + { inboundCode: 'NY-1', cabinetNo: 'D1', containerNo: 'TSTU0000001', nodes: [ + { standardNode: 'AT_ORIGIN_PORT', actualAt: '2026-10-05T09:00:00', estimatedAt: null, providerCode: 'MANUAL' }, + { standardNode: 'DEPARTED_ORIGIN_PORT', actualAt: null, estimatedAt: '2026-10-07T08:00:00', providerCode: 'MATSON' }, + ] }, + { inboundCode: 'NY-2', cabinetNo: 'D2', containerNo: 'TSTU0000002', nodes: [ + { standardNode: 'AT_ORIGIN_PORT', actualAt: '2026-10-06T10:00:00', estimatedAt: null, providerCode: 'MATSON' }, + ] }, + ]) + assert.equal(result.length, 3) + assert.equal(result[0].nodeLabel, '已进港') + assert.equal(result[0].source, '人工记录') + assert.equal(result[1].nodeLabel, '预计开航') + assert.equal(result[1].actualAt, null) + assert.equal(result[1].estimatedAt, '2026-10-07T08:00:00') + assert.equal(result[2].cabinetNo, 'D2') + assert.equal(result[2].inboundCode, 'NY-2') + assert.equal(result[2].actualAt, '2026-10-06T10:00:00') +}) + +test('empty actual scope creates no invented nodes or database identifier fallback', () => { + assert.equal(rows([]).length, 0) + assert.equal(rows([{ inboundCode: 'NY-1', nodes: [] }]).length, 0) + const [row] = rows([{ id: 'INTERNAL', nodes: [{ standardNode: 'AT_ORIGIN_PORT', actualAt: null, estimatedAt: '2026-10-05T09:00:00' }] }]) + assert.equal(row.cabinetNo, '—') + assert.equal(row.containerNo, '—') + assert.equal(row.inboundCode, '—') + assert.equal(row.actualAt, null) +}) + +test('detail consumes the existing merchant detail API and displays times separately', () => { + const store = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8') + const table = readFileSync(new URL('../components/TransportFactsTable.vue', import.meta.url), 'utf8') + assert.match(store, /transportFragments: detail\.transportFragments/) + assert.match(table, /label="实际时间".*time\(row\.actualAt\)/) + assert.match(table, /label="预计时间".*time\(row\.estimatedAt\)/) + assert.doesNotMatch(table, /platform\/|fetch\(|actualAt\s*\|\|\s*.*estimatedAt/) +}) diff --git a/src/modules/first-leg/utils/transportFacts.ts b/src/modules/first-leg/utils/transportFacts.ts new file mode 100644 index 0000000..df523ba --- /dev/null +++ b/src/modules/first-leg/utils/transportFacts.ts @@ -0,0 +1,24 @@ +import type { MerchantTransportFragment } from '../api/unifiedOrders' + +const labels: Readonly> = { + DEPARTED_WAREHOUSE: '已离仓', AT_ORIGIN_PORT: '已进港', LOADED_ON_VESSEL: '已装船', + DEPARTED_ORIGIN_PORT: '已开航', IN_TRANSIT: '海运途中', ARRIVED_DESTINATION_PORT: '船舶已到目的港', + DISCHARGED_FROM_VESSEL: '已卸船', PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜', +} +const estimatedLabels: Readonly> = { + DEPARTED_WAREHOUSE: '预计离仓', AT_ORIGIN_PORT: '预计进港', LOADED_ON_VESSEL: '预计装船', + DEPARTED_ORIGIN_PORT: '预计开航', IN_TRANSIT: '预计海运途中', ARRIVED_DESTINATION_PORT: '预计船舶到目的港', + DISCHARGED_FROM_VESSEL: '预计卸船', PICKUP_READY: '预计可提柜', PICKED_UP: '预计提柜', EMPTY_RETURNED: '预计还柜', +} + +export function customerTransportRows(fragments: MerchantTransportFragment[]) { + return fragments.flatMap((fragment, index) => fragment.nodes.map((node, nodeIndex) => ({ + key: `${index}-${nodeIndex}`, + inboundCode: fragment.inboundCode || '—', cabinetNo: fragment.cabinetNo || '—', + containerNo: fragment.containerNo || '—', + nodeLabel: (node.actualAt ? labels : estimatedLabels)[node.standardNode] || '运输节点', + actualAt: node.actualAt, estimatedAt: node.estimatedAt, + location: [node.locationCode, node.locationName].filter(Boolean).join(' · ') || '—', + source: node.providerCode === 'MANUAL' ? '人工记录' : node.providerCode === 'FIRSTLEG' ? '平台记录' : node.providerCode ? '承运轨迹' : '—', + }))) +} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 1f1e1cf..8fba950 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -12,6 +12,7 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' +import TransportFactsTable from '@/modules/first-leg/components/TransportFactsTable.vue' import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs' @@ -2455,6 +2456,7 @@ async function searchOrders(model: QueryModel) {

尚未分配柜次,后续柜次节点将在这里同步。

+ From 3703d0a6778fa245c31b7cdfa417f0070d0cad52 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 17:13:46 +0800 Subject: [PATCH 25/31] fix(auth): revoke captured client sessions safely on logout --- src/api/http.ts | 53 ++++++++++++-- src/api/session.test.mjs | 20 ++++++ src/api/session.ts | 11 ++- src/api/unifiedHttp.test.mjs | 79 ++++++++++++++++----- src/modules/auth/api/auth.ts | 6 +- src/modules/auth/store/auth.ts | 15 +++- src/modules/auth/store/authSession.test.mjs | 16 ++++- src/modules/auth/views/AuthViews.test.mjs | 3 +- 8 files changed, 175 insertions(+), 28 deletions(-) diff --git a/src/api/http.ts b/src/api/http.ts index 50b2e5b..9b9b4be 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -14,6 +14,7 @@ const transport = axios.create({ interface RetryableRequest extends InternalAxiosRequestConfig { _retriedAfterRefresh?: boolean _sessionIdentity?: string | null + _logoutGeneration?: number } function unwrap(envelope: ApiEnvelope) { @@ -52,6 +53,8 @@ function mapError(error: unknown) { return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } +let refreshExchange: { identity: string; refreshToken: string; promise: Promise } | null = null +let logoutGeneration = 0 let refreshPromise: Promise | null = null let refreshIdentity: string | null = null let redirectingToLogin = false @@ -95,8 +98,10 @@ export async function refreshAuthenticatedSession() { }, refresh: async (currentRefreshToken) => { requireCurrentIdentity(observedIdentity) - const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) - const tokens = unwrap(response.data) + const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) } + refreshExchange = exchange + let tokens: TokenPair + try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null } requireCurrentIdentity(observedIdentity) requireCurrentIdentity(observedIdentity, tokens.accessToken) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) @@ -134,29 +139,42 @@ transport.interceptors.request.use((config) => { const request = config as RetryableRequest if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token) if (token && !isPublicAuthRequest(config.url)) { - if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token) + if (!request._retriedAfterRefresh) { + request._sessionIdentity = accessTokenIdentity(token) + request._logoutGeneration = logoutGeneration + } config.headers.Authorization = `Bearer ${token}` } return config }) transport.interceptors.response.use( - (response) => response, + (response) => { + const request = response.config as RetryableRequest + if (request._sessionIdentity) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) + } + return response + }, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request._retriedAfterRefresh = true const refreshTokenBeforeRetry = getRefreshToken() try { const accessToken = await refreshAuthenticatedSession() + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) const mappedError = mapError(refreshError) if (mappedError.status === 401 || mappedError.code === 'A0401') { @@ -170,6 +188,33 @@ transport.interceptors.response.use( }, ) +// Captured revocation never persists credentials or restores local login state. +export async function revokeCapturedSession(accessToken: string, refreshToken: string) { + logoutGeneration++ + const identity = accessTokenIdentity(accessToken) + if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED') + const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null + const revoke = (pair: Pick) => apiRequest({ + method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken }, + headers: { Authorization: `Bearer ${pair.accessToken}` }, + }) + const validate = (pair: TokenPair) => { + if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED') + return pair + } + // An already sent refresh can rotate the pair. Consume its actual result + // without issuing a competing refresh or restoring it to local storage. + if (pending) { + await revoke(validate(await pending)) + return + } + try { await revoke({ accessToken, refreshToken }) } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) throw error + const pair = await apiRequest({ method: 'POST', url: '/auth/refresh', data: { refreshToken } }) + await revoke(validate(pair)) + } +} + export async function apiRequest(config: AxiosRequestConfig) { try { const response = await transport.request>(config) diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs index c2916d2..b305710 100644 --- a/src/api/session.test.mjs +++ b/src/api/session.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + accessTokenIdentity, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, REMEMBERED_LOGIN_ID_KEY, @@ -68,3 +69,22 @@ test('normal token rotation never removes either shared token between writes', ( assert.ok(!removed.includes(ACCESS_TOKEN_KEY)) assert.ok(!removed.includes(REFRESH_TOKEN_KEY)) }) + +function rawIdentity(overrides = '') { + return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test` +} +test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{ + const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString(); + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`) + assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`)) + } +}) +test('identity string and numeric integers are equivalent without changing other strings',()=>{ + const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString(); + const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test` + assert.equal(accessTokenIdentity(a),accessTokenIdentity(b)) + const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"') + assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated)) + assert.ok(accessTokenIdentity(a).includes('2107003446494334978')) +}) diff --git a/src/api/session.ts b/src/api/session.ts index cda260b..fb639ab 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -27,7 +27,16 @@ export function accessTokenIdentity(token: string): string | null { if (!encoded) return null const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/') const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0)) - const value = JSON.parse(new TextDecoder().decode(bytes)) + const json = new TextDecoder().decode(bytes) + // Match whole JSON strings first so digits inside arbitrary string values + // are never rewritten. Preserve integer identity claims before JSON.parse + // can round them; number/string representations then share one identity. + const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => { + if (!/^-?\d+$/.test(part)) return part + const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/) + return key ? JSON.stringify(part) : part + }) + const value = JSON.parse(exactIdentityJson) if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item))) } catch { diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs index ed66ff4..b051b7f 100644 --- a/src/api/unifiedHttp.test.mjs +++ b/src/api/unifiedHttp.test.mjs @@ -100,22 +100,6 @@ test('late refresh response cannot overwrite a session changed in another tab', function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } -test('logout after local clear sends only the original refresh token and never refreshes or clears a later login', async () => { - const sent = deferred(), release = deferred(), calls = [] - const http = harness(async config => { - calls.push(config); sent.resolve(); await release.promise; throw unauthorized(config) - }) - http.setSession('', '') - const api = compile('../modules/auth/api/auth.ts', { '@/api/http': http }) - const pending = api.logoutMerchant('old-refresh') - const rejected = assert.rejects(pending, error => error.code === 'A0401') - await sent.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected - assert.equal(calls.length, 1) - assert.equal(calls[0].url, '/auth/logout') - assert.equal(calls[0].headers.Authorization, undefined) - assert.deepEqual(JSON.parse(calls[0].data), { refreshToken: 'old-refresh' }) - assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' }) -}) function unauthorized(config) { return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) } @@ -173,3 +157,66 @@ test('refresh lock wait reuses same-identity rotation but rejects changed-identi assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' }) } }) + +function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) } +function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } } +test('logout after immediate local clear revokes captured Bearer and Refresh', async () => { + const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)}) + http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`) + assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => { + const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => { + for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})} +}) +test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected + assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('late successful business response after logout rejects even after same-identity new login',async()=>{ + const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')}) + const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise + await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected + assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving + assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)}) + const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected + assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('same identity new pair logout does not adopt another session inflight refresh',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh') + const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving + assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) + +test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{ + const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}' + const original=`test.${Buffer.from(raw).toString('base64url')}.test` + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[] + const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2) + } +}) diff --git a/src/modules/auth/api/auth.ts b/src/modules/auth/api/auth.ts index f462f01..7ed41de 100644 --- a/src/modules/auth/api/auth.ts +++ b/src/modules/auth/api/auth.ts @@ -1,4 +1,4 @@ -import { apiRequest } from '@/api/http' +import { apiRequest, revokeCapturedSession } from '@/api/http' import type { TokenPair } from '@/api/session' export interface RegisterMerchantRequest { @@ -35,8 +35,8 @@ export function registerMerchant(payload: RegisterMerchantRequest) { return apiRequest({ method: 'POST', url: '/auth/register', data: payload }) } -export function logoutMerchant(refreshToken: string) { - return apiRequest({ method: 'POST', url: '/auth/logout', data: { refreshToken } }) +export function logoutMerchant(accessToken: string, refreshToken: string) { + return revokeCapturedSession(accessToken, refreshToken) } export function changeCurrentPassword(oldPassword: string, newPassword: string) { diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 1997e8d..30f11c8 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -61,6 +61,7 @@ export const useAuthStore = defineStore('client-auth', () => { const permissionCodes = ref([]) const sessionRestored = ref(false) + let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise } | undefined let generation = 0 let contextRequest: { generation: number; promise: Promise } | undefined let restoreTimer: ReturnType | undefined @@ -199,10 +200,20 @@ export const useAuthStore = defineStore('client-auth', () => { await restoreSession() } - async function logout() { + function logout() { + const accessToken = getAccessToken() const refreshToken = getRefreshToken() + if (logoutRequest && ((!accessToken && !refreshToken) + || (logoutRequest.accessToken === accessToken && logoutRequest.refreshToken === refreshToken))) return logoutRequest.promise + const pending = accessToken || refreshToken + ? logoutMerchant(accessToken, refreshToken) + : Promise.resolve() clearSession() - if (refreshToken) await logoutMerchant(refreshToken) + const request = { accessToken, refreshToken, promise: pending.finally(() => { + if (logoutRequest === request) logoutRequest = undefined + }) } + logoutRequest = request + return request.promise } function hasPermission(permission?: string) { diff --git a/src/modules/auth/store/authSession.test.mjs b/src/modules/auth/store/authSession.test.mjs index 085a95f..0fcabb0 100644 --- a/src/modules/auth/store/authSession.test.mjs +++ b/src/modules/auth/store/authSession.test.mjs @@ -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 refresh => behavior.logout?.(refresh) }, + '@/modules/auth/api/auth': { logoutMerchant: async (access, refresh) => behavior.logout?.(refresh, access) }, '@/modules/auth/utils/jwt': compile('../utils/jwt.ts'), '@/api/http': { ApiError, refreshAuthenticatedSession: async () => { session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' }) @@ -152,3 +152,17 @@ test('store logout clears immediately and late revocation failure preserves a la assert.equal(h.auth.isAuthenticated, true) assert.equal(h.session.getRefreshToken(), 'refresh-b') }) + +test('duplicate logout joins captured pair revocation and does not send again',async t=>{ + const h=harness(t);const remote=deferred();let count=0,sent + h.behavior.logout=(refresh,access)=>{count++;sent={refresh,access};return remote.promise} + const a=h.auth.logout(),b=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(sent.access,token());assert.equal(sent.refresh,'refresh-a');assert.equal(count,1);remote.resolve();await Promise.all([a,b]) +}) + +test('B logout during A pending revokes B; A finally cannot release B deduplication',async t=>{ + const h=harness(t);const a=deferred(),b=deferred(),calls=[] + h.behavior.logout=(refresh,access)=>{calls.push({refresh,access});return calls.length===1?a.promise:b.promise} + const leavingA=h.auth.logout();h.rotate(token(2,2,2));await flush();assert.equal(h.auth.isAuthenticated,true) + const leavingB=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(h.session.getAccessToken(),'');assert.equal(calls.length,2);assert.equal(calls[1].access,token(2,2,2)) + a.resolve();await leavingA;const duplicateB=h.auth.logout();assert.equal(calls.length,2);let duplicateFinished=false;void duplicateB.then(()=>{duplicateFinished=true});await flush();assert.equal(duplicateFinished,false);b.resolve();await Promise.all([leavingB,duplicateB]) +}) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 0b2fc45..dd487a6 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -102,7 +102,8 @@ test('opens complete service and privacy documents from registration', () => { test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(authApiSource, /\/auth\/login/) assert.match(authApiSource, /\/auth\/register/) - assert.match(authApiSource, /\/auth\/logout/) + assert.match(authApiSource, /revokeCapturedSession\(accessToken, refreshToken\)/) + assert.match(httpSource, /\/auth\/logout/) assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /persistTokenPair\(tokens\)/) From b3f78207b8ee2a533c3ed3f43db16a4b923a87c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 18:13:02 +0800 Subject: [PATCH 26/31] fix(client): clarify cabinet customs totals --- src/modules/first-leg/views/CustomsDocumentsView.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/first-leg/views/CustomsDocumentsView.vue b/src/modules/first-leg/views/CustomsDocumentsView.vue index 6f7a0b9..69e5368 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.vue +++ b/src/modules/first-leg/views/CustomsDocumentsView.vue @@ -25,8 +25,8 @@ const columns: DataTableColumn[] = [ { label: '订单号', slotName: 'orderNo', minWidth: 230 }, { label: '入仓码', slotName: 'inboundCode', minWidth: 190 }, { label: '柜号', slotName: 'containerNo', minWidth: 170 }, - { label: '实际箱数', prop: 'detail.actualBoxCount', width: 110, align: 'center' }, - { label: '资料覆盖', slotName: 'coverage', width: 110, align: 'center' }, + { label: '柜内退税箱数', prop: 'detail.actualBoxCount', width: 110, align: 'center' }, + { label: '柜内资料覆盖', slotName: 'coverage', width: 110, align: 'center' }, { label: '状态', slotName: 'status', width: 130 }, ] const records = ref([]) @@ -94,7 +94,7 @@ onMounted(() => { void load() })
- + From b7fcc14e59fe2f063c2235ff72d697788666b8a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 11:09:42 +0800 Subject: [PATCH 27/31] fix(deploy): support isolated temporary production release --- Jenkinsfile | 32 ++++++++++++++-- docker-compose.production.yml | 22 +++++++++++ scripts/deploy-production.sh | 58 +++++++++++++++++++++++++++++ scripts/pull-production-image.sh | 9 +++++ src/deployment/nginxConfig.test.mjs | 19 +++++++--- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 docker-compose.production.yml create mode 100644 scripts/deploy-production.sh create mode 100644 scripts/pull-production-image.sh diff --git a/Jenkinsfile b/Jenkinsfile index 877891f..19da01a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,15 +1,16 @@ pipeline { - agent { label 'java-test' } + agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" } options { timestamps() disableConcurrentBuilds() } stages { stage('检查测试发布参数') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { script { - if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) { - error('当前仅支持 test,请使用测试环境 Job') + if (env.JOB_BASE_NAME?.endsWith('-dev')) { + error('此流水线仅支持 test 和本轮授权的 prod 灰度') } env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } @@ -23,6 +24,7 @@ pipeline { } } stage('构建并部署测试前端') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu docker compose -f docker-compose.test.yml build --pull=false @@ -31,8 +33,30 @@ pipeline { ''' } } + stage('发布临时生产灰度') { + when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } } + steps { + lock(resource: 'firstleg-production-client-web-deploy') { + withCredentials([ + usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + sh 'sh scripts/deploy-production.sh' + } + } + } + } } post { - failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } + failure { + script { + if (!env.JOB_BASE_NAME?.endsWith('-prod')) { + sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' + } + } + } } } diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..e7f339d --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,22 @@ +name: firstleg-prod-client-web +services: + client-web: + image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}" + container_name: firstleg-prod-client-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-29012}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s + networks: + - backend +networks: + backend: + external: true + name: firstleg-production-network diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..ebcb234 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu +set +x +: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" +: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}" +: "${TARGET_SSH_KEY:?}" +: "${TARGET_SSH_USER:?}" +: "${TARGET_SSH_HOST:?}" +: "${TARGET_SSH_KNOWN_HOSTS:?}" +: "${BUILD_REGISTRY_USER:?}" +: "${BUILD_REGISTRY_PASSWORD:?}" +: "${REGISTRY_USER:?}" +: "${REGISTRY_PASSWORD:?}" +case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac +# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters. +printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$' +printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' +git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +IMAGE_TAG=$(git rev-parse --short=12 HEAD) +registry=${PRODUCTION_IMAGE_REPOSITORY%%/*} +image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG" +TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST" +remote_dir=/data/firstleg/deploy/gray-client-web +run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@" +} +run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@" +} +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" +docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" . +docker push "$image" +run_ssh install -d -m 750 "$remote_dir" +run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml" +# The remote env file contains only image coordinates and public web origin, never credentials. +printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \ + "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'" +# Registry credentials exist only in an isolated temporary Docker config, removed after pull. +printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$' +run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image" +run_ssh sh -s -- "$remote_dir" <<'REMOTE' +set -eu +cd "$1" +docker network inspect firstleg-production-network >/dev/null +docker compose --env-file release.env -f compose.yml config --quiet +docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120 +docker compose --env-file release.env -f compose.yml exec -T client-web wget -qO- http://127.0.0.1/healthz +REMOTE diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh new file mode 100644 index 0000000..91562c3 --- /dev/null +++ b/scripts/pull-production-image.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +set +x +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +docker login -u "$1" --password-stdin "$2" +docker pull "$3" diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 67bee6b..83548bf 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -26,13 +26,12 @@ test('test deployment requires an explicit shared network, Gateway and version', assert.match(compose, /external: true/) assert.match(compose, /127\.0\.0\.1:/) assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) - assert.match(jenkins, /label 'java-test'/) + assert.match(jenkins, /'java-builder' : 'java-test'/) assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) assert.match(jenkins, /docker network inspect/) - assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) - for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { - assert.equal(existsSync(new URL(file, root)), false) - } + assert.match(jenkins, /scripts\/deploy-production\.sh/) + assert.match(jenkins, /firstleg-production-ssh/) + }) test('application shell is revalidated and missing chunks remain 404', () => { @@ -52,3 +51,13 @@ test('container build excludes local environment values and waits for Nginx heal assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) assert.match(jenkins, /up -d --wait --wait-timeout 120/) }) + +test('gray production is isolated from TEST and routes to the single Gateway', () => { + const production = readFileSync(new URL('docker-compose.production.yml', root), 'utf8') + assert.match(production, /name: firstleg-production-network/) + assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(production, /127\.0\.0\.1:/) + assert.match(production, /healthcheck:/) + assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/) + assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/) +}) From a853b1e0c238932cee83448bde8a96ce6730147f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 16:46:58 +0800 Subject: [PATCH 28/31] feat(orders): show actual pooled freight fulfillment --- .../cross-border/api/businessOrders.ts | 1 + .../cross-border/businessOrders.test.mjs | 9 +++++ .../components/BusinessOrdersView.vue | 33 ++++++++++++++++--- src/modules/cross-border/fleetArrangement.ts | 28 ++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/modules/cross-border/fleetArrangement.ts diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 3f1a894..39c3905 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -75,6 +75,7 @@ export interface ProcurementCreate { } export interface TransportSummary { + fleetArrangement: import('../fleetArrangement').FleetArrangement | null orderNo: string transportType: string transportMode?: string diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 548c760..3a36ecb 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -6,6 +6,15 @@ const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'u const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +test('shows merchant-owned arrangement facts without a pool request or editable status control', () => { + assert.match(api, /fleetArrangement: import\('\.\.\/fleetArrangement'\)\.FleetArrangement \| null/) + assert.match(view, /aria-label="承运安排"/) + for (const field of ['signedPackages', 'unsignedPackages', 'returnedPackages', 'readOnlyReason']) assert.ok(view.includes(`fleetArrangement.${field}`)) + assert.doesNotMatch(api, /capacity-offers|fleet\/trips/) + assert.match(view, /if \(query\.page !== 1\) query\.page = 1\s+else void load\(\)/) + assert.match(view, /if \(reloadDirectly\) void load\(\)/) +}) + test('activates all three customer cross-border pages with frozen permissions', () => { for (const [path, permission] of [ ['cross-border/procurement', 'merchant:cross-border:procurement:list'], diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index e2404e0..60703d0 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -18,6 +18,7 @@ import { type TransportSummary, } from '../api/businessOrders' import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications' +import { poolOrderStatusLabels } from '../fleetArrangement' const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false }) const auth = useAuthStore() @@ -105,6 +106,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] { { key: 'budgetAmount', label: '预算总价', width: 110 }, { key: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 130 }, + { key: 'fleetArrangement', label: '承运安排', width: 190 }, ] } @@ -115,6 +117,7 @@ const submitKey = ref('') const error = ref('') const rows = ref([]) const detail = ref(null) +const fleetArrangement = computed(() => detail.value && props.kind !== 'procurement' ? (detail.value as TransportDetail).summary.fleetArrangement : null) const formRef = ref() const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) const total = ref(0) @@ -157,6 +160,7 @@ const queryFields = computed(() => [ { label: '部分出库', value: 'PARTIALLY_OUTBOUND' }, { label: '全部出库', value: 'OUTBOUNDED' }, { label: '待接单', value: 'PENDING_ACCEPTANCE' }, + ...(props.kind === 'procurement' ? [] : Object.entries(poolOrderStatusLabels).filter(([value]) => !['IN_TRANSIT', 'COMPLETED', 'CANCELLED'].includes(value)).map(([value, label]) => ({ value, label }))), { label: '处理中', value: 'PROCESSING' }, { label: '运输中', value: 'IN_TRANSIT' }, { label: '已完成', value: 'COMPLETED' }, @@ -215,7 +219,7 @@ const tableColumns = computed(() => [ ...configuration.value.columns.map((column) => ({ ...column, prop: column.key, - slotName: ['orderNo', 'status'].includes(column.key) ? column.key : undefined, + slotName: ['orderNo', 'status', 'fleetArrangement'].includes(column.key) ? column.key : undefined, formatter: (row: object) => displayCell(row, column.key), })), ]) @@ -242,6 +246,7 @@ function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record)[String(value)] || text(value) } function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record)[String(value)] || text(value) } function statusLabel(value: unknown) { + if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)] return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record)[String(value)] || text(value) } function statusTone(value: unknown) { @@ -283,13 +288,14 @@ async function load() { } function resetQuery() { + const reloadDirectly = query.page === 1 Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' }) - void load() + if (reloadDirectly) void load() } function searchOrders() { - query.page = 1 - void load() + if (query.page !== 1) query.page = 1 + else void load() } function getRowActions(candidate: object): TableAction[] { @@ -397,6 +403,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat + @@ -413,6 +420,24 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
{{ field[0] }}
{{ text(field[1]) }}
+
+

承运安排

{{ fleetArrangement.readOnlyReason }}

+
+
预订号
{{ fleetArrangement.bookingNo }}
+
计划发车
{{ dateTime(fleetArrangement.plannedDepartureAt) }}
+
起运仓
{{ text(fleetArrangement.originWarehouseName) }}
+
目的地
{{ text(fleetArrangement.destinationLabel) }}
+
交货方式
{{ fleetArrangement.deliveryMode === 'SELF_DELIVERY' ? '自行送至起运仓' : '线下安排提货至起运仓' }}
+
履约进度
{{ poolOrderStatusLabels[fleetArrangement.fulfillmentStage] || '待核对履约进度' }}
+
更新时间
{{ dateTime(fleetArrangement.stageOccurredAt) }}
+
约定运费(非实收)
{{ money(fleetArrangement.agreedFreightAmount, fleetArrangement.currency) }}
+
来货单号
{{ text(fleetArrangement.incomingNo) }}
+
派送单号
{{ text(fleetArrangement.deliveryNo) }}
+
已签 / 未签 / 退回箱数
{{ fleetArrangement.signedPackages ?? '—' }} / {{ fleetArrangement.unsignedPackages ?? '—' }} / {{ fleetArrangement.returnedPackages ?? '—' }}
+
交货说明
{{ text(fleetArrangement.deliveryInstructions) }}
+
履约说明
{{ fleetArrangement.exceptionSummary }}
+
+
diff --git a/src/modules/cross-border/fleetArrangement.ts b/src/modules/cross-border/fleetArrangement.ts new file mode 100644 index 0000000..4ad6d1a --- /dev/null +++ b/src/modules/cross-border/fleetArrangement.ts @@ -0,0 +1,28 @@ +export interface FleetArrangement { + sourceType: 'FLEET_CAPACITY_OFFER' + businessType: 'LOCAL_LOOSEBOARD' | 'US_TRUCK' + businessOrderNo: string + bookingNo: string + tripNo: string + plannedDepartureAt: string | null + originWarehouseName: string + destinationLabel: string + deliveryMode: 'SELF_DELIVERY' | 'MANUAL_PICKUP_TO_WAREHOUSE' + deliveryInstructions: string | null + fulfillmentStage: string + stageOccurredAt: string | null + agreedFreightAmount: number | null + currency: string | null + incomingNo: string | null + dispatchNo: string | null + deliveryNo: string | null + signedPackages: number | null + unsignedPackages: number | null + returnedPackages: number | null + exceptionSummary: string | null + readOnlyReason: string +} +export const poolOrderStatusLabels: Record = { + WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', + PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', +} From 47385d46f20b816d0948c80d534261723e895209 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 19:44:44 +0800 Subject: [PATCH 29/31] fix(orders): retain nullable palletizing facts in customer summaries --- src/modules/cross-border/api/businessOrders.ts | 2 +- .../cross-border/businessOrders.test.mjs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 39c3905..5bb7ee3 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -79,7 +79,7 @@ export interface TransportSummary { orderNo: string transportType: string transportMode?: string - palletRequired?: string + palletRequired?: string | null origin: string destination: string containerType: string | null diff --git a/src/modules/cross-border/businessOrders.test.mjs b/src/modules/cross-border/businessOrders.test.mjs index 3a36ecb..01d11f0 100644 --- a/src/modules/cross-border/businessOrders.test.mjs +++ b/src/modules/cross-border/businessOrders.test.mjs @@ -1,11 +1,29 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { runInNewContext } from 'node:vm' +import ts from 'typescript' const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8') const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8') +test('renders unknown pallet service and container type as dashes while preserving explicit values', () => { + const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey(')) + const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const { displayCell, yesNo, text } = runInNewContext(`${compiled}\n({ displayCell, yesNo, text })`) + assert.equal(displayCell({ palletRequired: null }, 'palletRequired'), '—') + assert.equal(displayCell({ palletRequired: 'NO' }, 'palletRequired'), '否') + assert.equal(displayCell({ palletRequired: 'YES' }, 'palletRequired'), '是') + assert.equal(displayCell({ containerType: null }, 'containerType'), '—') + assert.equal(displayCell({ containerType: '40HQ' }, 'containerType'), '40HQ') + assert.equal(yesNo(null), '—') + assert.equal(text(null), '—') + assert.match(api, /palletRequired\?: string \| null/) + const create = api.slice(api.indexOf('export interface TransportCreate'), api.indexOf('export interface TransportUpdate')) + assert.doesNotMatch(create, /palletRequired\?: string \| null/) +}) + test('shows merchant-owned arrangement facts without a pool request or editable status control', () => { assert.match(api, /fleetArrangement: import\('\.\.\/fleetArrangement'\)\.FleetArrangement \| null/) assert.match(view, /aria-label="承运安排"/) From 19f060727b953864d88c02af4a0097b2f15b9e10 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 20:43:30 +0800 Subject: [PATCH 30/31] fix(client): localize transport mode in order detail --- src/modules/cross-border/components/BusinessOrdersView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 60703d0..25a4427 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -202,7 +202,7 @@ const detailFields = computed(() => { } const current = detail.value as TransportDetail return [ - ['运输方式', read(summary, 'transportMode')], + ['运输方式', transportModeLabel(read(summary, 'transportMode'))], ['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))], ['起运地', read(summary, 'origin')], ['起运地址', current.originAddress], ['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress], From 7759c7e9f30b4d11fb8f075938d61293741fa9b1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 20:59:34 +0800 Subject: [PATCH 31/31] fix(client): prevent arrangement labels overlapping values --- src/modules/cross-border/components/BusinessOrdersView.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index 25a4427..6ddbf6e 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -422,7 +422,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat

承运安排

{{ fleetArrangement.readOnlyReason }}

-
+
预订号
{{ fleetArrangement.bookingNo }}
计划发车
{{ dateTime(fleetArrangement.plannedDepartureAt) }}
起运仓
{{ text(fleetArrangement.originWarehouseName) }}
@@ -497,6 +497,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat .detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); } .detail-grid div.is-wide { grid-column: span 2; } .detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; } +.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); } +.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; } .detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; } .form-card :deep(.el-form) { max-width: 860px; } .form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }