From e99714838fbbd896b86a6d5006a2df4bb02b056e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:06:18 +0800 Subject: [PATCH 1/5] fix: add big fish runtime settlement prompt --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 1 + .../big-fish-runtime/BigFishRuntimeShell.tsx | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index dfb720dc..00df4ac5 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -241,6 +241,7 @@ 1. 展示会话、草稿、资产槽位、运行快照。 2. 发送聊天、action 和摇杆输入。 3. 根据后端 snapshot 渲染实体。 +4. 当后端 snapshot 返回 `won` 或 `failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。 前端禁止: diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index e49471d1..10088c99 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -108,6 +108,24 @@ function resolveRuntimeEntityAsset( ); } +function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) { + if (run.status === 'won') { + return { + title: '通关完成', + message: `已成长到 Lv.${run.playerLevel},本轮生态征服完成。`, + tone: 'from-emerald-300/28 via-cyan-300/18 to-white/10', + }; + } + if (run.status === 'failed') { + return { + title: '本轮失败', + message: '己方鱼群已经耗尽,重新调整路线再来一次。', + tone: 'from-rose-300/30 via-orange-300/16 to-white/10', + }; + } + return null; +} + function BigFishEntityDot({ entity, run, @@ -241,6 +259,7 @@ export function BigFishRuntimeShell({ const statusLabel = run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中'; + const settlementCopy = resolveSettlementCopy(run); const backgroundAsset = findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null; @@ -298,6 +317,21 @@ export function BigFishRuntimeShell({ ))} + {settlementCopy ? ( +
+
+
+ {settlementCopy.title} +
+
+ {settlementCopy.message} +
+
+
+ ) : null} +
{isBusy ?
同步中...
: null} {error ?
{error}
: null} From dbf106c74698416e83afc4bf41d4e7122c8b00a5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:07:16 +0800 Subject: [PATCH 2/5] fix: restore puzzle next level flow --- ...E_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md | 12 +++- .../puzzle-runtime/puzzleLocalRuntime.test.ts | 69 +++++++++++++++++++ .../puzzle-runtime/puzzleLocalRuntime.ts | 59 ++++++++++++++-- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/services/puzzle-runtime/puzzleLocalRuntime.test.ts diff --git a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md index c2a78538..9fb0373d 100644 --- a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md +++ b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md @@ -15,7 +15,8 @@ 2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。 3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。 4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。 -5. 后端仍然负责: +5. 通关后的第一版接续只保证单次游玩闭环:本地生成一个临时 `recommendedNextProfileId`,点击“下一关”后沿用当前作品图片、作者和标签,重建下一关棋盘;正式的广场推荐池仍留给后端运行态版本恢复。 +6. 后端仍然负责: - Agent 会话 - 结果页草稿编译 - 正式候选图生成 @@ -47,6 +48,8 @@ 不能继续写到仓库本地 `public/generated-puzzle-covers/*`。 +这些路径只是前后端 DTO 里的兼容标识,不是浏览器可以直接裸读的公开资源地址。实际图片对象存放在私有 OSS 中,前端渲染前必须先通过 `/api/assets/read-url?legacyPublicPath=...` 换取签名读 URL;签名 URL 未返回或换签失败时,图片组件不能把 `/generated-puzzle-assets/*` 直接写入 ``,避免浏览器发起无签名、无鉴权请求。 + ### 4.2 运行态边界 第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。 @@ -55,7 +58,9 @@ 1. 进入玩法时从作品详情构造本地 `run` 2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run` -3. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 +3. 通关时本地写入临时下一关 id,用于显示“下一关”按钮 +4. 点击下一关时重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4` +5. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 ## 5. 当前实现判断标准 @@ -65,4 +70,5 @@ 2. 返回路径切到 `/generated-puzzle-assets/*`。 3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。 4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。 -5. 关闭玩法后不保留当前 run 进度。 +5. 玩家完成整张图后能看到通关态与“下一关”入口,点击后进入新棋盘。 +6. 关闭玩法后不保留当前 run 进度。 diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts new file mode 100644 index 00000000..be9ba9e5 --- /dev/null +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, +} from './puzzleLocalRuntime'; + +const baseWork: PuzzleWorkSummary = { + workId: 'work-1', + profileId: 'profile-1', + ownerUserId: 'user-1', + sourceSessionId: null, + authorDisplayName: '测试作者', + levelName: '测试拼图', + summary: '服务层测试用拼图。', + themeTags: ['测试', '拼图'], + coverImageSrc: '/generated-puzzle-assets/test.png', + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T00:00:00.000Z', + publishedAt: '2026-04-25T00:00:00.000Z', + playCount: 0, + publishReady: true, +}; + +function solveCurrentLevel(run: ReturnType) { + let nextRun = run; + for (let index = 0; index < 12; index += 1) { + const currentLevel = nextRun.currentLevel; + if (!currentLevel || currentLevel.status === 'cleared') { + return nextRun; + } + + const misplacedPiece = currentLevel.board.pieces.find( + (piece) => + piece.currentRow !== piece.correctRow || + piece.currentCol !== piece.correctCol, + ); + if (!misplacedPiece) { + return nextRun; + } + + nextRun = dragLocalPuzzlePiece(nextRun, { + pieceId: misplacedPiece.pieceId, + targetRow: misplacedPiece.correctRow, + targetCol: misplacedPiece.correctCol, + }); + } + return nextRun; +} + +describe('puzzleLocalRuntime', () => { + test('通关后提供下一关入口并能推进到新棋盘', () => { + const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork)); + + expect(clearedRun.currentLevel?.status).toBe('cleared'); + expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2'); + + const nextRun = advanceLocalPuzzleLevel(clearedRun); + + expect(nextRun.currentLevelIndex).toBe(2); + expect(nextRun.currentLevel?.status).toBe('playing'); + expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关'); + expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false); + expect(nextRun.recommendedNextProfileId).toBeNull(); + }); +}); diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index cfa15349..527a207f 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -89,17 +89,66 @@ function applyNextBoard( return run; } const status = nextBoard.allTilesResolved ? 'cleared' : 'playing'; + const nextClearedLevelCount = + status === 'cleared' && run.currentLevel.status !== 'cleared' + ? run.clearedLevelCount + 1 + : run.clearedLevelCount; return { ...run, - clearedLevelCount: - status === 'cleared' && run.currentLevel.status !== 'cleared' - ? run.clearedLevelCount + 1 - : run.clearedLevelCount, + clearedLevelCount: nextClearedLevelCount, currentLevel: { ...run.currentLevel, board: nextBoard, status, }, + recommendedNextProfileId: + status === 'cleared' + ? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1) + : run.recommendedNextProfileId, + }; +} + +function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) { + return `${entryProfileId}::local-level-${levelIndex}`; +} + +// 第一版单机玩法没有后端推荐池,本地沿用当前作品图片并生成可推进的临时关卡名。 +function buildLocalLevelName(previousLevelName: string, levelIndex: number) { + return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`; +} + +// 本地运行态只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。 +function buildNextLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { + const currentLevel = run.currentLevel; + if (!currentLevel || currentLevel.status !== 'cleared') { + return run; + } + + const nextLevelIndex = run.currentLevelIndex + 1; + const gridSize = resolvePuzzleGridSize(run.clearedLevelCount); + const nextProfileId = + run.recommendedNextProfileId ?? + buildLocalNextProfileId(run.entryProfileId, nextLevelIndex); + + return { + ...run, + currentLevelIndex: nextLevelIndex, + currentGridSize: gridSize, + playedProfileIds: run.playedProfileIds.includes(nextProfileId) + ? run.playedProfileIds + : [...run.playedProfileIds, nextProfileId], + previousLevelTags: currentLevel.themeTags, + currentLevel: { + ...currentLevel, + runId: run.runId, + levelIndex: nextLevelIndex, + gridSize, + profileId: nextProfileId, + levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex), + board: buildInitialBoard(gridSize), + status: 'playing', + }, + recommendedNextProfileId: null, }; } @@ -191,5 +240,5 @@ export function dragLocalPuzzlePiece( } export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { - return run; + return buildNextLocalLevel(run); } From 6f35306d5359882ccf10ea514e95e35df71f7418 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:10:40 +0800 Subject: [PATCH 3/5] fix: prevent unsigned generated asset image requests --- src/hooks/useResolvedAssetReadUrl.test.tsx | 108 +++++++++++++++++++++ src/hooks/useResolvedAssetReadUrl.ts | 12 ++- 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/hooks/useResolvedAssetReadUrl.test.tsx diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx new file mode 100644 index 00000000..4eac46a3 --- /dev/null +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; +import { clearStoredAccessToken, setStoredAccessToken } from '../services/apiClient'; +import { clearSignedAssetReadUrlCache } from '../services/assetReadUrlService'; + +describe('useResolvedAssetReadUrl', () => { + beforeEach(() => { + clearSignedAssetReadUrlCache(); + clearStoredAccessToken({ emit: false }); + setStoredAccessToken('test-access-token', { emit: false }); + vi.restoreAllMocks(); + }); + + afterEach(() => { + clearStoredAccessToken({ emit: false }); + }); + + test('generated 私有资源签名完成前不会把裸路径写入 img', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: + 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', + signedUrl: 'https://signed.example.com/puzzle.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + routeVersion: '2026-04-08', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ); + + render( + , + ); + + expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); + + const image = await screen.findByRole('img', { name: '候选图' }); + expect(image.getAttribute('src')).toBe('https://signed.example.com/puzzle.png'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png', + ); + }); + + test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + ok: false, + data: null, + error: { + code: 'NOT_FOUND', + message: '对象不存在', + }, + meta: { + apiVersion: '2026-04-08', + routeVersion: '2026-04-08', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ); + + render( + , + ); + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); + }); +}); + diff --git a/src/hooks/useResolvedAssetReadUrl.ts b/src/hooks/useResolvedAssetReadUrl.ts index 4fae92e6..ddb2044a 100644 --- a/src/hooks/useResolvedAssetReadUrl.ts +++ b/src/hooks/useResolvedAssetReadUrl.ts @@ -18,7 +18,9 @@ export function useResolvedAssetReadUrl( const normalizedSource = source?.trim() ?? ''; const shouldResolve = enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource); - const [resolvedUrl, setResolvedUrl] = useState(normalizedSource); + const [resolvedUrl, setResolvedUrl] = useState( + shouldResolve ? '' : normalizedSource, + ); useEffect(() => { if (!normalizedSource) { @@ -32,8 +34,8 @@ export function useResolvedAssetReadUrl( } let cancelled = false; - // 生成资源的签名 URL 还没回来前,先保留原始路径占位,避免结果页/运行时首屏出现空白图块。 - setResolvedUrl(normalizedSource); + // 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。 + setResolvedUrl(''); void resolveAssetReadUrl(normalizedSource, { expireSeconds: options.expireSeconds, @@ -45,8 +47,8 @@ export function useResolvedAssetReadUrl( }) .catch(() => { if (!cancelled) { - // 读取签名失败时回退原始路径,至少保持现有 UI 可见错误表象。 - setResolvedUrl(normalizedSource); + // 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。 + setResolvedUrl(''); } }); From 8ec19e236408ec6d1368e9f6ebe2f938c35d7c95 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:11:26 +0800 Subject: [PATCH 4/5] fix: show big fish publish errors in modal --- ..._DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md | 37 ++++++++++++ docs/technical/README.md | 1 + packages/shared/src/http.ts | 24 +++++++- .../BigFishResultView.test.tsx | 26 +++++++- .../big-fish-result/BigFishResultView.tsx | 60 +++++++++++++++++-- .../PlatformEntryFlowShellImpl.tsx | 3 + src/editor/shared/jsonClient.test.ts | 23 +++++++ 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 docs/technical/API_ERROR_DETAILS_MESSAGE_DISPLAY_FIX_2026-04-25.md 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( From 1b2daf47962b95d9269f1088aeb5d470c3dee6f3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 25 Apr 2026 14:18:40 +0800 Subject: [PATCH 5/5] fix creation chat initial progress --- ...HAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md | 1 + .../CreationAgentWorkspace.test.tsx | 31 +++++++++++++++++++ .../creation-agent/CreationAgentWorkspace.tsx | 11 +++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md index 698e5902..16bd8907 100644 --- a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -308,6 +308,7 @@ 2. 壳层文件内不再直接包含自动保存防抖实现。 3. 壳层文件内不再直接包含 session -> result profile 编译细节。 4. 壳层文件内不再直接处理 works/library/history/save 的多路请求编排。 +5. Agent 聊天工作区进入时必须如实展示后端 session 的 `progressPercent`。新会话后端初始值为 `0` 时,前端数字与进度条填充都必须保持 `0%`;只允许对大于 0 的值使用视觉最小宽度,避免用户误判聊天流程已经推进。 ## 5.2 custom world 专属 client 拆分方案 diff --git a/src/components/creation-agent/CreationAgentWorkspace.test.tsx b/src/components/creation-agent/CreationAgentWorkspace.test.tsx index 3d5d6192..66d5b7e4 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.test.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.test.tsx @@ -32,6 +32,37 @@ function ensureScrollApis() { } } +test('creation agent workspace keeps initial chat progress at zero percent', () => { + ensureScrollApis(); + + render( + {}} + onSubmitText={() => {}} + onPrimaryAction={() => {}} + />, + ); + + const progressbar = screen.getByRole('progressbar'); + + expect(progressbar.getAttribute('aria-valuenow')).toBe('0'); + expect((progressbar.firstElementChild as HTMLElement | null)?.style.width).toBe( + '0%', + ); +}); + test('creation agent workspace filters duplicate recommended replies', () => { const consoleErrorSpy = vi .spyOn(console, 'error') diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index ccd54771..042a8441 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -311,6 +311,7 @@ export function CreationAgentWorkspace({ } const progress = normalizeCreationAgentProgress(session.progressPercent); + const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`; const hasHeroCopy = Boolean(session.title || session.assistantSummary); const canShowPrimaryAction = progress >= 100; const visibleQuickActions = quickActions.filter((action) => @@ -414,10 +415,16 @@ export function CreationAgentWorkspace({ {progress}%
-
+