diff --git a/docs/technical/API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md b/docs/technical/API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md new file mode 100644 index 00000000..7a3615b7 --- /dev/null +++ b/docs/technical/API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md @@ -0,0 +1,37 @@ +# API 错误 details.message 展示修复 + +## 背景 + +`POST /api/runtime/big-fish/agent/sessions/{sessionId}/actions` 在执行 `big_fish_publish_game` 时,Rust `api-server` 会把 SpacetimeDB 发布校验失败映射为统一 API envelope: + +```json +{ + "ok": false, + "data": null, + "error": { + "code": "BAD_REQUEST", + "message": "请求参数不合法", + "details": { + "message": "big_fish 发布校验未通过:还缺少 16 个基础动作", + "provider": "spacetimedb" + } + } +} +``` + +其中 `error.message` 是通用错误分类文案,`error.details.message` 才是当前业务动作的可定位失败原因。前端通用请求解析此前只读取 `error.message`,导致界面只显示“请求参数不合法”。 + +## 落地口径 + +1. 所有通过 `parseApiErrorMessage(...)` 解析的 API 错误,优先展示 `error.details.message`。 +2. 当 `error.details.message` 不存在或为空时,再回退到 `error.message`。 +3. 当 envelope 外层也不存在有效文案时,继续沿用原有的顶层 `message`、错误码和原始响应兜底逻辑。 +4. `unwrapApiResponse(...)` 处理 `ok: false` envelope 时也复用同一优先级,避免成功响应解析路径和 HTTP 非 2xx 路径展示不一致。 +5. Big Fish 结果页发布失败属于阻断性动作错误,展示为独立模态窗口,不再挤在结果页内容流里,关闭后只清掉当前错误状态,不改变草稿与资源数据。 + +## 验收 + +1. `big_fish_publish_game` 返回发布校验失败时,界面应显示 `big_fish 发布校验未通过:还缺少 16 个基础动作`。 +2. 没有 `details.message` 的旧错误响应仍显示原 `error.message`。 +3. 非 JSON 错误响应仍显示原始响应文本。 +4. Big Fish 结果页错误以居中模态窗口展示,并可通过关闭按钮回到结果页继续补资源。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 33314d47..71b524d2 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md](./API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md):记录统一 API envelope 错误展示优先读取 `error.details.message` 的修复口径,避免 Big Fish 发布校验等业务错误只显示通用“请求参数不合法”。 - [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。 - [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。 - [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。 diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 94e609ca..f4b5f700 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -143,7 +143,20 @@ export function unwrapApiResponse(value: ApiResponse | T): T { return value.data; } - throw new Error(value.error.message || '请求失败'); + throw new Error(getApiErrorDisplayMessage(value.error) || '请求失败'); +} + +function readTrimmedMessage(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +export function getApiErrorDisplayMessage(error: ApiErrorPayload) { + // 后端通用 message 常用于错误分类,details.message 才是给用户定位问题的业务原因。 + const detailMessage = isRecord(error.details) + ? readTrimmedMessage(error.details.message) + : ''; + + return detailMessage || readTrimmedMessage(error.message); } export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { @@ -158,11 +171,20 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) { error?: { message?: string; code?: string; + details?: Record | null; }; message?: string; code?: string; }; + const detailMessage = isRecord(parsed.error?.details) + ? readTrimmedMessage(parsed.error.details.message) + : ''; + + if (detailMessage) { + return detailMessage; + } + if ( typeof parsed.error?.message === 'string' && parsed.error.message.trim() diff --git a/src/components/big-fish-result/BigFishResultView.test.tsx b/src/components/big-fish-result/BigFishResultView.test.tsx index 2f8c883d..e1bca15e 100644 --- a/src/components/big-fish-result/BigFishResultView.test.tsx +++ b/src/components/big-fish-result/BigFishResultView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; @@ -146,4 +146,28 @@ describe('BigFishResultView', () => { expect(screen.getByAltText('荧潮幼体')).toBeTruthy(); expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy(); }); + + test('shows publish failures in a dismissible modal', () => { + const onDismissError = vi.fn(); + + render( + {}} + onDismissError={onDismissError} + onExecuteAction={() => {}} + onStartTestRun={() => {}} + />, + ); + + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getByText('发布失败')).toBeTruthy(); + expect( + screen.getByText('big_fish 发布校验未通过:还缺少 16 个基础动作'), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '知道了' })); + expect(onDismissError).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx index a9c4ec5e..27447760 100644 --- a/src/components/big-fish-result/BigFishResultView.tsx +++ b/src/components/big-fish-result/BigFishResultView.tsx @@ -37,6 +37,7 @@ type BigFishResultViewProps = { isBusy?: boolean; error?: string | null; onBack: () => void; + onDismissError?: () => void; onExecuteAction: (payload: ExecuteBigFishActionRequest) => void; onStartTestRun: () => void; }; @@ -330,6 +331,7 @@ export function BigFishResultView({ isBusy = false, error = null, onBack, + onDismissError, onExecuteAction, onStartTestRun, }: BigFishResultViewProps) { @@ -417,12 +419,6 @@ export function BigFishResultView({ - {error ? ( -
- {error} -
- ) : null} -
@@ -520,6 +516,58 @@ export function BigFishResultView({ }} /> ) : null} + + {error ? ( + { + onDismissError?.(); + }} + /> + ) : null} +
+ ); +} + +function BigFishResultErrorModal({ + message, + onClose, +}: { + message: string; + onClose: () => void; +}) { + return ( +
+
+
+
+ +
+
+
+ 发布失败 +
+
+ {message} +
+
+
+ +
); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b8cccedb..b4ee6fb0 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1820,6 +1820,9 @@ export function PlatformEntryFlowShellImpl({ onBack={() => { setSelectionStage('big-fish-agent-workspace'); }} + onDismissError={() => { + setBigFishError(null); + }} onExecuteAction={(payload) => { void executeBigFishAction(payload); }} diff --git a/src/editor/shared/jsonClient.test.ts b/src/editor/shared/jsonClient.test.ts index 54626055..492c924f 100644 --- a/src/editor/shared/jsonClient.test.ts +++ b/src/editor/shared/jsonClient.test.ts @@ -3,6 +3,29 @@ import {describe, expect, it} from 'vitest'; import {parseApiErrorMessage} from './jsonClient'; describe('parseApiErrorMessage', () => { + it('prefers api error detail messages for business failures', () => { + expect( + parseApiErrorMessage( + JSON.stringify({ + ok: false, + data: null, + error: { + code: 'BAD_REQUEST', + message: '请求参数不合法', + details: { + message: 'big_fish 发布校验未通过:还缺少 16 个基础动作', + provider: 'spacetimedb', + }, + }, + meta: { + apiVersion: '2026-04-08', + }, + }), + 'Fallback failure', + ), + ).toBe('big_fish 发布校验未通过:还缺少 16 个基础动作'); + }); + it('prefers nested api error messages', () => { expect( parseApiErrorMessage(