fix: show big fish publish errors in modal
This commit is contained in:
@@ -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 结果页错误以居中模态窗口展示,并可通过关闭按钮回到结果页继续补资源。
|
||||
@@ -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` 和本地占位运行态的调试边界。
|
||||
|
||||
@@ -143,7 +143,20 @@ export function unwrapApiResponse<T>(value: ApiResponse<T> | 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<string, unknown> | 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()
|
||||
|
||||
@@ -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(
|
||||
<BigFishResultView
|
||||
session={createSession()}
|
||||
error="big_fish 发布校验未通过:还缺少 16 个基础动作"
|
||||
onBack={() => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -520,6 +516,58 @@ export function BigFishResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<BigFishResultErrorModal
|
||||
message={error}
|
||||
onClose={() => {
|
||||
onDismissError?.();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigFishResultErrorModal({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="big-fish-result-error-title"
|
||||
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
|
||||
<Waves className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
id="big-fish-result-error-title"
|
||||
className="text-base font-black text-slate-950"
|
||||
>
|
||||
发布失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1820,6 +1820,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={() => {
|
||||
setSelectionStage('big-fish-agent-workspace');
|
||||
}}
|
||||
onDismissError={() => {
|
||||
setBigFishError(null);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeBigFishAction(payload);
|
||||
}}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user