diff --git a/src/api/http.ts b/src/api/http.ts index 9206f64..ed8a391 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig { function unwrap(envelope: ApiEnvelope) { if (envelope.code !== '200') { const fieldErrors = asFieldErrors(envelope.data) - throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId) + throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) } return envelope.data } @@ -36,6 +36,7 @@ function mapError(error: unknown) { error.response?.status, fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, ) } if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR') @@ -142,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) { const response = await transport.request({ ...config, responseType: 'blob' }) return response.data } catch (error) { + await throwBlobApiError(error) throw mapError(error) } } @@ -151,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) { const response = await transport.request({ ...config, responseType: 'blob' }) return { blob: response.data, headers: response.headers } } catch (error) { + await throwBlobApiError(error) throw mapError(error) } } +async function throwBlobApiError(error: unknown) { + if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return + try { + const body = JSON.parse(await error.response.data.text()) as unknown + if (!isApiEnvelope(body)) return + const fieldErrors = asFieldErrors(body.data) + throw new ApiError( + apiErrorMessage(body.message, fieldErrors), + body.code, + error.response.status, + fieldErrors, + typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, + ) + } catch (parsedError) { + if (parsedError instanceof ApiError) throw parsedError + } +} + export { ApiError } diff --git a/src/api/types.ts b/src/api/types.ts index f8f6518..e262136 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -18,6 +18,7 @@ export class ApiError extends Error { readonly status?: number, readonly fieldErrors?: ApiFieldErrors, readonly traceId?: string | null, + readonly requestId?: string | null, ) { super(message) this.name = 'ApiError' diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs new file mode 100644 index 0000000..1178ea2 --- /dev/null +++ b/src/modules/first-leg/api/customs.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8') +const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8') +const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8') + +test('maps only the frozen merchant container-customs contract', () => { + assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/) + assert.match(source, /inboundId: string/) + assert.match(source, /orderId: string/) + assert.match(source, /documentId: string/) + assert.match(source, /loadingPlanId: string/) + assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/) + assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/) +}) + +test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => { + assert.match(source, /params\.append\('inboundIds', id\)/) + assert.match(source, /expectedVersion: document\.source\.version/) + assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/) + assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4) + assert.match(source, /data\.append\('file', file\)/) + assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/) +}) + +test('preserves request and trace identifiers on API errors', () => { + assert.match(httpTypes, /readonly requestId\?: string \| null/) + assert.match(httpSource, /envelope\.traceId, envelope\.requestId/) + assert.match(source, /interface ApiErrorMeta/) + assert.match(source, /requestId: error\.requestId \?\? null/) + assert.match(source, /traceId: error\.traceId \?\? null/) +}) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index bf89f41..ea26674 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -1,31 +1,210 @@ -import { ApiError, apiRequest } from '@/api/http' +import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http' +import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg' -export interface MerchantCustomsReceipt { - id: number | string - receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string - receiptVersion: number | null - fileName: string | null - uploadedAt: string | null +export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED' +export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED' + +export interface MerchantCustomsFile { + fileId: string + fileName: string + fileHash: string + contentType: string | null + sizeBytes: number + version: number + uploadedAt: string } -export interface MerchantCustomsCaseDetail { - customsCase: { - id: number | string - customsNo: string | null - orderId: number | string - status: string | null - updatedAt: string | null - } - receipts: MerchantCustomsReceipt[] +export interface MerchantCustomsInbound { + inboundId: string + orderId: string + orderNo: string + inboundCode: string + tenantId: string + customerName: string + taxMode: 'TAX_REFUND' | 'BUY_DECLARATION' + covered: boolean } -export function getMerchantCustomsByOrder(orderId: number | string) { - return apiRequest({ +export interface MerchantCustomsDocument { + documentId: string + tenantId: string + source: MerchantCustomsFile + inboundIds: string[] + inboundCodes: string[] + frozen: boolean + completion: MerchantCustomsFile | null +} + +export interface MerchantCustomsContainer { + aggregateId: string + loadingPlanId: string + containerBookingId: string + planNo?: string + cabinetNo?: string + containerNo: string | null + status: ContainerCustomsStatus + merchantStatus?: MerchantCustomsStatus | null + version: number + taxRefundCount: number + coveredTaxRefundCount: number + inbounds: MerchantCustomsInbound[] + documents: MerchantCustomsDocument[] + startedAt?: string | null + completedAt?: string | null +} + +export interface ApiErrorMeta { + code: string + requestId: string | null + traceId: string | null +} + +export interface MerchantCustomsDownload { + blob: Blob + fileName: string +} + +export const MERCHANT_CUSTOMS_STATUS_LABELS: Record = { + WAITING_UPLOAD: '待上传资料', + DOCUMENT_UPLOADED: '资料已上传', + DECLARING: '报关中', + CUSTOMS_COMPLETED: '报关完成', +} + +const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024 + +function encodedId(value: number | string) { + return encodeURIComponent(String(value)) +} + +function inboundQuery(inboundIds: Array) { + const params = new URLSearchParams() + inboundIds.map(String).forEach((id) => params.append('inboundIds', id)) + return params.toString() +} + +function fileBody(file: File) { + const data = new FormData() + data.append('file', file) + return data +} + +export function validateMerchantCustomsFile(file: File) { + if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB') +} + +export function getMerchantCustomsForOrder(orderId: number | string) { + return apiRequest({ method: 'GET', - url: `/merchant/v1/customs/orders/${orderId}`, + url: `/merchant/v1/customs/orders/${encodedId(orderId)}`, }) } +export function uploadMerchantCustomsDocument(planId: number | string, inboundIds: Array, file: File) { + validateMerchantCustomsFile(file) + return apiRequest({ + method: 'POST', + url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) }, + data: fileBody(file), + timeout: 120_000, + }) +} + +export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: Array, file: File) { + validateMerchantCustomsFile(file) + const params = new URLSearchParams(inboundQuery(inboundIds)) + params.set('expectedVersion', String(document.source.version)) + return apiRequest({ + method: 'POST', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) }, + data: fileBody(file), + timeout: 120_000, + }) +} + +export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: Array) { + return apiRequest({ + method: 'PUT', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) }, + data: { inboundIds: inboundIds.map(String), expectedVersion: document.source.version }, + }) +} + +export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) { + return apiRequest({ + method: 'DELETE', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) }, + params: { expectedVersion: document.source.version }, + }) +} + +function contentDispositionFileName(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback + const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { return decodeURIComponent(encoded) } catch { return fallback } + } + return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback +} + +async function requireBinaryDownload(blob: Blob) { + if (!blob.type.toLowerCase().includes('json')) return + try { + const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown } + if (typeof body.message === 'string') { + throw new ApiError( + body.message, + typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED', + undefined, + undefined, + typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, + ) + } + } catch (error) { + if (error instanceof ApiError) throw error + } +} + +async function download(url: string, fallbackName: string): Promise { + const response = await apiBlobResponseRequest({ method: 'GET', url }) + await requireBinaryDownload(response.blob) + return { + blob: response.blob, + fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName), + } +} + +export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) { + return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName) +} + +export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) { + return download( + `/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`, + document.completion?.fileName || `customs-completion-${document.documentId}`, + ) +} + +export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null { + if (!(error instanceof ApiError)) return null + return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null } +} + +export function merchantCustomsErrorMessage(error: unknown, fallback: string) { + if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback + const identifiers = [ + error.code ? `错误码 ${error.code}` : '', + error.requestId ? `请求号 ${error.requestId}` : '', + error.traceId ? `追踪号 ${error.traceId}` : '', + ].filter(Boolean) + return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}` +} + export function isMerchantCustomsUnavailable(error: unknown) { return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') } diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue new file mode 100644 index 0000000..83648a0 --- /dev/null +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 0070544..a74f1bd 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -6,6 +6,8 @@ const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url) const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8') const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8') +const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8') +const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8') test('routes the confirmed first-leg path to the implemented order view', () => { assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/) @@ -198,7 +200,7 @@ test('shows one unified progress line and C02 business summaries without detail assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/) assert.match(progressSource, /国内装柜进度及后续履约同步/) assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/) - assert.match(source, /报关回执/) + assert.doesNotMatch(source, /

报关回执<\/h3>/) assert.match(source, /船期摘要/) assert.match(source, /柜次进度/) assert.doesNotMatch(source, /summary\.sealNo \|\|/) @@ -492,6 +494,34 @@ test('copies reactive order drafts into plain request data', () => { assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/) }) +test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => { + assert.match(source, /order\.status !== 'LOADED'/) + assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/) + assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/) + assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/) + assert.match(source, / { + for (const path of [ + '/merchant/v1/customs/orders/', + '/merchant/v1/customs/loading-plans/', + '/merchant/v1/customs/documents/', + '/inbounds', + '/completion/download', + ]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/'))) + assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/) + assert.match(customsApiSource, /inboundIds: inboundIds\.map\(String\)/) + assert.match(customsApiSource, /requestId: error\.requestId/) + assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/) + assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/) + assert.match(customsDialogSource, /资料版本历史/) + assert.match(customsDialogSource, /尚未上传退税报关资料/) + assert.match(customsDialogSource, /退税报关资料加载失败/) + assert.doesNotMatch(customsDialogSource, /accept=/) +}) + test('uses the global inbound-code policy to require old-system codes during parallel running', () => { assert.match(source, /getMerchantInboundCodePolicy\(\)/) assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 8f64b5a..e6ba9d1 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus' -import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue' +import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue' import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' @@ -10,8 +10,9 @@ import { ApiError } from '@/api/http' import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue' import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' +import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' -import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs' +import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs' import { estimateMerchantFirstLegPrice, downloadMerchantOrderCartonLabelsBatch, @@ -95,7 +96,7 @@ const cargoTemplateColumns = [ const cargoNumberKeys = new Set(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm']) const fixedOrderColumns: DataTableColumn[] = [ { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, - { key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, { key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' }, ] const selectableOrderColumns: DataTableColumn[] = [ @@ -188,9 +189,10 @@ const inboundBillingLoading = ref(false) const inboundBillingUnavailable = ref(false) const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布') const inboundBillingError = ref('') -const customsDetail = ref(null) -const customsLoading = ref(false) -const customsUnavailable = ref(false) +const customsByOrder = reactive>({}) +const customsEligibilityPending = new Set() +const customsDialogOpen = ref(false) +const customsDialogOrderId = ref('') const supportingDataLoading = ref(false) const supportingDataErrors = ref([]) const destinationWarehousesLoading = ref(false) @@ -461,6 +463,41 @@ const pagedOrders = computed(() => { return filteredOrders.value.slice(start, start + tablePageSize.value) }) +function eligibleCustomsDetail(order: FirstLegOrder) { + const value = customsByOrder[order.id] + if (order.status !== 'LOADED' || !value?.merchantStatus) return null + return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null +} + +async function loadCustomsEligibility(order: FirstLegOrder) { + if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return + customsEligibilityPending.add(order.id) + try { + customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id) + } catch (error) { + customsByOrder[order.id] = null + if (!isMerchantCustomsUnavailable(error)) { + // Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response. + } + } finally { + customsEligibilityPending.delete(order.id) + } +} + +watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => { + pagedOrders.value.forEach((order) => void loadCustomsEligibility(order)) +}, { immediate: true }) + +function openCustomsDocuments(order: FirstLegOrder) { + if (!eligibleCustomsDetail(order)) return + customsDialogOrderId.value = order.id + customsDialogOpen.value = true +} + +function updateCustomsDetail(value: MerchantCustomsContainer) { + if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value +} + const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined) const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null) const pagedDetailCargoItems = computed(() => { @@ -486,7 +523,6 @@ const progressSummaryItems = computed(() => { { label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' }, { label: '拣货进度', value: pickingProgress }, { label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' }, - { label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' }, { label: '船期摘要', value: transportReference || '待更新' }, { label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') }, { label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') }, @@ -598,9 +634,6 @@ watch(isCreatePage, (isCreate, wasCreate) => { watch(detailOpen, (open) => { if (!open) { resetInboundBillingState() - customsDetail.value = null - customsLoading.value = false - customsUnavailable.value = false if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items) } }) @@ -1073,7 +1106,6 @@ async function openDetail(order: FirstLegOrder) { detailLoading.value = true detailError.value = '' void loadInboundBilling(order) - void loadCustoms(order) try { const detailedOrder = await store.loadDetail(order.id) // Load previews from the persisted file IDs after the store has replaced the detail row. @@ -1138,21 +1170,6 @@ async function loadInboundBilling(order: FirstLegOrder) { } } -async function loadCustoms(order: FirstLegOrder) { - customsDetail.value = null - customsUnavailable.value = false - if (order.status === 'DRAFT') return - customsLoading.value = true - try { - customsDetail.value = await getMerchantCustomsByOrder(order.id) - } catch (error) { - if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true - else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败') - } finally { - customsLoading.value = false - } -} - async function retryInboundBilling() { const order = detailId.value ? store.getById(detailId.value) : undefined if (order) await loadInboundBilling(order) @@ -1632,6 +1649,7 @@ async function searchOrders(model: QueryModel) {
详情 编辑 + 报关资料
@@ -2226,9 +2241,6 @@ async function searchOrders(model: QueryModel) { .cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; } .cabinet-progress-card dt { color: var(--muted); font-size: 10px; } .cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; } -.customs-receipt-list { display: grid; gap: 8px; } -.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; } -.customs-receipt-list strong { color: var(--navy); } .express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); } .express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .express-information span { color: var(--muted); font-size: 11px; }