优化
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
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)
|
||||
}
|
||||
+22
-11
@@ -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<TokenPair> | null = null
|
||||
let refreshPromise: Promise<string> | 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<ApiEnvelope<TokenPair>>('/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<ApiEnvelope<TokenPair>>('/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)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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<T> {
|
||||
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<ApiDestinationType, 'PRIVATE'>
|
||||
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<PageResult<MerchantWarehouse>>({
|
||||
method: 'GET',
|
||||
@@ -212,6 +255,22 @@ export function getMerchantWarehouseDetail(id: number | string) {
|
||||
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
||||
}
|
||||
|
||||
export function getMerchantPricingChannels(originWarehouseId: number | string) {
|
||||
return apiRequest<MerchantPricingChannel[]>({
|
||||
method: 'GET',
|
||||
url: '/merchant/v1/first-leg/pricing/channels',
|
||||
params: { originWarehouseId },
|
||||
})
|
||||
}
|
||||
|
||||
export function estimateMerchantFirstLegPrice(payload: MerchantPriceEstimateRequest) {
|
||||
return apiRequest<MerchantPriceEstimate>({
|
||||
method: 'POST',
|
||||
url: '/merchant/v1/first-leg/pricing/estimate',
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
|
||||
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
|
||||
}
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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: '',
|
||||
}
|
||||
|
||||
@@ -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<Pick<
|
||||
| 'pickupMethod'
|
||||
| 'channelCode'
|
||||
| 'channelName'
|
||||
| 'pricingTaxMode'
|
||||
| 'taxMode'
|
||||
| 'customsInspection'
|
||||
| 'destinationType'
|
||||
|
||||
@@ -46,6 +46,7 @@ function validDraft() {
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
channelCode: 'SEA-US-FBA',
|
||||
channelName: '美国海运渠道',
|
||||
pricingTaxMode: 'TAX_INCLUDED',
|
||||
taxMode: 'BUY_DECLARATION',
|
||||
customsInspection: false,
|
||||
destinationType: 'FBA',
|
||||
|
||||
@@ -77,6 +77,7 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||
if (draft.destinationType !== 'PRIVATE' && !draft.destinationWarehouseId) errors.push('请选择平台维护的目的仓')
|
||||
if (draft.destinationType === 'PRIVATE' && draft.destinationWarehouseId) errors.push('私人地址不能引用平台公共仓库')
|
||||
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
|
||||
if (!['TAX_INCLUDED', 'TAX_EXCLUDED'].includes(draft.pricingTaxMode)) errors.push('请选择报价包税方式')
|
||||
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
|
||||
if (draft.pickupMethod === 'PICKUP') {
|
||||
if (!draft.shipperPartySnapshot?.contactName.trim()) errors.push('请填写提货联系人')
|
||||
|
||||
@@ -201,13 +201,13 @@ test('auto-fills trusted warehouse and company data instead of asking customers
|
||||
})
|
||||
|
||||
test('collects the backend-required first-leg fields', () => {
|
||||
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>目的地信息<\/h3>/)
|
||||
assert.match(source, /<h3>发货信息<\/h3>/)
|
||||
assert.match(source, /<h3>货物明细<\/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<MerchantPricingChannel\[\]>\(\[\]\)/)
|
||||
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(`<h3>${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, /原目的仓当前不可用,请重新选择平台维护的目的仓/)
|
||||
})
|
||||
|
||||
@@ -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<MerchantPricingChannel[]>([])
|
||||
const channelLoading = ref(false)
|
||||
const channelError = ref('')
|
||||
const priceEstimate = ref<MerchantPriceEstimate | null>(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<typeof setTimeout> | 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) {
|
||||
</div>
|
||||
<div v-if="selectedWarehouse" class="selected-warehouse" aria-label="所选收货仓详情"><MapPin :size="17" /><div><span>{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}</span><small>{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}</small><small v-if="selectedWarehouse.contactName || selectedWarehouse.contactPhone">联系人:{{ selectedWarehouse.contactName || '-' }} {{ selectedWarehouse.contactPhone }}</small></div></div>
|
||||
<div class="order-form-grid compact-transport-grid">
|
||||
<el-form-item label="渠道" prop="channelCode" required><el-select v-model="form.channelCode" placeholder="请选择运输渠道" @change="selectChannel"><el-option v-if="form.channelCode && !channelOptions.some((channel) => channel.code === form.channelCode)" :label="form.channelName || form.channelCode" :value="form.channelCode" /><el-option v-for="channel in channelOptions" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select></el-form-item>
|
||||
<el-form-item label="渠道" prop="channelCode" required>
|
||||
<el-select v-model="form.channelCode" :loading="channelLoading" :disabled="!form.receivingWarehouseId || channelLoading" :placeholder="form.receivingWarehouseId ? '请选择运输渠道' : '请先选择国内收货仓'" @change="selectChannel"><el-option v-for="channel in channelOptions" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select>
|
||||
<small v-if="channelError" class="field-guidance field-guidance--error">渠道加载失败:{{ channelError }} <el-button link type="primary" @click="retryPricingChannels">重试</el-button></small>
|
||||
<small v-else-if="form.receivingWarehouseId && !channelLoading && channelOptions.length === 0" class="field-guidance">当前收货仓暂无可用渠道,请联系平台管理员。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="报价包税方式" prop="pricingTaxMode" required>
|
||||
<el-radio-group v-model="form.pricingTaxMode"><el-radio v-for="mode in availablePricingTaxModes" :key="mode" :value="mode">{{ pricingTaxModeLabel(mode) }}</el-radio></el-radio-group>
|
||||
<small v-if="form.channelCode && availablePricingTaxModes.length === 0" class="field-guidance field-guidance--error">当前渠道没有可用报价方式,请重新选择渠道。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单报关</el-radio><el-radio value="TAX_REFUND">退税报关</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="运输方式" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :value="item.code">{{ item.label }}</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="是否商检" prop="customsInspection" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
|
||||
@@ -990,6 +1139,13 @@ async function searchOrders(model: QueryModel) {
|
||||
</div></el-collapse-item></el-collapse>
|
||||
</article>
|
||||
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
|
||||
<aside class="price-estimate" aria-live="polite">
|
||||
<div class="price-estimate__heading"><div><strong>参考运费</strong><span>仅供参考,最终以实际结算为准</span></div><el-button v-if="priceEstimateError" link type="primary" @click="loadPriceEstimate">重新估价</el-button></div>
|
||||
<div v-if="priceEstimateLoading" class="price-estimate__state">正在匹配当前生效价格...</div>
|
||||
<div v-else-if="priceEstimate" class="price-estimate__result"><strong>{{ priceEstimate.currency }} {{ Number(priceEstimate.estimatedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}</strong><span>计费量 {{ priceEstimate.chargeableQuantity }} {{ priceEstimate.chargeUnit }} · 单价 {{ priceEstimate.currency }} {{ priceEstimate.unitPrice }}</span><small>{{ priceEstimate.disclaimer || '仅供参考,最终以实际结算为准' }}</small></div>
|
||||
<div v-else-if="priceEstimateError" class="price-estimate__state is-error">暂未取得参考价:{{ priceEstimateError }}。您仍可正常保存订单草稿。</div>
|
||||
<div v-else class="price-estimate__state">选择收货仓、渠道和目的仓,并填写货物重量与体积后显示参考价。</div>
|
||||
</aside>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
|
||||
@@ -1018,7 +1174,6 @@ async function searchOrders(model: QueryModel) {
|
||||
<div><dt>发货方式</dt><dd>{{ pickupMethodLabels[detailOrder.pickupMethod] }}</dd></div>
|
||||
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
|
||||
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
|
||||
<div><dt>承运商</dt><dd>{{ detailOrder.carrierName || '待平台指派' }}</dd></div>
|
||||
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
|
||||
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user