diff --git a/src/api/errorDetails.ts b/src/api/errorDetails.ts new file mode 100644 index 0000000..e932bab --- /dev/null +++ b/src/api/errorDetails.ts @@ -0,0 +1,16 @@ +export type ApiFieldErrors = Record + +export function asFieldErrors(value: unknown): ApiFieldErrors | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +export function apiErrorMessage(message: string, fieldErrors?: ApiFieldErrors) { + const baseMessage = message.trim() || '请求失败' + const entries = Object.entries(fieldErrors ?? {}) + if (entries.length === 0) return baseMessage + if (entries.every(([field, detail]) => baseMessage.includes(field) && baseMessage.includes(detail))) return baseMessage + const details = entries.map(([field, detail]) => `${field}: ${detail}`).join(';') + return `${baseMessage}:${details}` +} diff --git a/src/api/http.ts b/src/api/http.ts index 1453ae1..bfaedc4 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,7 +1,8 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' -import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' +import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', @@ -15,7 +16,8 @@ interface RetryableRequest extends InternalAxiosRequestConfig { function unwrap(envelope: ApiEnvelope) { if (envelope.code !== '200') { - throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId) + const fieldErrors = asFieldErrors(envelope.data) + throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId) } return envelope.data } @@ -26,11 +28,12 @@ function mapError(error: unknown) { const body = error.response?.data if (isApiEnvelope(body)) { + const fieldErrors = asFieldErrors(body.data) return new ApiError( - body.message || '请求失败', + apiErrorMessage(body.message, fieldErrors), body.code, error.response?.status, - asFieldErrors(body.data), + fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, ) } diff --git a/src/api/types.test.mjs b/src/api/types.test.mjs new file mode 100644 index 0000000..809e521 --- /dev/null +++ b/src/api/types.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { apiErrorMessage, asFieldErrors } from './errorDetails.ts' + +test('adds validation field details to a generic API message', () => { + const errors = asFieldErrors({ carrierName: '不能为空', carrierId: '不能为null' }) + + assert.equal( + apiErrorMessage('Validation failed', errors), + 'Validation failed:carrierName: 不能为空;carrierId: 不能为null', + ) +}) + +test('does not duplicate validation details already included by the server', () => { + const errors = { carrierName: '不能为空', carrierId: '不能为null' } + const message = 'carrierName: 不能为空;carrierId: 不能为null' + + assert.equal(apiErrorMessage(message, errors), message) +}) diff --git a/src/api/types.ts b/src/api/types.ts index 5097dfa..f8f6518 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,3 +1,5 @@ +import type { ApiFieldErrors } from '@/api/errorDetails' + export interface ApiEnvelope { code: string message: string @@ -7,7 +9,7 @@ export interface ApiEnvelope { timestamp: string } -export type ApiFieldErrors = Record +export type { ApiFieldErrors } from '@/api/errorDetails' export class ApiError extends Error { constructor( @@ -27,9 +29,3 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope { const record = value as Record return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record } - -export function asFieldErrors(value: unknown): ApiFieldErrors | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined - const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - return entries.length > 0 ? Object.fromEntries(entries) : undefined -} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 5f594ea..a9e5cbc 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -36,6 +36,10 @@ test('exposes real list, draft, submit, and detail workflow entries without canc assert.doesNotMatch(source, /cancelOrder|取消订单/) }) +test('keeps create-page support errors scoped to create-page requests', () => { + assert.match(source, /if \(!isCreatePage\.value && store\.draftLoadError\)/) +}) + test('blocks order submission before company approval while preserving draft actions', () => { assert.match(source, /companySubmissionBlocked/) assert.match(source, /companyAuditLoaded\.value = true/) @@ -166,10 +170,16 @@ test('selects platform destinations by type and caches private addresses per use assert.match(source, /私人地址不能引用平台公共仓库|form\.destinationWarehouseId = ''/) }) -test('shows a recoverable destination warehouse error when list-to-create loading fails', () => { - assert.match(source, /await router\.push\(\{ name: 'first-leg-order-create' \}\)[\s\S]*?try \{[\s\S]*?await loadDestinationWarehouses\(form\.destinationType\)/) - assert.match(source, /supportingDataErrors\.value\.push\(message\)/) - assert.match(source, /目的仓数据加载失败,请点击页面提示重新加载/) +test('lets the create-page instance own destination loading and exposes a recoverable error state', () => { + assert.match(source, /await router\.push\(\{ name: 'first-leg-order-create' \}\)\s*\n\}/) + assert.doesNotMatch(source, /await router\.push\(\{ name: 'first-leg-order-create' \}\)[\s\S]*?await loadDestinationWarehouses\(form\.destinationType\)/) + assert.match(source, /if \(destinationResult\.status === 'rejected'\) supportingDataErrors\.value\.push\(`目的仓加载失败:\$\{errorMessage\(destinationResult\.reason\)\}`\)/) + assert.match(source, /下单配套数据加载失败/) +}) + +test('shows a loading state before the list can create an order', () => { + assert.match(source, / { diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 8cc518c..ca8973d 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -312,7 +312,7 @@ async function loadPageData() { } if (!isCreatePage.value && ordersResult.status === 'rejected' && !store.loadError) supportingDataErrors.value.push(`订单加载失败:${errorMessage(ordersResult.reason)}`) if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`) - if (store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`) + if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`) if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`) await autofillWarehouseSelections() supportingDataLoading.value = false @@ -551,21 +551,15 @@ async function autofillWarehouseSelections() { async function openCreate() { if (availableWarehouses.value.length === 0) { - ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单') + ElMessage.warning(supportingDataLoading.value + ? '平台收货仓正在加载,请稍候再试' + : '当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单') return } editingId.value = undefined replaceForm(emptyDraft()) resetEditorValidation() await router.push({ name: 'first-leg-order-create' }) - try { - await loadDestinationWarehouses(form.destinationType) - await autofillWarehouseSelections() - } catch (error) { - const message = `目的仓加载失败:${errorMessage(error)}` - if (!supportingDataErrors.value.includes(message)) supportingDataErrors.value.push(message) - ElMessage.error('目的仓数据加载失败,请点击页面提示重新加载') - } } async function openEdit(order: FirstLegOrder) { @@ -826,7 +820,7 @@ async function searchOrders(model: QueryModel) {
订单列表共 {{ filteredOrders.length }} 条
- 创建订单 + 创建订单
@@ -862,7 +856,7 @@ async function searchOrders(model: QueryModel) { @@ -877,7 +871,7 @@ async function searchOrders(model: QueryModel) {
- {{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }} + {{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}
@@ -904,7 +898,7 @@ async function searchOrders(model: QueryModel) { - {{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }} + {{ warehouse.name }}
{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}联系人:{{ selectedWarehouse.contactName || '-' }} {{ selectedWarehouse.contactPhone }}
@@ -1070,9 +1064,9 @@ async function searchOrders(model: QueryModel) { .prototype-section { padding: 2px 2px 0; } .segmented-options { display: flex; width: min(100%, 620px); } .segmented-options :deep(.el-radio-button) { flex: 1; } -.segmented-options :deep(.el-radio-button__inner) { width: 100%; min-height: 42px; padding: 11px 16px; } +.segmented-options :deep(.el-radio-button__inner) { display: flex; width: 100%; min-height: 42px; box-sizing: border-box; align-items: center; justify-content: center; padding: 11px 16px; } .warehouse-options { display: flex; flex-wrap: wrap; gap: 8px; } -.warehouse-options :deep(.el-radio-button__inner) { min-height: 40px; border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; } +.warehouse-options :deep(.el-radio-button__inner) { display: flex; min-height: 40px; box-sizing: border-box; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; } .warehouse-options :deep(.el-radio-button.is-active .el-radio-button__inner) { border-color: var(--primary); } .selected-warehouse { display: flex; align-items: flex-start; gap: 10px; margin-top: 0; padding: 11px 13px; border: 1px solid #d9def8; background: #f6f7fe; color: var(--primary); } .selected-warehouse svg { flex: 0 0 17px; margin-top: 2px; }