This commit is contained in:
2026-08-06 18:02:02 +08:00
parent 9aa79bef6d
commit 20f8a436e0
6 changed files with 69 additions and 31 deletions
+16
View File
@@ -0,0 +1,16 @@
export type ApiFieldErrors = Record<string, string>
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}`
}
+7 -4
View File
@@ -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<T>(envelope: ApiEnvelope<T>) {
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,
)
}
+19
View File
@@ -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)
})
+3 -7
View File
@@ -1,3 +1,5 @@
import type { ApiFieldErrors } from '@/api/errorDetails'
export interface ApiEnvelope<T> {
code: string
message: string
@@ -7,7 +9,7 @@ export interface ApiEnvelope<T> {
timestamp: string
}
export type ApiFieldErrors = Record<string, string>
export type { ApiFieldErrors } from '@/api/errorDetails'
export class ApiError extends Error {
constructor(
@@ -27,9 +29,3 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
const record = value as Record<string, unknown>
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
}
@@ -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, /<el-button v-if="auth\.hasPermission\('order:first-leg:create'\)" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading \|\| availableWarehouses\.length === 0" @click="openCreate"><FilePlus2/)
assert.match(source, /pageDataLoading \? '加载收货仓中' : \(availableWarehouses\.length \? '创建第一笔订单'/)
})
test('auto-fills trusted warehouse and company data instead of asking customers to repeat it', () => {
@@ -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) {
<div class="client-card order-table-card">
<div class="order-table-toolbar">
<div><strong>订单列表</strong><span>共 {{ filteredOrders.length }} 条</span></div>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div>
<div v-if="companySubmissionBlocked && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status">
<AlertTriangle :size="20" />
@@ -862,7 +856,7 @@ async function searchOrders(model: QueryModel) {
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
<template #empty>
<el-empty :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</template>
</DataTable>
@@ -877,7 +871,7 @@ async function searchOrders(model: QueryModel) {
</article>
</div>
<el-empty v-else :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</div>
</template>
@@ -904,7 +898,7 @@ async function searchOrders(model: QueryModel) {
<el-form-item label="城市" prop="shipperPartySnapshot.city" required :rules="requiredTextRules('请输入提货城市')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.city" maxlength="100" placeholder="请输入城市" /></el-form-item>
<el-form-item label="提货地址" prop="shipperPartySnapshot.address" required :rules="requiredTextRules('请输入提货地址')" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入详细提货地址" /></el-form-item>
</template>
<el-form-item label="国内收货仓" prop="receivingWarehouseId" required class="prototype-form-row prototype-form-row--wide"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio-button v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }}</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="国内收货仓" prop="receivingWarehouseId" required class="prototype-form-row prototype-form-row--wide"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio-button v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.name }}</el-radio-button></el-radio-group></el-form-item>
</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">
@@ -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; }