diff --git a/src/api/crossTabRefresh.test.mjs b/src/api/crossTabRefresh.test.mjs new file mode 100644 index 0000000..44ddd28 --- /dev/null +++ b/src/api/crossTabRefresh.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { coordinateCrossTabRefresh } from './crossTabRefresh.ts' + +test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => { + let currentRefreshToken = 'refresh-old' + let refreshCalls = 0 + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + async request(_name, callback) { + currentRefreshToken = 'refresh-new' + return callback() + }, + }, + }, + }) + + try { + const result = await coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => currentRefreshToken, + reuseCurrentSession: async () => 'access-new', + refresh: async () => { + refreshCalls += 1 + return 'unexpected' + }, + }) + + assert.equal(result, 'access-new') + assert.equal(refreshCalls, 0) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) + +test('refreshes normally when this tab owns the current refresh token', async () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { locks: { request: async (_name, callback) => callback() } }, + }) + + try { + const result = await coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: async () => 'unexpected', + refresh: async (refreshToken) => `access-for-${refreshToken}`, + }) + + assert.equal(result, 'access-for-refresh-old') + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) + +test('does not wait for rotation after the shared session was removed', async () => { + const originalNavigator = globalThis.navigator + const refreshError = new Error('session removed') + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {} }) + + try { + await assert.rejects(coordinateCrossTabRefresh({ + lockName: 'client-refresh', + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => '', + reuseCurrentSession: async () => 'unexpected', + refresh: async () => { throw refreshError }, + }), refreshError) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) diff --git a/src/api/crossTabRefresh.ts b/src/api/crossTabRefresh.ts new file mode 100644 index 0000000..6f429ec --- /dev/null +++ b/src/api/crossTabRefresh.ts @@ -0,0 +1,37 @@ +export interface CrossTabRefreshOptions { + lockName: string + observedRefreshToken: string + currentRefreshToken: () => string + reuseCurrentSession: () => Promise + refresh: (refreshToken: string) => Promise +} + +export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { + const run = async () => { + const currentRefreshToken = options.currentRefreshToken() + if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) { + return options.reuseCurrentSession() + } + return options.refresh(currentRefreshToken || options.observedRefreshToken) + } + + if (typeof navigator === 'undefined' || !navigator.locks) { + try { + return await run() + } catch (error) { + if (!options.currentRefreshToken()) throw error + // Web Locks requires a secure context. On plain HTTP, briefly allow another tab's + // successful rotation to reach shared storage before treating this session as expired. + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 50)) + const currentRefreshToken = options.currentRefreshToken() + if (!currentRefreshToken) throw error + if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) { + return options.reuseCurrentSession() + } + } + throw error + } + } + return navigator.locks.request(options.lockName, run) +} diff --git a/src/api/http.ts b/src/api/http.ts index bfaedc4..977ead7 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -3,6 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', @@ -42,7 +43,7 @@ function mapError(error: unknown) { return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } -let refreshPromise: Promise | null = null +let refreshPromise: Promise | null = null let redirectingToLogin = false function redirectToLoginAfterSessionExpiry() { @@ -57,16 +58,26 @@ function redirectToLoginAfterSessionExpiry() { } async function refreshSession() { - const refreshToken = getRefreshToken() - if (!refreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) + const observedRefreshToken = getRefreshToken() + if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) if (!refreshPromise) { - refreshPromise = transport - .post>('/auth/refresh', { refreshToken }) - .then((response) => unwrap(response.data)) - .then((tokens) => { + refreshPromise = coordinateCrossTabRefresh({ + lockName: 'cc-client-auth-refresh', + observedRefreshToken, + currentRefreshToken: getRefreshToken, + reuseCurrentSession: async () => { + const currentAccessToken = getAccessToken() + if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401) + return currentAccessToken + }, + refresh: async (currentRefreshToken) => { + const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) + const tokens = unwrap(response.data) + if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) persistTokenPair(tokens) - return tokens - }) + return tokens.accessToken + }, + }) .finally(() => { refreshPromise = null }) @@ -92,8 +103,8 @@ transport.interceptors.response.use( request._retriedAfterRefresh = true try { - const tokens = await refreshSession() - request.headers.Authorization = `Bearer ${tokens.accessToken}` + const accessToken = await refreshSession() + request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { const mappedError = mapError(refreshError) diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 9d3b214..51760be 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -100,6 +100,25 @@ export const useAuthStore = defineStore('client-auth', () => { sessionRestored.value = true } + function applyStoredSession() { + const storedToken = getAccessToken() + if (!storedToken) { + token.value = '' + payload.value = null + user.value = null + permissionCodes.value = [] + profile.value = { ...emptyProfile } + sessionRestored.value = true + return + } + try { + applyToken(storedToken) + sessionRestored.value = false + } catch { + clearSession() + } + } + if (token.value) { try { applyToken(token.value) @@ -201,6 +220,9 @@ export const useAuthStore = defineStore('client-auth', () => { } window.addEventListener('cc-client-session-expired', clearSession) + window.addEventListener('storage', (event) => { + if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession() + }) return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, updateProfile, savePersonalProfile } }) diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index b4ca866..e5a99fb 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -6,6 +6,8 @@ export type ApiPickupMethod = 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK' export type ApiTaxMode = 'BUY_DECLARATION' | 'TAX_REFUND' export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL' export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE' +export type ApiPricingTaxMode = 'TAX_INCLUDED' | 'TAX_EXCLUDED' +export type ApiPriceModel = 'FBA_WAREHOUSE_RATE' | 'REGION_WEIGHT_TIER' export interface PageResult { records: T[] @@ -114,6 +116,7 @@ export interface FirstLegOrderSaveRequest { receivingTime?: string | null channelCode: string channelName: string + pricingTaxMode: ApiPricingTaxMode taxMode: ApiTaxMode containerMode?: ApiContainerMode customsInspection: boolean @@ -143,6 +146,7 @@ export interface MerchantFirstLegOrder { receivingTime?: string | null channelCode: string channelName: string + pricingTaxMode?: ApiPricingTaxMode | null carrierId?: number | string | null carrierName?: string | null taxMode: ApiTaxMode @@ -200,6 +204,45 @@ export interface FirstLegOrderQuery { size?: number } +export interface MerchantPricingChannel { + priceBookVersionId: number | string + channelCode: string + channelName: string + priceModel: ApiPriceModel + pricingTaxModes: ApiPricingTaxMode[] + estimatedDepartureDate: string | null + estimatedDepartureText: string | null + latestWarehouseEntry: string | null + referenceTransit: string | null + surchargeDescription: string | null + remark: string | null +} + +export interface MerchantPriceEstimateRequest { + originWarehouseId: number | string + channelCode: string + destinationCode?: string + destinationType: Exclude + destinationWarehouseId?: number | string + destinationName?: string + pricingTaxMode: ApiPricingTaxMode + actualWeightKg: number + actualVolumeCbm: number +} + +export interface MerchantPriceEstimate { + priceBookVersionId: number | string + channelCode: string + priceModel: ApiPriceModel + pricingTaxMode: ApiPricingTaxMode + chargeableQuantity: number + chargeUnit: string + unitPrice: number + estimatedAmount: number + currency: string + disclaimer: string +} + export function getMerchantWarehouses(params: MerchantWarehouseQuery = {}) { return apiRequest>({ method: 'GET', @@ -212,6 +255,22 @@ export function getMerchantWarehouseDetail(id: number | string) { return apiRequest({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }) } +export function getMerchantPricingChannels(originWarehouseId: number | string) { + return apiRequest({ + method: 'GET', + url: '/merchant/v1/first-leg/pricing/channels', + params: { originWarehouseId }, + }) +} + +export function estimateMerchantFirstLegPrice(payload: MerchantPriceEstimateRequest) { + return apiRequest({ + method: 'POST', + url: '/merchant/v1/first-leg/pricing/estimate', + data: payload, + }) +} + export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) { return apiRequest>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } }) } diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 9c315c8..1cdb121 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -12,6 +12,9 @@ test('declares the documented merchant warehouse and first-leg endpoints', () => assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/) assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/) + assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/channels'/) + assert.match(apiSource, /params: \{ originWarehouseId \}/) + assert.match(apiSource, /url: '\/merchant\/v1\/first-leg\/pricing\/estimate'/) }) test('maps transport and status fields instead of treating container mode as transport mode', () => { @@ -75,8 +78,8 @@ test('maps C02 rows by orderId and leaves absent transport details unknown', () assert.match(storeSource, /summary\?\.inboundId == null \? '' : String\(summary\.inboundId\)/) }) -test('sends backend-required channel, customs, destination and cargo fields', () => { - for (const field of ['pickupMethod', 'channelCode', 'channelName', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) { +test('sends backend-required channel, pricing, customs, destination and cargo fields', () => { + for (const field of ['pickupMethod', 'channelCode', 'channelName', 'pricingTaxMode', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) { assert.match(storeSource, new RegExp(field)) } assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index c44e3a0..934e04a 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -124,6 +124,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust receivingWarehouseName: warehouse.name, channelCode: draft.channelCode, channelName: draft.channelName, + pricingTaxMode: draft.pricingTaxMode, taxMode: draft.taxMode, containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined, customsInspection: draft.customsInspection, @@ -281,7 +282,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar destinationWarehouseId: order.destinationWarehouseId ? String(order.destinationWarehouseId) : '', destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode, channelName: order.channelName, carrierId: order.carrierId == null ? '' : String(order.carrierId), carrierName: order.carrierName ?? '', - taxMode: order.taxMode, customsInspection: order.customsInspection, + pricingTaxMode: order.pricingTaxMode ?? 'TAX_INCLUDED', taxMode: order.taxMode, customsInspection: order.customsInspection, destinationType: order.destinationType, amazonWarehouseCode: order.amazonWarehouseCode ?? '', shipmentId: order.shipmentId ?? '', receivingTime: order.receivingTime ?? '', submittedAt: order.submittedAt ?? '', completedAt: order.completedAt ?? '', } @@ -301,7 +302,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId), destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '', - channelName: summary.channelName ?? '', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false, + channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false, destinationType: 'PRIVATE', amazonWarehouseCode: '', shipmentId: '', receivingTime: '', submittedAt: '', completedAt: '', } diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index 590f65c..2ea2743 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -109,6 +109,7 @@ export interface FirstLegOrder { pickupMethod: 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK' channelCode: string channelName: string + pricingTaxMode: 'TAX_INCLUDED' | 'TAX_EXCLUDED' carrierId: string carrierName: string taxMode: 'BUY_DECLARATION' | 'TAX_REFUND' @@ -146,6 +147,7 @@ export type FirstLegOrderDraft = Omit { - for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) { + for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pricingTaxMode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) { assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`)) } - assert.match(source, /form\.channelName = channel\?\.name/) + assert.match(source, /form\.channelName = channel\?\.channelName/) }) -test('distills order creation into the confirmed prototype sections without fake pricing', () => { +test('distills order creation into the confirmed prototype sections with server pricing', () => { assert.match(source, /

目的地信息<\/h3>/) assert.match(source, /

发货信息<\/h3>/) assert.match(source, /

货物明细<\/h3>/) @@ -217,10 +217,51 @@ test('distills order creation into the confirmed prototype sections without fake assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/) assert.match(source, /class="cargo-primary-grid"/) assert.match(source, /title="更多申报信息"/) - assert.doesNotMatch(source, /费用预估|查看最新报价|cost-estimate/) + assert.match(source, /参考运费/) + assert.match(source, /仅供参考,最终以实际结算为准/) assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/) }) +test('loads only active server channels for the selected domestic warehouse', () => { + assert.match(source, /getMerchantPricingChannels\(warehouseId\)/) + assert.match(source, /const channelOptions = ref\(\[\]\)/) + assert.match(source, /channelOptions\.value = \[\]/) + assert.match(source, /form\.channelCode = ''/) + assert.match(source, /sequence !== channelRequestSequence \|\| form\.receivingWarehouseId !== warehouseId/) + assert.match(source, /channel\.pricingTaxModes\.includes\(form\.pricingTaxMode\)/) + assert.match(source, /availablePricingTaxModes/) + assert.match(source, /:loading="channelLoading"/) + assert.match(source, /:disabled="!form\.receivingWarehouseId \|\| channelLoading"/) + assert.match(source, /当前收货仓暂无可用渠道/) + assert.match(source, /渠道加载失败/) + assert.match(source, /@click="retryPricingChannels"/) + assert.doesNotMatch(source, /SEA-US-GENERAL|SEA-US-SENSITIVE|SEA-US-EXPRESS|美线 FBA 直送/) + assert.doesNotMatch(source, /channelOptions\.some\(\(channel\) => channel\.code/) +}) + +test('estimates by warehouse, destination, tax mode, weight and volume without blocking saves', () => { + assert.match(source, /estimateMerchantFirstLegPrice\(request\)/) + assert.match(source, /originWarehouseId: form\.receivingWarehouseId/) + assert.match(source, /destinationCode: form\.destinationType === 'FBA' \? form\.amazonWarehouseCode : undefined/) + assert.match(source, /destinationWarehouseId: form\.destinationType === 'OVERSEAS'/) + assert.match(source, /pricingTaxMode: form\.pricingTaxMode/) + assert.match(source, /actualWeightKg: summary\.grossWeightKg/) + assert.match(source, /actualVolumeCbm: summary\.volumeCbm/) + assert.match(source, /setTimeout\(\(\) => void loadPriceEstimate\(\), 300\)/) + assert.match(source, /sequence === estimateRequestSequence/) + assert.match(source, /您仍可正常保存订单草稿/) + assert.match(source, /v-for="mode in availablePricingTaxModes"/) + assert.match(source, /pricingTaxModeLabel\(mode\)/) + assert.doesNotMatch(source, /当前价格表不提供不包税报价|历史草稿的不包税设置/) + const saveSource = source.slice(source.indexOf('async function saveDraft()'), source.indexOf('async function submitOrder()')) + assert.doesNotMatch(saveSource, /loadPriceEstimate|priceEstimateError|estimateMerchantFirstLegPrice/) +}) + +test('keeps the reference estimate readable on narrow screens', () => { + assert.match(source, /\.price-estimate__result \{ grid-template-columns: 1fr; \}/) + assert.match(source, /\.price-estimate__result > small \{ grid-column: auto; \}/) +}) + test('shows field-level validation and concise guidance for required order inputs', () => { assert.match(source, /ref="orderFormRef"[^>]*:model="form" :rules="orderFormRules"/) assert.match(source, /prop="shipperPartySnapshot\.contactName" required/) @@ -256,10 +297,10 @@ test('shows the full customer-safe order detail without platform-only controls', for (const heading of ['订单与运输', '收发货信息', '关键业务摘要', '履约快递信息', '货物明细']) { assert.match(source, new RegExp(`

${heading}<\\/h3>`)) } - for (const field of ['贸易条款', '发货方式', '报关方式', '运输渠道', '承运商', '收货要求', '目的仓类型', '海关查验', '提交时间', '完成时间', 'SKU / UPC', 'HS 编码', '申报价值', '危险品']) { + for (const field of ['贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验', '提交时间', '完成时间', 'SKU / UPC', 'HS 编码', '申报价值', '危险品']) { assert.match(source, new RegExp(field)) } - assert.match(source, /detailOrder\.carrierName \|\| '待平台指派'/) + assert.doesNotMatch(detailSource, /承运商|待平台指派/) assert.match(source, /detailOrder\.customerRemark/) assert.doesNotMatch(detailSource, /内部备注|审核通过|添加备注/) }) @@ -317,6 +358,7 @@ test('loads complete order detail before editing and rejects an unavailable hist assert.match(source, /const detailedOrder = await store\.loadDetail\(order\.id\)/) assert.match(source, /draftFromOrder\(detailedOrder\)/) assert.match(source, /draft\.receivingWarehouseId = ''/) + assert.match(source, /draft\.channelCode = ''/) assert.match(source, /原平台收货仓当前不可用,请重新选择可用收货仓/) assert.match(source, /原目的仓当前不可用,请重新选择平台维护的目的仓/) }) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 333eecd..acf4bc6 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -11,6 +11,13 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' +import { + estimateMerchantFirstLegPrice, + getMerchantPricingChannels, + type MerchantPriceEstimate, + type MerchantPriceEstimateRequest, + type MerchantPricingChannel, +} from '@/modules/first-leg/api/firstLeg' import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders' import { useTenantAdministrationStore } from '@/modules/tenant-management/public' import { useAuthStore } from '@/modules/auth/public' @@ -48,12 +55,12 @@ const queryFields: QueryField[] = [ { key: 'status', type: 'select', placeholder: '全部状态', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '订单状态' } }, { key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } }, ] -const channelOptions = [ - { code: 'SEA-US-GENERAL', name: '美线普货专线' }, - { code: 'SEA-US-SENSITIVE', name: '美线敏感货专线' }, - { code: 'SEA-US-EXPRESS', name: '美线快线' }, - { code: 'SEA-US-FBA', name: '美线 FBA 直送' }, -] +const channelOptions = ref([]) +const channelLoading = ref(false) +const channelError = ref('') +const priceEstimate = ref(null) +const priceEstimateLoading = ref(false) +const priceEstimateError = ref('') const orderColumns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'order', label: '订单', minWidth: 190, slotName: 'order' }, @@ -86,6 +93,9 @@ const pageDataLoading = computed(() => supportingDataLoading.value || (!isCreate const editorBaseline = ref('') let cargoSequence = 0 let inboundBillingRequestSequence = 0 +let channelRequestSequence = 0 +let estimateRequestSequence = 0 +let estimateTimer: ReturnType | undefined interface CachedPrivateAddress { id: string @@ -127,8 +137,9 @@ const orderFormRules: FormRules = { { pattern: /^[A-Za-z]{2}$/, message: '请输入两位英文字母,例如 US', trigger: 'blur' }, ], destinationWarehouseName: [{ required: true, message: '请输入目的仓名称', trigger: 'blur' }], - channelCode: [{ required: true, message: '请输入渠道编码', trigger: 'blur' }], + channelCode: [{ required: true, message: '请选择运输渠道', trigger: 'change' }], channelName: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }], + pricingTaxMode: [{ required: true, message: '请选择报价包税方式', trigger: 'change' }], pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }], taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }], destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }], @@ -234,8 +245,9 @@ function emptyDraft(): FirstLegOrderDraft { destinationWarehouseId: '', destinationWarehouseName: '', pickupMethod: 'SELF_EXPRESS', - channelCode: 'SEA-US-FBA', - channelName: '美线 FBA 直送', + channelCode: '', + channelName: '', + pricingTaxMode: 'TAX_INCLUDED', taxMode: 'BUY_DECLARATION', customsInspection: false, destinationType: 'FBA', @@ -303,6 +315,8 @@ const progressSummaryItems = computed(() => { }) const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId)) const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType)) +const selectedPricingChannel = computed(() => channelOptions.value.find((item) => item.channelCode === form.channelCode)) +const availablePricingTaxModes = computed(() => selectedPricingChannel.value?.pricingTaxModes ?? []) const hasActiveFilters = computed(() => { const values = appliedFilters.value return Boolean( @@ -382,6 +396,9 @@ watch(detailOpen, (open) => { onBeforeUnmount(() => { resetInboundBillingState() + channelRequestSequence += 1 + estimateRequestSequence += 1 + if (estimateTimer) clearTimeout(estimateTimer) }) function markEditorClean() { @@ -430,6 +447,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft { pickupMethod: order.pickupMethod, channelCode: order.channelCode, channelName: order.channelName, + pricingTaxMode: order.pricingTaxMode, taxMode: order.taxMode, customsInspection: order.customsInspection, destinationType: order.destinationType, @@ -440,10 +458,118 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft { } function selectChannel(code: string) { - const channel = channelOptions.find((item) => item.code === code) - form.channelName = channel?.name ?? '' + const channel = channelOptions.value.find((item) => item.channelCode === code) + form.channelName = channel?.channelName ?? '' + if (channel && !channel.pricingTaxModes.includes(form.pricingTaxMode)) { + form.pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED' + } } +function pricingTaxModeLabel(mode: 'TAX_INCLUDED' | 'TAX_EXCLUDED') { + return mode === 'TAX_INCLUDED' ? '包税' : '不包税' +} + +function resetPriceEstimate() { + estimateRequestSequence += 1 + priceEstimate.value = null + priceEstimateLoading.value = false + priceEstimateError.value = '' + if (estimateTimer) clearTimeout(estimateTimer) + estimateTimer = undefined +} + +async function loadChannelsForWarehouse(warehouseId: string) { + const sequence = ++channelRequestSequence + const selectedCode = form.channelCode + channelOptions.value = [] + channelError.value = '' + channelLoading.value = true + form.channelCode = '' + form.channelName = '' + resetPriceEstimate() + try { + const channels = await getMerchantPricingChannels(warehouseId) + if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return + channelOptions.value = channels + const selected = channels.find((item) => item.channelCode === selectedCode) + if (selected) { + form.channelCode = selected.channelCode + form.channelName = selected.channelName + if (!selected.pricingTaxModes.includes(form.pricingTaxMode)) { + form.pricingTaxMode = selected.pricingTaxModes[0] ?? 'TAX_INCLUDED' + } + } + } catch (error) { + if (sequence !== channelRequestSequence || form.receivingWarehouseId !== warehouseId) return + channelError.value = errorMessage(error) + } finally { + if (sequence === channelRequestSequence) channelLoading.value = false + } +} + +function createEstimateRequest(): MerchantPriceEstimateRequest | null { + if (!form.receivingWarehouseId || !form.channelCode || form.destinationType === 'PRIVATE') return null + if (!availablePricingTaxModes.value.includes(form.pricingTaxMode)) return null + const summary = summarizeFirstLegCargo(form.items) + if (summary.grossWeightKg <= 0 || summary.volumeCbm <= 0) return null + if (form.destinationType === 'FBA' && !form.amazonWarehouseCode) return null + if (form.destinationType === 'OVERSEAS' && !form.destinationWarehouseId) return null + return { + originWarehouseId: form.receivingWarehouseId, + channelCode: form.channelCode, + destinationType: form.destinationType, + destinationCode: form.destinationType === 'FBA' ? form.amazonWarehouseCode : undefined, + destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined, + destinationName: form.destinationWarehouseName || undefined, + pricingTaxMode: form.pricingTaxMode, + actualWeightKg: summary.grossWeightKg, + actualVolumeCbm: summary.volumeCbm, + } +} + +async function loadPriceEstimate() { + const request = createEstimateRequest() + const sequence = ++estimateRequestSequence + priceEstimate.value = null + priceEstimateError.value = '' + if (!request) { + priceEstimateLoading.value = false + return + } + priceEstimateLoading.value = true + try { + const estimate = await estimateMerchantFirstLegPrice(request) + if (sequence === estimateRequestSequence) priceEstimate.value = estimate + } catch (error) { + if (sequence === estimateRequestSequence) priceEstimateError.value = errorMessage(error) + } finally { + if (sequence === estimateRequestSequence) priceEstimateLoading.value = false + } +} + +function schedulePriceEstimate() { + if (estimateTimer) clearTimeout(estimateTimer) + estimateTimer = setTimeout(() => void loadPriceEstimate(), 300) +} + +function retryPricingChannels() { + if (form.receivingWarehouseId) void loadChannelsForWarehouse(form.receivingWarehouseId) +} + +watch( + () => JSON.stringify({ + receivingWarehouseId: form.receivingWarehouseId, + channelCode: form.channelCode, + pricingTaxMode: form.pricingTaxMode, + destinationType: form.destinationType, + destinationWarehouseId: form.destinationWarehouseId, + amazonWarehouseCode: form.amazonWarehouseCode, + destinationWarehouseName: form.destinationWarehouseName, + items: form.items.map((item) => [item.grossWeightKg, item.volumeCbm]), + }), + schedulePriceEstimate, +) + async function selectDestinationWarehouse(id: string) { const warehouse = availableDestinationWarehouses.value.find((item) => item.id === id) if (!warehouse) return @@ -555,9 +681,19 @@ function removePrivateAddress() { async function loadSelectedWarehouse(id: string | number | boolean | undefined) { const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : '' - if (!warehouseId) return + if (!warehouseId) { + channelRequestSequence += 1 + channelOptions.value = [] + channelError.value = '' + form.channelCode = '' + form.channelName = '' + resetPriceEstimate() + return + } + const requests = [loadChannelsForWarehouse(warehouseId)] try { - await store.loadWarehouseDetail(warehouseId) + requests.push(store.loadWarehouseDetail(warehouseId).then(() => undefined)) + await Promise.all(requests) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败') } @@ -598,6 +734,11 @@ async function openEdit(order: FirstLegOrder) { const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId) if (!warehouseAvailable) { draft.receivingWarehouseId = '' + draft.channelCode = '' + draft.channelName = '' + channelOptions.value = [] + channelError.value = '' + resetPriceEstimate() ElMessage.warning('原平台收货仓当前不可用,请重新选择可用收货仓') } if (draft.destinationType !== 'PRIVATE') { @@ -930,7 +1071,15 @@ async function searchOrders(model: QueryModel) {
{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}联系人:{{ selectedWarehouse.contactName || '-' }} {{ selectedWarehouse.contactPhone }}
- + + + 渠道加载失败:{{ channelError }} 重试 + 当前收货仓暂无可用渠道,请联系平台管理员。 + + + {{ pricingTaxModeLabel(mode) }} + 当前渠道没有可用报价方式,请重新选择渠道。 + 买单报关退税报关 {{ item.label }} 是否 @@ -990,6 +1139,13 @@ async function searchOrders(model: QueryModel) {

FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。

+ @@ -1018,7 +1174,6 @@ async function searchOrders(model: QueryModel) {
发货方式
{{ pickupMethodLabels[detailOrder.pickupMethod] }}
报关方式
{{ taxModeLabels[detailOrder.taxMode] }}
运输渠道
{{ detailOrder.channelName || detailOrder.channelCode || '-' }}{{ detailOrder.channelCode }}
-
承运商
{{ detailOrder.carrierName || '待平台指派' }}
国内收货仓
{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}
收货要求
{{ displayValue(detailOrder.receivingTime) }}
目的仓类型
{{ destinationTypeLabels[detailOrder.destinationType] }}
@@ -1140,6 +1295,17 @@ async function searchOrders(model: QueryModel) { .address-book-control { display: grid; grid-template-columns: minmax(260px, 1fr) auto auto; gap: 10px; align-items: center; } .address-book-control :deep(.el-button) { min-height: 40px; margin-left: 0; } .field-guidance { display: block; margin-top: 5px; color: var(--muted); line-height: 1.45; } +.field-guidance--error { color: #b54708; } +.price-estimate { margin-top: 16px; padding: 15px 16px; border: 1px solid #cbd5da; background: #f7faf9; } +.price-estimate__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.price-estimate__heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.price-estimate__heading strong { color: var(--navy); font-size: 13px; } +.price-estimate__heading span, .price-estimate__state, .price-estimate__result span, .price-estimate__result small { color: var(--muted); font-size: 11px; line-height: 1.5; } +.price-estimate__state { padding-top: 12px; } +.price-estimate__state.is-error { color: #b54708; } +.price-estimate__result { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 5px 14px; padding-top: 12px; } +.price-estimate__result > strong { color: var(--primary); font-size: 20px; } +.price-estimate__result > small { grid-column: 1 / -1; } .selected-destination { display: flex; width: min(100%, 720px); align-items: flex-start; gap: 9px; margin: -4px 0 18px 140px; padding: 10px 12px; background: #f2f7f6; color: var(--primary); } .selected-destination svg { flex: 0 0 17px; margin-top: 2px; } .selected-destination div { display: flex; min-width: 0; flex-direction: column; gap: 2px; } @@ -1248,6 +1414,8 @@ async function searchOrders(model: QueryModel) { .segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; } .warehouse-options { display: grid; width: 100%; grid-template-columns: 1fr; } .warehouse-options :deep(.el-radio-button), .warehouse-options :deep(.el-radio-button__inner) { width: 100%; } + .price-estimate__result { grid-template-columns: 1fr; } + .price-estimate__result > small { grid-column: auto; } :global(.first-leg-editor .el-drawer__header) { min-height: 56px; padding: 15px 20px; } :global(.first-leg-editor .el-drawer__body) { padding: 16px 20px 28px; } :global(.first-leg-editor .el-drawer__footer) { padding: 10px 20px; }