Skip to content

Commit 4fd14bd

Browse files
authored
Merge pull request #1693 from QQHKX/fix/expression-review-error-format
fix: 修复快速审核结构化错误导致的 React #31
2 parents c1aa490 + ddd7f83 commit 4fd14bd

3 files changed

Lines changed: 157 additions & 21 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { formatApiError } from '../api-error'
4+
5+
describe('formatApiError', () => {
6+
it('returns string detail directly', () => {
7+
expect(formatApiError({ detail: '请求失败' }, '默认错误')).toBe('请求失败')
8+
})
9+
10+
it('formats FastAPI validation detail arrays as text', () => {
11+
const error = formatApiError(
12+
{
13+
detail: [
14+
{
15+
type: 'int_parsing',
16+
loc: ['query', 'exclude_ids', 0],
17+
msg: 'Input should be a valid integer',
18+
input: 'abc',
19+
},
20+
],
21+
},
22+
'获取审核列表失败',
23+
)
24+
25+
expect(error).toBe('query.exclude_ids.0: Input should be a valid integer')
26+
})
27+
28+
it('formats object details without returning an object', () => {
29+
const error = formatApiError(
30+
{
31+
detail: {
32+
loc: ['body', 'items'],
33+
msg: 'Field required',
34+
},
35+
},
36+
'批量审核失败',
37+
)
38+
39+
expect(error).toBe('body.items: Field required')
40+
})
41+
42+
it('falls back when response has no usable message', () => {
43+
expect(formatApiError({}, '默认错误')).toBe('默认错误')
44+
})
45+
46+
it('uses message when detail is empty', () => {
47+
expect(formatApiError({ detail: '', message: '权限不足' }, '默认错误')).toBe('权限不足')
48+
})
49+
})

dashboard/src/lib/api-error.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
type ApiErrorDetail = {
2+
loc?: unknown
3+
msg?: unknown
4+
message?: unknown
5+
type?: unknown
6+
}
7+
8+
/** 将 FastAPI 校验错误中的 loc 路径转换为可读字段路径。 */
9+
function formatLocation(loc: unknown): string {
10+
if (Array.isArray(loc)) {
11+
return loc.map((item) => String(item)).join('.')
12+
}
13+
if (loc === null || loc === undefined || loc === '') {
14+
return ''
15+
}
16+
return String(loc)
17+
}
18+
19+
/** 将单个错误详情转换为可安全渲染的字符串。 */
20+
function formatDetailItem(item: unknown): string {
21+
if (typeof item === 'string') {
22+
return item
23+
}
24+
25+
if (item && typeof item === 'object') {
26+
const detail = item as ApiErrorDetail
27+
const message = detail.msg ?? detail.message
28+
const location = formatLocation(detail.loc)
29+
if (message !== null && message !== undefined && message !== '') {
30+
return location ? `${location}: ${String(message)}` : String(message)
31+
}
32+
}
33+
34+
try {
35+
return JSON.stringify(item)
36+
} catch {
37+
return String(item)
38+
}
39+
}
40+
41+
/** 判断候选错误字段是否包含可展示的信息。 */
42+
function hasUsableMessage(value: unknown): boolean {
43+
if (value === null || value === undefined || value === '') {
44+
return false
45+
}
46+
if (Array.isArray(value)) {
47+
return value.length > 0
48+
}
49+
return true
50+
}
51+
52+
/**
53+
* 将后端错误响应统一转换为字符串,避免将对象直接传给 React 渲染。
54+
*/
55+
export function formatApiError(errorData: unknown, fallback: string): string {
56+
if (!errorData) {
57+
return fallback
58+
}
59+
60+
if (typeof errorData === 'string') {
61+
return errorData || fallback
62+
}
63+
64+
if (typeof errorData !== 'object') {
65+
return String(errorData) || fallback
66+
}
67+
68+
const data = errorData as { detail?: unknown; message?: unknown; error?: unknown }
69+
const rawMessage = [data.detail, data.message, data.error].find(hasUsableMessage)
70+
71+
if (Array.isArray(rawMessage)) {
72+
const message = rawMessage.map(formatDetailItem).filter(Boolean).join('; ')
73+
return message || fallback
74+
}
75+
76+
if (rawMessage && typeof rawMessage === 'object') {
77+
const message = formatDetailItem(rawMessage)
78+
return message || fallback
79+
}
80+
81+
if (rawMessage !== null && rawMessage !== undefined && rawMessage !== '') {
82+
return String(rawMessage)
83+
}
84+
85+
return fallback
86+
}

dashboard/src/lib/expression-api.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* 表达方式管理 API
33
*/
44
import { fetchWithAuth } from '@/lib/fetch-with-auth'
5+
import { formatApiError } from '@/lib/api-error'
56
import type {
67
BatchReviewItem,
78
BatchReviewResponse,
@@ -46,7 +47,7 @@ export async function getChatList(params: { include_legacy?: boolean } = {}): Pr
4647
const errorData = await response.json()
4748
return {
4849
success: false,
49-
error: errorData.detail || errorData.message || '获取聊天列表失败',
50+
error: formatApiError(errorData, '获取聊天列表失败'),
5051
}
5152
} catch {
5253
return {
@@ -92,7 +93,7 @@ export async function getExpressionChatTargets(
9293
const errorData = await response.json()
9394
return {
9495
success: false,
95-
error: errorData.detail || errorData.message || '获取导入目标聊天流失败',
96+
error: formatApiError(errorData, '获取导入目标聊天流失败'),
9697
}
9798
} catch {
9899
return {
@@ -137,7 +138,7 @@ export async function getExpressionGroups(
137138
const errorData = await response.json()
138139
return {
139140
success: false,
140-
error: errorData.detail || errorData.message || '获取表达互通组失败',
141+
error: formatApiError(errorData, '获取表达互通组失败'),
141142
}
142143
} catch {
143144
return {
@@ -196,7 +197,7 @@ export async function getExpressionList(params: {
196197
const errorData = await response.json()
197198
return {
198199
success: false,
199-
error: errorData.detail || errorData.message || '获取表达方式列表失败',
200+
error: formatApiError(errorData, '获取表达方式列表失败'),
200201
}
201202
} catch {
202203
return {
@@ -244,7 +245,7 @@ export async function exportExpressions(params: {
244245
const errorData = await response.json()
245246
return {
246247
success: false,
247-
error: errorData.detail || errorData.message || '导出表达方式失败',
248+
error: formatApiError(errorData, '导出表达方式失败'),
248249
}
249250
} catch {
250251
return {
@@ -285,7 +286,7 @@ export async function importExpressions(params: {
285286
const errorData = await response.json()
286287
return {
287288
success: false,
288-
error: errorData.detail || errorData.message || '导入表达方式失败',
289+
error: formatApiError(errorData, '导入表达方式失败'),
289290
}
290291
} catch {
291292
return {
@@ -325,7 +326,7 @@ export async function clearExpressions(params: {
325326
const errorData = await response.json()
326327
return {
327328
success: false,
328-
error: errorData.detail || errorData.message || '清除表达方式失败',
329+
error: formatApiError(errorData, '清除表达方式失败'),
329330
}
330331
} catch {
331332
return {
@@ -365,7 +366,7 @@ export async function previewLegacyExpressionImport(params: {
365366
const errorData = await response.json()
366367
return {
367368
success: false,
368-
error: errorData.detail || errorData.message || '预览旧版导入失败',
369+
error: formatApiError(errorData, '预览旧版导入失败'),
369370
}
370371
} catch {
371372
return {
@@ -407,7 +408,7 @@ export async function previewLegacyExpressionImportFile(
407408
const errorData = await response.json()
408409
return {
409410
success: false,
410-
error: errorData.detail || errorData.message || '预览旧版导入失败',
411+
error: formatApiError(errorData, '预览旧版导入失败'),
411412
}
412413
} catch {
413414
return {
@@ -448,7 +449,7 @@ export async function importLegacyExpressions(params: {
448449
const errorData = await response.json()
449450
return {
450451
success: false,
451-
error: errorData.detail || errorData.message || '旧版导入失败',
452+
error: formatApiError(errorData, '旧版导入失败'),
452453
}
453454
} catch {
454455
return {
@@ -485,7 +486,7 @@ export async function getExpressionDetail(expressionId: number): Promise<ApiResp
485486
const errorData = await response.json()
486487
return {
487488
success: false,
488-
error: errorData.detail || errorData.message || '获取表达方式详情失败',
489+
error: formatApiError(errorData, '获取表达方式详情失败'),
489490
}
490491
} catch {
491492
return {
@@ -533,7 +534,7 @@ export async function createExpression(
533534
const errorData = await response.json()
534535
return {
535536
success: false,
536-
error: errorData.detail || errorData.message || '创建表达方式失败',
537+
error: formatApiError(errorData, '创建表达方式失败'),
537538
}
538539
} catch {
539540
return {
@@ -582,7 +583,7 @@ export async function updateExpression(
582583
const errorData = await response.json()
583584
return {
584585
success: false,
585-
error: errorData.detail || errorData.message || '更新表达方式失败',
586+
error: formatApiError(errorData, '更新表达方式失败'),
586587
}
587588
} catch {
588589
return {
@@ -627,7 +628,7 @@ export async function deleteExpression(expressionId: number): Promise<ApiRespons
627628
const errorData = await response.json()
628629
return {
629630
success: false,
630-
error: errorData.detail || errorData.message || '删除表达方式失败',
631+
error: formatApiError(errorData, '删除表达方式失败'),
631632
}
632633
} catch {
633634
return {
@@ -673,7 +674,7 @@ export async function batchDeleteExpressions(expressionIds: number[]): Promise<A
673674
const errorData = await response.json()
674675
return {
675676
success: false,
676-
error: errorData.detail || errorData.message || '批量删除表达方式失败',
677+
error: formatApiError(errorData, '批量删除表达方式失败'),
677678
}
678679
} catch {
679680
return {
@@ -719,7 +720,7 @@ export async function getExpressionStats(params: { include_legacy?: boolean } =
719720
const errorData = await response.json()
720721
return {
721722
success: false,
722-
error: errorData.detail || errorData.message || '获取统计数据失败',
723+
error: formatApiError(errorData, '获取统计数据失败'),
723724
}
724725
} catch {
725726
return {
@@ -763,7 +764,7 @@ export async function getReviewStats(): Promise<ApiResponse<ReviewStats>> {
763764
const errorData = await response.json()
764765
return {
765766
success: false,
766-
error: errorData.detail || errorData.message || '获取审核统计失败',
767+
error: formatApiError(errorData, '获取审核统计失败'),
767768
}
768769
} catch {
769770
return {
@@ -816,7 +817,7 @@ export async function getReviewList(params: {
816817
const errorData = await response.json()
817818
return {
818819
success: false,
819-
error: errorData.detail || errorData.message || '获取审核列表失败',
820+
error: formatApiError(errorData, '获取审核列表失败'),
820821
}
821822
} catch {
822823
return {
@@ -863,7 +864,7 @@ export async function batchReviewExpressions(
863864
const errorData = await response.json()
864865
return {
865866
success: false,
866-
error: errorData.detail || errorData.message || '批量审核失败',
867+
error: formatApiError(errorData, '批量审核失败'),
867868
}
868869
} catch {
869870
return {
@@ -909,7 +910,7 @@ export async function getExpressionReviewLogs(params: {
909910
const errorData = await response.json()
910911
return {
911912
success: false,
912-
error: errorData.detail || errorData.message || '获取 AI 审核记录失败',
913+
error: formatApiError(errorData, '获取 AI 审核记录失败'),
913914
}
914915
} catch {
915916
return {
@@ -945,7 +946,7 @@ export async function approveExpressionReviewLog(
945946
const errorData = await response.json()
946947
return {
947948
success: false,
948-
error: errorData.detail || errorData.message || '恢复表达方式失败',
949+
error: formatApiError(errorData, '恢复表达方式失败'),
949950
}
950951
} catch {
951952
return {

0 commit comments

Comments
 (0)