From 26975644b5cd43cc52a6ec0eebbab4c8ba9b40dc Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 30 May 2026 05:05:02 +0800 Subject: [PATCH 1/3] feat: unify phase one creation flow --- .gitignore | 1 + .hermes/shared-memory/development-workflow.md | 5 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- quality-gates/README.md | 18 + ...发运维】dev-stack状态文件门禁-2026-05-29.md | 16 + .../【玩法创作】统一创作页门禁-2026-05-29.md | 16 + .../【玩法创作】统一生成页门禁-2026-05-29.md | 16 + scripts/dev.mjs | 287 +++++++- scripts/dev.test.ts | 75 +++ .../api-server/src/edutainment_baby_object.rs | 1 + .../crates/api-server/src/match3d/draft.rs | 110 ++- .../crates/api-server/src/match3d/mappers.rs | 41 ++ .../crates/api-server/src/match3d/tests.rs | 42 ++ server-rs/crates/api-server/src/state.rs | 4 + .../crates/api-server/src/wooden_fish.rs | 121 +++- .../crates/module-runtime/src/application.rs | 32 +- .../src/creation_entry_config.rs | 109 +++ .../src/mapper/wooden_fish.rs | 38 +- .../spacetime-client/src/wooden_fish.rs | 179 ++++- .../spacetime-module/src/wooden_fish.rs | 3 + .../spacetime-module/src/wooden_fish/types.rs | 1 + .../common/CreativeAudioInputPanel.tsx | 206 ++++++ .../PlatformEntryFlowShellImpl.tsx | 637 ++++++++++-------- .../UnifiedCreationPage.test.tsx | 42 ++ .../unified-creation/UnifiedCreationPage.tsx | 44 ++ .../UnifiedGenerationPage.test.tsx | 53 ++ .../UnifiedGenerationPage.tsx | 91 +++ .../unifiedCreationSpecs.test.ts | 42 ++ .../unified-creation/unifiedCreationSpecs.ts | 107 +++ .../WoodenFishWorkspace.tsx | 187 +---- src/services/creationEntryConfigService.ts | 23 + .../miniGameDraftGenerationProgress.ts | 21 +- 33 files changed, 2037 insertions(+), 539 deletions(-) create mode 100644 quality-gates/README.md create mode 100644 quality-gates/【开发运维】dev-stack状态文件门禁-2026-05-29.md create mode 100644 quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md create mode 100644 quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md create mode 100644 src/components/common/CreativeAudioInputPanel.tsx create mode 100644 src/components/unified-creation/UnifiedCreationPage.test.tsx create mode 100644 src/components/unified-creation/UnifiedCreationPage.tsx create mode 100644 src/components/unified-creation/UnifiedGenerationPage.test.tsx create mode 100644 src/components/unified-creation/UnifiedGenerationPage.tsx create mode 100644 src/components/unified-creation/unifiedCreationSpecs.test.ts create mode 100644 src/components/unified-creation/unifiedCreationSpecs.ts diff --git a/.gitignore b/.gitignore index c90efe5c..2885233d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ temp*build*/ /public/generated-character-drafts /public/generated-characters /.codex-temp +/.app/ /target/ /logs /server-rs/crates/*/logs/ diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 3710e85b..23e61f20 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -57,6 +57,8 @@ npm run dev - 主站 Vite - 后台 Vite +`npm run dev` 和单模块 `dev:*` 命令会更新根目录 `.app/dev-stack.json`,记录四个本地服务的 pid、端口、URL、启动状态和当前命令。该目录只作本机运行态观测,不提交 Git。 + 开启自动刷新: ```bash @@ -240,6 +242,7 @@ npm run check:server-rs-ddd - 移动端优先,再兼容网页端。 - 页面只展示后端返回的状态,不自行计算结论型业务状态。 - 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。 +- 一期统一创作页字段 spec 同样跟随 `GET /api/creation-entry/config`,由 `creationTypes[].unifiedCreationSpec` 下发;拼图、抓大鹅、敲木鱼之外的模板不接入该扩展位,前端只保留旧后端缺字段时的兜底默认。 - 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 - 不在 UI 中默认写功能说明类文本。 - 弹出独立面板的交互不要实现成在当前面板下方追加内容。 @@ -253,6 +256,8 @@ npm run check:server-rs-ddd ## 提交前建议让 Hermes 执行 +涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路或本地 dev 栈时,先按 `quality-gates/README.md` 和对应门禁文档执行自动脚本与体验检查。 + ```text 请检查当前 git diff,指出: 1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定; diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index befe256c..d04666dd 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -31,6 +31,8 @@ npm run dev - 主站 Vite。 - 后台 Vite。 +`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。 + 单独启动主站前端: ```bash @@ -90,6 +92,8 @@ npm run build npm run check:content ``` +一期创作流程统一化新增 `quality-gates/` 提交前门禁。涉及拼图、抓大鹅、敲木鱼统一创作页、统一生成页或 dev 栈启动脚本时,先执行 `quality-gates/README.md` 列出的脚本,再按对应门禁文档完成体验检查。 + 综合检查: ```bash diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index d5207f03..203eb5b1 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,7 +8,9 @@ 当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡;点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛` 和 `抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。 -创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页恢复时只认当前进入页的时间作为新的 `startedAtMs`,作品摘要里的 `updatedAt` 只用于排序与摘要展示,不再作为生成进度起点。 +创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 + +一期创作流程统一化只覆盖拼图、抓大鹅和敲木鱼。三者在前端统一经过 `UnifiedCreationPage` 和 `UnifiedGenerationPage`:创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;首期字段类型只保留 `text`、`select`、`image`、`audio`。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。视觉小说、`airp`、`component`、汪汪声浪、方洞、大鱼、跳一跳和宝贝识物不进入一期接线范围,已有链路保持现状。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 diff --git a/quality-gates/README.md b/quality-gates/README.md new file mode 100644 index 00000000..9fe983fe --- /dev/null +++ b/quality-gates/README.md @@ -0,0 +1,18 @@ +# 提交前质量门禁 + +本目录记录一期创作流程统一化提交前必须执行的检查。门禁既可以是自动脚本,也可以是需要人工确认的体验流程。 + +## 一期必跑 + +- [【玩法创作】统一创作页门禁-2026-05-29.md](./【玩法创作】统一创作页门禁-2026-05-29.md) +- [【玩法创作】统一生成页门禁-2026-05-29.md](./【玩法创作】统一生成页门禁-2026-05-29.md) +- [【开发运维】dev-stack状态文件门禁-2026-05-29.md](./【开发运维】dev-stack状态文件门禁-2026-05-29.md) + +## 基线脚本 + +```bash +npm run check:encoding +npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx +npm run test -- scripts/dev.test.ts +node --check scripts/dev.mjs +``` diff --git a/quality-gates/【开发运维】dev-stack状态文件门禁-2026-05-29.md b/quality-gates/【开发运维】dev-stack状态文件门禁-2026-05-29.md new file mode 100644 index 00000000..b136321b --- /dev/null +++ b/quality-gates/【开发运维】dev-stack状态文件门禁-2026-05-29.md @@ -0,0 +1,16 @@ +# dev-stack 状态文件门禁 + +## 自动检查 + +```bash +node --check scripts/dev.mjs +npm run test -- scripts/dev.test.ts +``` + +## 体验检查 + +- `npm run dev` 启动后根目录生成 `.app/dev-stack.json`。 +- `npm run dev:web`、`npm run dev:api-server`、`npm run dev:spacetime`、`npm run dev:admin-web` 单独启动时会更新对应服务状态。 +- 文件包含 `schemaVersion`、`command`、`repoRoot`、`database`、`updatedAt` 和四个服务条目。 +- 每个服务条目包含 `status`、`pid`、`host`、`port`、`url`、`command`、`updatedAt`。 +- 端口漂移后文件记录实际端口;服务退出或重启后 PID 与状态会更新。 diff --git a/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md b/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md new file mode 100644 index 00000000..bd3d7d6c --- /dev/null +++ b/quality-gates/【玩法创作】统一创作页门禁-2026-05-29.md @@ -0,0 +1,16 @@ +# 统一创作页门禁 + +## 自动检查 + +```bash +npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts +npm run test -- src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx +``` + +## 体验检查 + +- 拼图、抓大鹅、敲木鱼均从 `/creation/` 进入创作工作台。 +- 三条链路都经过 `UnifiedCreationPage`,字段 spec 只包含 `text`、`select`、`image`、`audio` 四类。 +- 拼图参考图仍走 `CreativeImageInputPanel`,不新增专属上传入口。 +- 敲木鱼音频槽位走 `CreativeAudioInputPanel`,上传、录音、重置和默认音效状态可用。 +- 抓大鹅难度只显示轻松、标准、进阶、硬核四档,提交 payload 仍派生 `clearCount` 和 `difficulty`。 diff --git a/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md b/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md new file mode 100644 index 00000000..97626cb0 --- /dev/null +++ b/quality-gates/【玩法创作】统一生成页门禁-2026-05-29.md @@ -0,0 +1,16 @@ +# 统一生成页门禁 + +## 自动检查 + +```bash +npm run test -- src/components/unified-creation/UnifiedGenerationPage.test.tsx +npm run test -- src/services/miniGameDraftGenerationProgress.test.ts +``` + +## 体验检查 + +- `/creation/puzzle/generating`、`/creation/match3d/generating`、`/creation/wooden-fish/generating` 均渲染 `UnifiedGenerationPage`。 +- 生成页展示后端阶段、当前步骤、总进度、错误和重试动作。 +- 等待时间以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 时间戳。 +- 失败后生成页保持可见错误和重试入口,作品架恢复时不得只依赖前端内存 notice。 +- 不为一期新增客户端直连 SpacetimeDB 状态通道。 diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 13cbb831..91955c8c 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -26,6 +26,7 @@ import { } from './dev-utils.mjs'; const repoRoot = process.cwd(); +const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); const serverRsDir = resolve(repoRoot, 'server-rs'); const manifestPath = resolve(serverRsDir, 'Cargo.toml'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); @@ -245,6 +246,136 @@ function normalizeServiceName(rawName) { throw new Error(`未知模块: ${rawName}`); } +function resolveDevStackStatePath(root = repoRoot) { + return resolve(root, '.app/dev-stack.json'); +} + +function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) { + const services = {}; + for (const serviceName of SERVICE_NAMES) { + services[serviceName] = buildDevStackServiceSnapshot( + runner, + serviceName, + updatedAt, + ); + } + + return { + schemaVersion: 1, + command: runner.command ?? 'all', + repoRoot, + database: runner.options.database, + watch: Boolean(runner.options.watch), + updatedAt, + services, + }; +} + +function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) { + const service = runner.services.get(serviceName); + const runtime = service?.runtime ?? {}; + const endpoint = resolveDevStackServiceEndpoint(runner, serviceName); + const isReusedSpacetime = + serviceName === 'spacetime' && runner.state.spacetimeReused; + const runtimeStatus = runtime.status ?? 'idle'; + const status = + runtimeStatus === 'idle' && isReusedSpacetime ? 'reused' : runtimeStatus; + const childPid = + service?.child && Number.isInteger(service.child.pid) + ? service.child.pid + : null; + + return { + status, + pid: + childPid ?? + runtime.pid ?? + (isReusedSpacetime ? (runner.state.spacetimePid ?? null) : null), + host: runtime.host ?? endpoint.host, + port: runtime.port ?? endpoint.port, + url: runtime.url ?? endpoint.url, + command: runtime.command ?? resolveDevStackServiceCommand(runner, serviceName), + startedAt: runtime.startedAt ?? null, + updatedAt: runtime.updatedAt ?? updatedAt, + exitCode: runtime.exitCode ?? null, + signal: runtime.signal ?? null, + }; +} + +function resolveDevStackServiceEndpoint(runner, serviceName) { + const {options, state} = runner; + switch (serviceName) { + case 'spacetime': + return { + host: options.spacetimeHost, + port: options.spacetimePort, + url: state.spacetimeServer, + }; + case 'api-server': + return { + host: options.apiHost, + port: options.apiPort, + url: state.apiTarget, + }; + case 'web': + return { + host: options.webHost, + port: options.webPort, + url: `http://${resolveClientHost(options.webHost)}:${options.webPort}`, + }; + case 'admin-web': + return { + host: options.adminWebHost, + port: options.adminWebPort, + url: `http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`, + }; + default: + return { + host: null, + port: null, + url: null, + }; + } +} + +function resolveDevStackServiceCommand(runner, serviceName) { + const {options, state} = runner; + switch (serviceName) { + case 'spacetime': + return state.spacetimeReused + ? `reuse spacetime standalone ${state.spacetimeServer}` + : [ + 'spacetime', + 'start', + '--data-dir', + options.spacetimeDataDir, + '--listen-addr', + `${options.spacetimeHost}:${options.spacetimePort}`, + '--non-interactive', + ].join(' '); + case 'api-server': + return 'cargo run -p api-server --manifest-path server-rs/Cargo.toml'; + case 'web': + return [ + 'node', + relative(repoRoot, viteCliPath), + `--port=${options.webPort}`, + `--host=${options.webHost}`, + '--strictPort', + ].join(' '); + case 'admin-web': + return [ + 'node', + relative(adminWebDir, viteCliPath), + `--host=${options.adminWebHost}`, + `--port=${options.adminWebPort}`, + '--strictPort', + ].join(' '); + default: + return null; + } +} + function requireCommand(command) { const result = spawnSync(command, ['--version'], { cwd: repoRoot, @@ -374,14 +505,37 @@ function ensureRequiredFiles(command) { } class DevService { - constructor(name, startFn) { + constructor(name, startFn, onStateChange = null) { this.name = name; this.startFn = startFn; + this.onStateChange = onStateChange; this.child = null; this.children = []; this.logStream = null; this.stopping = false; this.restartTimer = null; + this.runtime = { + status: 'idle', + pid: null, + host: null, + port: null, + url: null, + command: null, + startedAt: null, + updatedAt: null, + exitCode: null, + signal: null, + }; + } + + updateRuntimeState(patch) { + const updatedAt = new Date().toISOString(); + this.runtime = { + ...this.runtime, + ...patch, + updatedAt, + }; + this.onStateChange?.(); } async start() { @@ -389,17 +543,36 @@ class DevService { return; } - await this.startFn(this); + this.updateRuntimeState({status: 'starting', exitCode: null, signal: null}); + try { + await this.startFn(this); + } catch (error) { + this.updateRuntimeState({status: 'failed', pid: null}); + throw error; + } } registerChild(child) { this.child = child; + this.updateRuntimeState({ + status: 'running', + pid: Number.isInteger(child.pid) ? child.pid : null, + exitCode: null, + signal: null, + startedAt: this.runtime.startedAt ?? new Date().toISOString(), + }); child.on('exit', (code, signal) => { if (this.logStream && !this.logStream.destroyed) { this.logStream.end(); } this.child = null; + this.updateRuntimeState({ + status: this.stopping ? 'stopped' : code === 0 ? 'stopped' : 'failed', + pid: null, + exitCode: code ?? null, + signal: signal ?? null, + }); if (this.stopping) { this.stopping = false; return; @@ -418,6 +591,9 @@ class DevService { const processes = [this.child, ...this.children].filter(Boolean); this.stopping = processes.length > 0; + if (processes.length > 0) { + this.updateRuntimeState({status: 'stopping'}); + } this.child = null; this.children = []; @@ -430,6 +606,9 @@ class DevService { } this.logStream = null; this.stopping = false; + if (processes.length > 0) { + this.updateRuntimeState({status: 'stopped', pid: null}); + } } scheduleRestart(delayMs = 250, restartFn = null, actionLabel = '重启') { @@ -576,6 +755,7 @@ class DevRunner { await this.resolvePorts(command); this.registerServices(); this.printSummary(command); + this.writeDevStackState(); } shouldValidateSpacetimeToolVersion(command) { @@ -642,6 +822,9 @@ class DevRunner { } this.state.spacetimeServer = candidate; this.state.spacetimeReused = true; + this.state.spacetimePid = Number.isInteger(pidState.pid) + ? pidState.pid + : null; const pidLabel = Number.isInteger(pidState.pid) ? ` pid=${pidState.pid}` : ''; console.log(`[dev:spacetime] 复用已启动实例${pidLabel}: ${candidate}`); return; @@ -727,19 +910,48 @@ class DevRunner { } registerServices() { + const onStateChange = () => this.writeDevStackState(); this.services.set( 'spacetime', - new DevService('spacetime', async (service) => this.startSpacetime(service)), + new DevService( + 'spacetime', + async (service) => this.startSpacetime(service), + onStateChange, + ), ); this.services.set( 'api-server', - new DevService('api-server', async (service) => this.startApiServer(service)), + new DevService( + 'api-server', + async (service) => this.startApiServer(service), + onStateChange, + ), + ); + this.services.set( + 'web', + new DevService('web', async (service) => this.startWeb(service), onStateChange), ); - this.services.set('web', new DevService('web', async (service) => this.startWeb(service))); this.services.set( 'admin-web', - new DevService('admin-web', async (service) => this.startAdminWeb(service)), + new DevService( + 'admin-web', + async (service) => this.startAdminWeb(service), + onStateChange, + ), ); + + if (this.state.spacetimeReused) { + const spacetimeService = this.services.get('spacetime'); + const endpoint = resolveDevStackServiceEndpoint(this, 'spacetime'); + spacetimeService?.updateRuntimeState({ + status: 'reused', + pid: this.state.spacetimePid ?? null, + host: endpoint.host, + port: endpoint.port, + url: endpoint.url, + command: resolveDevStackServiceCommand(this, 'spacetime'), + }); + } } printSummary(command) { @@ -754,6 +966,16 @@ class DevRunner { console.log(`[dev] database: ${options.database}`); } + writeDevStackState() { + try { + ensureParentDir(devStackStatePath); + const snapshot = buildDevStackSnapshot(this); + writeFileSync(devStackStatePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); + } catch (error) { + console.warn(`[dev] 写入 ${devStackStatePath} 失败: ${error.message}`); + } + } + async startCommand(command) { if (command === 'all') { await this.startSpacetimeForFullStack(); @@ -827,6 +1049,17 @@ class DevRunner { ); console.log(`[dev:spacetime] log: ${logFile}`); + service.updateRuntimeState({ + status: 'starting', + pid: null, + host: options.spacetimeHost, + port: options.spacetimePort, + url: this.state.spacetimeServer, + command: resolveDevStackServiceCommand(this, 'spacetime'), + startedAt: new Date().toISOString(), + exitCode: null, + signal: null, + }); const env = { ...this.baseEnv, }; @@ -880,6 +1113,11 @@ class DevRunner { this.options.spacetimePort = port; this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${port}`; recordSpacetimeUrl(this.options.spacetimeDataDir, this.state.spacetimeServer); + this.services.get('spacetime')?.updateRuntimeState({ + host: this.options.spacetimeHost, + port, + url: this.state.spacetimeServer, + }); console.log(`[dev:spacetime] actual: ${this.state.spacetimeServer}`); } @@ -976,6 +1214,17 @@ class DevRunner { console.log( `[dev:api-server] SpacetimeDB ${this.options.database} @ ${this.state.spacetimeServer}`, ); + service.updateRuntimeState({ + status: 'starting', + pid: null, + host: this.options.apiHost, + port: this.options.apiPort, + url: this.state.apiTarget, + command: resolveDevStackServiceCommand(this, 'api-server'), + startedAt: new Date().toISOString(), + exitCode: null, + signal: null, + }); const child = spawn( 'cargo', @@ -1018,6 +1267,7 @@ class DevRunner { startWeb(service) { const apiTarget = this.resolveFrontendApiTarget(); + const endpoint = resolveDevStackServiceEndpoint(this, 'web'); const env = { ...this.baseEnv, RUST_SERVER_TARGET: apiTarget, @@ -1028,6 +1278,17 @@ class DevRunner { }; console.log(`[dev:web] api target: ${apiTarget}`); + service.updateRuntimeState({ + status: 'starting', + pid: null, + host: endpoint.host, + port: endpoint.port, + url: endpoint.url, + command: resolveDevStackServiceCommand(this, 'web'), + startedAt: new Date().toISOString(), + exitCode: null, + signal: null, + }); const child = spawn( 'node', [ @@ -1053,6 +1314,7 @@ class DevRunner { startAdminWeb(service) { const apiTarget = this.resolveFrontendApiTarget({admin: true}); + const endpoint = resolveDevStackServiceEndpoint(this, 'admin-web'); const env = { ...this.baseEnv, ADMIN_API_TARGET: apiTarget, @@ -1061,6 +1323,17 @@ class DevRunner { }; console.log(`[dev:admin-web] api target: ${apiTarget}`); + service.updateRuntimeState({ + status: 'starting', + pid: null, + host: endpoint.host, + port: endpoint.port, + url: endpoint.url, + command: resolveDevStackServiceCommand(this, 'admin-web'), + startedAt: new Date().toISOString(), + exitCode: null, + signal: null, + }); const child = spawn( 'node', [ @@ -1731,12 +2004,14 @@ export { assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, + buildDevStackSnapshot, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, isSpacetimePublishPermissionError, parseSpacetimeToolVersion, parseArgs, + resolveDevStackStatePath, shouldAcceptWatchEvent, }; diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 341cde80..b281dd43 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -9,12 +9,14 @@ import { assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, + buildDevStackSnapshot, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, isSpacetimePublishPermissionError, parseSpacetimeToolVersion, parseArgs, + resolveDevStackStatePath, shouldAcceptWatchEvent, } from './dev.mjs'; @@ -105,6 +107,79 @@ describe('dev scheduler api-server env', () => { }); }); +describe('dev scheduler stack state file', () => { + test('状态文件路径固定在根目录 .app/dev-stack.json', () => { + expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe( + join('C:\\repo\\Genarrative', '.app/dev-stack.json'), + ); + }); + + test('状态快照记录服务 pid、端口、URL 和当前命令', () => { + const updatedAt = '2026-05-29T00:00:00.000Z'; + const runner = { + command: 'web', + options: { + apiHost: '127.0.0.1', + apiPort: 8090, + webHost: '0.0.0.0', + webPort: 3010, + adminWebHost: '127.0.0.1', + adminWebPort: 3110, + spacetimeHost: '127.0.0.1', + spacetimePort: 3120, + spacetimeDataDir: 'server-rs/.spacetimedb/local/data', + database: 'genarrative-test', + watch: false, + }, + state: { + apiTarget: 'http://127.0.0.1:8090', + adminWebTargetHost: '127.0.0.1', + spacetimeServer: 'http://127.0.0.1:3120', + }, + services: new Map([ + [ + 'web', + { + child: {pid: 4321}, + runtime: { + status: 'running', + pid: 4321, + host: '0.0.0.0', + port: 3010, + url: 'http://127.0.0.1:3010', + command: 'node scripts/vite-cli.mjs --port=3010', + startedAt: updatedAt, + updatedAt, + exitCode: null, + signal: null, + }, + }, + ], + ]), + }; + + const snapshot = buildDevStackSnapshot(runner, updatedAt); + + expect(snapshot.schemaVersion).toBe(1); + expect(snapshot.command).toBe('web'); + expect(snapshot.database).toBe('genarrative-test'); + expect(snapshot.services.web).toMatchObject({ + status: 'running', + pid: 4321, + host: '0.0.0.0', + port: 3010, + url: 'http://127.0.0.1:3010', + command: 'node scripts/vite-cli.mjs --port=3010', + }); + expect(snapshot.services['api-server']).toMatchObject({ + status: 'idle', + pid: null, + port: 8090, + url: 'http://127.0.0.1:8090', + }); + }); +}); + describe('dev scheduler spacetime reuse guard', () => { test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-')); diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index dda85bf5..abfb5349 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -1052,6 +1052,7 @@ mod tests { external_api_audit_state: None, external_api_audit_user_id: None, external_api_audit_profile_id: None, + external_api_audit_request_id: None, }); assert_eq!( diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index 79a0aa77..98e8a8b2 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -163,7 +163,14 @@ pub(super) async fn compile_match3d_draft_for_session( .clone() .unwrap_or_else(|| fallback_work_metadata.tags.clone()); let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); - execute_billable_match3d_draft_generation( + let compile_session_id = session_id.clone(); + let compile_owner_user_id = owner_user_id.clone(); + let compile_profile_id = profile_id.clone(); + let compile_initial_game_name = initial_game_name.clone(); + let compile_requested_summary = requested_summary.clone(); + let compile_initial_tags = initial_tags.clone(); + let compile_requested_cover_image_src = requested_cover_image_src.clone(); + let result = execute_billable_match3d_draft_generation( state, request_context, owner_user_id.as_str(), @@ -307,7 +314,108 @@ pub(super) async fn compile_match3d_draft_for_session( Ok((next_session, generated_item_assets)) }, ) + .await; + + if let Err(response) = result.as_ref() + && response.status().is_server_error() + { + let failure_message = match3d_response_failure_message(response); + persist_failed_match3d_draft_generation( + state, + request_context, + authenticated, + compile_session_id, + compile_owner_user_id, + compile_profile_id, + compile_initial_game_name, + compile_requested_summary, + compile_initial_tags, + compile_requested_cover_image_src, + failure_message, + ) + .await; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn persist_failed_match3d_draft_generation( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + owner_user_id: String, + profile_id: String, + game_name: String, + summary: Option, + tags: Vec, + cover_image_src: Option, + failure_message: String, +) { + let failure_assets_json = serialize_match3d_failed_generation_assets(failure_message.as_str()); + if let Err(persist_error) = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + Some(game_name), + summary.or_else(|| Some(String::new())), + Some(serde_json::to_string(&tags).unwrap_or_default()), + cover_image_src, + None, + failure_assets_json, + ) .await + { + tracing::error!( + provider = MATCH3D_AGENT_PROVIDER, + status = ?persist_error.status(), + "抓大鹅草稿生成失败后的状态回写失败" + ); + } +} + +fn serialize_match3d_failed_generation_assets(message: &str) -> Option { + let background_asset = Match3DGeneratedBackgroundAsset { + prompt: String::new(), + status: "failed".to_string(), + error: Some(message.trim().to_string()), + ..Default::default() + }; + let assets = vec![Match3DGeneratedItemAssetJson { + item_id: "match3d-generation-failure".to_string(), + item_name: "生成失败".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), + image_src: None, + image_object_key: None, + image_views: Vec::new(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: Some(background_asset), + status: "failed".to_string(), + error: Some(message.trim().to_string()), + }]; + serde_json::to_string(&assets).ok() +} + +fn match3d_response_failure_message(response: &Response) -> String { + response + .extensions() + .get::() + .cloned() + .unwrap_or_else(|| format!("抓大鹅草稿生成失败,HTTP {}", response.status())) } /// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。 diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index 3fe49eb6..7ef007b8 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -453,6 +453,32 @@ fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) - || match3d_text_present(asset.container_image_object_key.as_ref()) } +fn match3d_asset_status_is_failure(status: &str) -> bool { + let normalized = status.trim().to_ascii_lowercase().replace(['-', ' '], "_"); + matches!( + normalized.as_str(), + "failed" | "failure" | "error" | "partial_failed" + ) +} + +fn match3d_error_present(value: Option<&String>) -> bool { + value.is_some_and(|value| !value.trim().is_empty()) +} + +fn match3d_item_asset_has_failure(asset: &Match3DGeneratedItemAssetJson) -> bool { + match3d_asset_status_is_failure(asset.status.as_str()) + || match3d_error_present(asset.error.as_ref()) + || asset.background_asset.as_ref().is_some_and(|background| { + match3d_asset_status_is_failure(background.status.as_str()) + || match3d_error_present(background.error.as_ref()) + }) +} + +fn match3d_background_asset_has_failure(asset: &Match3DGeneratedBackgroundAsset) -> bool { + match3d_asset_status_is_failure(asset.status.as_str()) + || match3d_error_present(asset.error.as_ref()) +} + fn resolve_match3d_work_generation_status( item: &Match3DWorkProfileRecord, assets: &[Match3DGeneratedItemAssetJson], @@ -462,6 +488,21 @@ fn resolve_match3d_work_generation_status( return Some("ready".to_string()); } + let has_failure = assets.iter().any(match3d_item_asset_has_failure) + || background_asset.is_some_and(match3d_background_asset_has_failure); + if has_failure { + let has_partial_result = assets.iter().any(match3d_item_asset_has_image) + || background_asset.is_some_and(match3d_background_asset_has_image); + return Some( + if has_partial_result { + "partial_failed" + } else { + "failed" + } + .to_string(), + ); + } + if assets.is_empty() || !assets.iter().any(match3d_item_asset_has_image) || !background_asset.is_some_and(match3d_background_asset_has_image) diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 425fcb69..226be1f1 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1842,3 +1842,45 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() { assert_eq!(response.generation_status.as_deref(), Some("ready")); } + +#[test] +fn match3d_work_summary_marks_failed_generated_assets_failed() { + let assets = vec![Match3DGeneratedItemAsset { + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "水果厨房背景".to_string(), + status: "failed".to_string(), + error: Some("VectorEngine 请求失败".to_string()), + ..Default::default() + }), + status: "failed".to_string(), + error: Some("VectorEngine 请求失败".to_string()), + ..test_match3d_generated_item_asset(1, "草莓") + }]; + let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { + work_id: "match3d-profile-1".to_string(), + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("match3d-session-1".to_string()), + author_display_name: "玩家".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + cover_asset_id: None, + reference_image_src: None, + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-10T00:00:00.000Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), + }); + + assert_eq!( + response.generation_status.as_deref(), + Some("partial_failed") + ); +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 59d47d45..8d70b13e 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -555,6 +555,10 @@ impl AppState { .to_string(), category_sort_order: 0, updated_at_micros: 0, + unified_creation_spec: + shared_contracts::creation_entry_config::build_phase1_unified_creation_spec( + creation_type_id, + ), }, ); } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a181489e..ae0cc415 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -147,26 +147,34 @@ pub async fn execute_wooden_fish_action( wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); let author_display_name = resolve_author_display_name(&state, &authenticated); - maybe_generate_hit_object_asset( + let result = execute_wooden_fish_action_with_generated_assets( &state, &request_context, &session_id, - owner_user_id.as_str(), + &owner_user_id, + &author_display_name, &mut payload, ) - .await?; - maybe_generate_hit_sound_asset(&mut payload); - let response = state - .spacetime_client() - .execute_wooden_fish_action(session_id, owner_user_id, author_display_name, payload) - .await - .map_err(|error| { - wooden_fish_error_response( - &request_context, - WOODEN_FISH_CREATION_PROVIDER, - map_wooden_fish_client_error(error), - ) - })?; + .await; + if result + .as_ref() + .err() + .is_some_and(|response| response.status().is_server_error()) + && matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ) + { + mark_wooden_fish_generation_failed( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + author_display_name.as_str(), + ) + .await; + } + let response = result?; Ok(json_success_body(Some(&request_context), response)) } @@ -372,16 +380,24 @@ async fn build_wooden_fish_draft( payload: &WoodenFishWorkspaceCreateRequest, state: &AppState, ) -> Result { - Ok(WoodenFishDraftResponse { + let work_title = resolve_wooden_fish_work_title( + state, + &payload.work_description, + &payload.hit_object_prompt, + ) + .await?; + Ok(build_wooden_fish_draft_response(payload, work_title)) +} + +fn build_wooden_fish_draft_response( + payload: &WoodenFishWorkspaceCreateRequest, + work_title: String, +) -> WoodenFishDraftResponse { + WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, - work_title: resolve_wooden_fish_work_title( - state, - &payload.work_description, - &payload.hit_object_prompt, - ) - .await?, + work_title, work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), @@ -401,7 +417,7 @@ async fn build_wooden_fish_draft( .or_else(|| Some(default_wooden_fish_hit_sound_asset())), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, - }) + } } fn validate_workspace_request( @@ -543,6 +559,62 @@ async fn maybe_generate_hit_object_asset( Ok(()) } +async fn execute_wooden_fish_action_with_generated_assets( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, + payload: &mut WoodenFishActionRequest, +) -> Result { + maybe_generate_hit_object_asset(state, request_context, session_id, owner_user_id, payload) + .await?; + maybe_generate_hit_sound_asset(payload); + state + .spacetime_client() + .execute_wooden_fish_action( + session_id.to_string(), + owner_user_id.to_string(), + author_display_name.to_string(), + payload.clone(), + ) + .await + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + }) +} + +async fn mark_wooden_fish_generation_failed( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, +) { + if let Err(error) = state + .spacetime_client() + .mark_wooden_fish_generation_failed( + session_id.to_string(), + owner_user_id.to_string(), + author_display_name.to_string(), + ) + .await + { + tracing::error!( + provider = WOODEN_FISH_CREATION_PROVIDER, + session_id, + owner_user_id, + request_id = request_context.request_id(), + error = %error, + "敲木鱼草稿生成失败后的状态回写失败" + ); + } +} + fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { WoodenFishImageAsset { asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), @@ -1475,7 +1547,8 @@ mod tests { floating_words: vec![], }; - let draft = build_wooden_fish_draft(&payload); + let draft = + build_wooden_fish_draft_response(&payload, WOODEN_FISH_TEMPLATE_NAME.to_string()); assert!(draft.hit_sound_prompt.is_none()); let asset = draft diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 2d997da6..4a4e4b7d 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -11,7 +11,7 @@ use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; use shared_contracts::creation_entry_config::{ CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse, - CreationEntryTypeModalResponse, CreationEntryTypeResponse, + CreationEntryTypeModalResponse, CreationEntryTypeResponse, build_phase1_unified_creation_spec, }; pub fn build_creation_entry_config_response( @@ -39,19 +39,23 @@ pub fn build_creation_entry_config_response( creation_types: snapshot .creation_types .into_iter() - .map(|item| CreationEntryTypeResponse { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - category_id: item.category_id, - category_label: item.category_label, - category_sort_order: item.category_sort_order, - updated_at_micros: item.updated_at_micros, + .map(|item| { + let unified_creation_spec = build_phase1_unified_creation_spec(item.id.as_str()); + CreationEntryTypeResponse { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, + updated_at_micros: item.updated_at_micros, + unified_creation_spec, + } }) .collect(), } diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs index c6664cbb..3f981007 100644 --- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs +++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs @@ -51,4 +51,113 @@ pub struct CreationEntryTypeResponse { pub category_label: String, pub category_sort_order: i32, pub updated_at_micros: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub unified_creation_spec: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedCreationSpecResponse { + pub play_id: String, + pub title: String, + pub workspace_stage: String, + pub generation_stage: String, + pub result_stage: String, + pub fields: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UnifiedCreationFieldResponse { + pub id: String, + pub kind: String, + pub label: String, + pub required: bool, +} + +pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option { + let (workspace_stage, generation_stage, result_stage, fields) = match play_id { + "puzzle" => ( + "puzzle-agent-workspace", + "puzzle-generating", + "puzzle-result", + vec![ + unified_creation_field("pictureDescription", "text", "画面描述", true), + unified_creation_field("referenceImage", "image", "拼图画面", false), + unified_creation_field("promptReferenceImages", "image", "参考图", false), + ], + ), + "match3d" => ( + "match3d-agent-workspace", + "match3d-generating", + "match3d-result", + vec![ + unified_creation_field("themeText", "text", "题材", true), + unified_creation_field("difficulty", "select", "难度", true), + ], + ), + "wooden-fish" => ( + "wooden-fish-workspace", + "wooden-fish-generating", + "wooden-fish-result", + vec![ + unified_creation_field("hitObjectPrompt", "text", "敲什么", false), + unified_creation_field("hitObjectReferenceImage", "image", "参考图", false), + unified_creation_field("hitSoundAsset", "audio", "敲击音效", false), + unified_creation_field("floatingWords", "text", "功德有什么", true), + ], + ), + _ => return None, + }; + + Some(UnifiedCreationSpecResponse { + play_id: play_id.to_string(), + title: "想做个什么玩法?".to_string(), + workspace_stage: workspace_stage.to_string(), + generation_stage: generation_stage.to_string(), + result_stage: result_stage.to_string(), + fields, + }) +} + +fn unified_creation_field( + id: &str, + kind: &str, + label: &str, + required: bool, +) -> UnifiedCreationFieldResponse { + UnifiedCreationFieldResponse { + id: id.to_string(), + kind: kind.to_string(), + label: label.to_string(), + required, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phase1_unified_creation_specs_only_cover_three_templates() { + let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); + assert_eq!(puzzle.fields[0].id, "pictureDescription"); + assert_eq!(puzzle.fields[1].kind, "image"); + + let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec"); + assert_eq!( + match3d + .fields + .iter() + .filter(|field| field.kind == "select") + .count(), + 1 + ); + + let wooden_fish = + build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); + assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio")); + assert!(build_phase1_unified_creation_spec("visual-novel").is_none()); + assert!(build_phase1_unified_creation_spec("bark-battle").is_none()); + } } diff --git a/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs index d4ec1031..60890a91 100644 --- a/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/mapper/wooden_fish.rs @@ -100,6 +100,7 @@ fn map_wooden_fish_session_snapshot( fn map_wooden_fish_work_snapshot( snapshot: WoodenFishWorkSnapshot, ) -> Result { + let generation_status = parse_generation_status(&snapshot.generation_status); let draft = WoodenFishDraftResponse { template_id: "wooden-fish".to_string(), template_name: "敲木鱼".to_string(), @@ -116,15 +117,23 @@ fn map_wooden_fish_work_snapshot( back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset), hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset), cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()), - generation_status: parse_generation_status(&snapshot.generation_status), + generation_status: generation_status.clone(), }; let hit_object_asset = draft .hit_object_asset .clone() + .or_else(|| { + matches!(generation_status, WoodenFishGenerationStatus::Failed) + .then(default_failed_hit_object_asset) + }) .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?; let hit_sound_asset = draft .hit_sound_asset .clone() + .or_else(|| { + matches!(generation_status, WoodenFishGenerationStatus::Failed) + .then(default_failed_hit_sound_asset) + }) .ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?; Ok(WoodenFishWorkProfileResponse { summary: WoodenFishWorkSummaryResponse { @@ -143,7 +152,7 @@ fn map_wooden_fish_work_snapshot( updated_at: format_timestamp_micros(snapshot.updated_at_micros), published_at: snapshot.published_at_micros.map(format_timestamp_micros), publish_ready: snapshot.publish_ready, - generation_status: parse_generation_status(&snapshot.generation_status), + generation_status, }, draft, hit_object_asset, @@ -154,6 +163,31 @@ fn map_wooden_fish_work_snapshot( }) } +fn default_failed_hit_object_asset() -> WoodenFishImageAsset { + WoodenFishImageAsset { + asset_id: "wooden-fish-failed-hit-object".to_string(), + image_src: "/wooden-fish/default-hit-object.png".to_string(), + image_object_key: "public/wooden-fish/default-hit-object.png".to_string(), + asset_object_id: "wooden-fish-failed-hit-object".to_string(), + generation_provider: "failed-fallback".to_string(), + prompt: "生成失败占位图".to_string(), + width: 1024, + height: 1024, + } +} + +fn default_failed_hit_sound_asset() -> WoodenFishAudioAsset { + WoodenFishAudioAsset { + asset_id: "wooden-fish-failed-hit-sound".to_string(), + audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(), + audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(), + asset_object_id: "wooden-fish-failed-hit-sound".to_string(), + source: "failed-fallback".to_string(), + prompt: Some("生成失败占位音效".to_string()), + duration_ms: Some(3_000), + } +} + fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse { WoodenFishDraftResponse { template_id: snapshot.template_id, diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index 66304b09..d09f5238 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -122,6 +122,35 @@ impl SpacetimeClient { }) } + pub async fn mark_wooden_fish_generation_failed( + &self, + session_id: String, + owner_user_id: String, + author_display_name: String, + ) -> Result { + let current = self + .get_wooden_fish_session(session_id.clone(), owner_user_id.clone()) + .await?; + let mut draft = current.draft.clone().unwrap_or_else(default_draft); + let profile_id = resolve_wooden_fish_profile_id( + &draft, + &WoodenFishActionType::CompileDraft, + draft.profile_id.as_deref(), + )?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = WoodenFishGenerationStatus::Failed; + let now_micros = current_unix_micros(); + self.compile_wooden_fish_draft(build_failed_compile_input( + ¤t, + &owner_user_id, + &author_display_name, + &profile_id, + &draft, + now_micros, + )?) + .await + } + pub async fn compile_wooden_fish_draft( &self, procedure_input: WoodenFishDraftCompileInput, @@ -636,6 +665,52 @@ fn build_compile_input( }) } +fn build_failed_compile_input( + current: &WoodenFishSessionSnapshotResponse, + owner_user_id: &str, + author_display_name: &str, + profile_id: &str, + draft: &WoodenFishDraftResponse, + now_micros: i64, +) -> Result { + Ok(WoodenFishDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: author_display_name.trim().to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + hit_object_asset_json: draft + .hit_object_asset + .as_ref() + .map(json_string) + .transpose()?, + background_asset_json: draft + .background_asset + .as_ref() + .map(json_string) + .transpose()?, + hit_sound_asset_json: draft + .hit_sound_asset + .as_ref() + .map(json_string) + .transpose()?, + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, + floating_words_json: Some(json_string(&draft.floating_words)?), + cover_image_src: draft.cover_image_src.clone(), + generation_status: Some("failed".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_update_input( owner_user_id: &str, profile_id: &str, @@ -801,6 +876,7 @@ mod tests { const SESSION_ID: &str = "wooden-fish-session-test"; const OWNER_USER_ID: &str = "user-test"; + const AUTHOR_DISPLAY_NAME: &str = "测试玩家"; const PROFILE_ID: &str = "wooden-fish-profile-test"; const NOW_MICROS: i64 = 1_763_456_789_000_000; @@ -813,9 +889,14 @@ mod tests { payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back")); payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); - let (plan, draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) - .expect("compile-draft should build plan"); + let (plan, draft) = build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) + .expect("compile-draft should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { panic!("compile-draft should call compile_wooden_fish_draft"); @@ -862,11 +943,16 @@ mod tests { payload.background_asset = Some(generated_background_asset("generated-compile-background")); payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back")); - let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { - Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"), - Err(error) => error, - }; + let error = match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { + Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"), + Err(error) => error, + }; assert!( error @@ -883,11 +969,16 @@ mod tests { payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back")); - let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { - Ok(_) => panic!("compile-draft should not publish without background asset"), - Err(error) => error, - }; + let error = match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { + Ok(_) => panic!("compile-draft should not publish without background asset"), + Err(error) => error, + }; assert!( error @@ -904,11 +995,16 @@ mod tests { payload.background_asset = Some(generated_background_asset("generated-compile-background")); payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); - let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { - Ok(_) => panic!("compile-draft should not publish without back button asset"), - Err(error) => error, - }; + let error = match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { + Ok(_) => panic!("compile-draft should not publish without back button asset"), + Err(error) => error, + }; assert!( error @@ -926,9 +1022,14 @@ mod tests { payload.background_asset = Some(generated_background_asset("generated-background")); payload.back_button_asset = Some(generated_back_button_asset("generated-back")); - let (plan, _draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) - .expect("regenerate-hit-object should build plan"); + let (plan, _draft) = build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) + .expect("regenerate-hit-object should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { panic!("regenerate-hit-object should call compile_wooden_fish_draft"); @@ -987,9 +1088,14 @@ mod tests { "健康+1".to_string(), ]); - let (plan, draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) - .expect("update-floating-words should build plan"); + let (plan, draft) = build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) + .expect("update-floating-words should build plan"); let WoodenFishActionProcedure::Update(input) = plan else { panic!("update-floating-words should call update_wooden_fish_work"); @@ -1016,6 +1122,31 @@ mod tests { assert!(draft.hit_sound_prompt.is_none()); } + #[test] + fn wooden_fish_failed_compile_input_preserves_session_and_marks_failed() { + let session = session_with_draft(draft_without_assets()); + let mut draft = session.draft.clone().expect("draft should exist"); + draft.profile_id = Some(PROFILE_ID.to_string()); + draft.generation_status = WoodenFishGenerationStatus::Failed; + + let input = build_failed_compile_input( + &session, + OWNER_USER_ID, + "测试玩家", + PROFILE_ID, + &draft, + NOW_MICROS, + ) + .expect("failed compile input should build"); + + assert_eq!(input.session_id, SESSION_ID); + assert_eq!(input.profile_id, PROFILE_ID); + assert_eq!(input.generation_status.as_deref(), Some("failed")); + assert!(input.hit_object_asset_json.is_none()); + assert!(input.background_asset_json.is_none()); + assert!(input.back_button_asset_json.is_none()); + } + fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest { WoodenFishActionRequest { action_type, diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index 33482ac2..d4dc56de 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -534,6 +534,9 @@ fn publish_wooden_fish_work_tx( input: WoodenFishWorkPublishInput, ) -> Result { let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + if row.generation_status == WOODEN_FISH_GENERATION_FAILED { + return Err("生成失败的敲木鱼作品需要重新生成后才能发布".to_string()); + } if !is_publish_ready(&row) { return Err("发布需要完整的敲击物图案、背景、返回按钮、敲击音效和飘字配置".to_string()); } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs index 12ebbdca..2ad5cb1b 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/types.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/types.rs @@ -12,6 +12,7 @@ pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published"; pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft"; pub const WOODEN_FISH_GENERATION_GENERATING: &str = "generating"; pub const WOODEN_FISH_GENERATION_READY: &str = "ready"; +pub const WOODEN_FISH_GENERATION_FAILED: &str = "failed"; pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started"; pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint"; pub const WOODEN_FISH_EVENT_RUN_FINISHED: &str = "finish"; diff --git a/src/components/common/CreativeAudioInputPanel.tsx b/src/components/common/CreativeAudioInputPanel.tsx new file mode 100644 index 00000000..84d87893 --- /dev/null +++ b/src/components/common/CreativeAudioInputPanel.tsx @@ -0,0 +1,206 @@ +import { Mic, Pause, Upload } from 'lucide-react'; +import { useRef, useState } from 'react'; + +export type CreativeAudioAsset = { + assetId: string; + audioSrc: string; + audioObjectKey: string; + assetObjectId: string; + source: string; + prompt?: string | null; + durationMs?: number | null; +}; + +type CreativeAudioInputPanelProps = { + disabled?: boolean; + title: string; + defaultLabel: string; + asset: TAsset | null; + buildRecordedFileName: () => string; + onAssetChange: (asset: TAsset | null) => void; + onError: (message: string | null) => void; + readFileAsAsset?: ( + file: File, + source: 'uploaded' | 'recorded', + ) => Promise; +}; + +export function readCreativeAudioFileAsAsset( + file: File, + source: 'uploaded' | 'recorded', +) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('音频读取失败,请重试。')); + reader.onload = () => { + if (typeof reader.result !== 'string') { + reject(new Error('音频读取失败,请重试。')); + return; + } + resolve({ + assetId: `local-${source}-${Date.now()}`, + audioSrc: reader.result, + audioObjectKey: '', + assetObjectId: '', + source, + prompt: file.name, + durationMs: null, + } as TAsset); + }; + reader.readAsDataURL(file); + }); +} + +export function CreativeAudioInputPanel({ + disabled = false, + title, + defaultLabel, + asset, + buildRecordedFileName, + onAssetChange, + onError, + readFileAsAsset = readCreativeAudioFileAsAsset, +}: CreativeAudioInputPanelProps) { + const [isRecording, setIsRecording] = useState(false); + const recorderRef = useRef(null); + const chunksRef = useRef([]); + + const startRecording = async () => { + if (disabled || isRecording) { + return; + } + + try { + if ( + typeof navigator === 'undefined' || + !navigator.mediaDevices?.getUserMedia || + typeof MediaRecorder === 'undefined' + ) { + throw new Error('当前浏览器不支持录音。'); + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + chunksRef.current = []; + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { + type: recorder.mimeType || 'audio/webm', + }); + stream.getTracks().forEach((track) => track.stop()); + const file = new File([blob], buildRecordedFileName(), { + type: blob.type, + }); + void readFileAsAsset(file, 'recorded') + .then(onAssetChange) + .catch((caughtError) => { + onError( + caughtError instanceof Error + ? caughtError.message + : '录音保存失败。', + ); + }); + }; + recorderRef.current = recorder; + recorder.start(); + setIsRecording(true); + onError(null); + } catch (caughtError) { + onError( + caughtError instanceof Error ? caughtError.message : '录音启动失败。', + ); + } + }; + + const stopRecording = () => { + recorderRef.current?.stop(); + recorderRef.current = null; + setIsRecording(false); + }; + + return ( +
+
+
+ {title} +
+ {asset ? ( + + ) : null} +
+
+ + + {asset?.audioSrc ? ( +
+
+ ); +} + +export default CreativeAudioInputPanel; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 32c45498..d5e17908 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -215,11 +215,13 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, type MiniGameDraftGenerationKind, type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; +import { getUnifiedCreationSpec } from '../unified-creation/unifiedCreationSpecs'; import { buildBabyObjectMatchPublicWorkCode, buildBarkBattlePublicWorkCode, @@ -2195,9 +2197,7 @@ function rebaseMiniGameDraftGenerationStateForDisplay( function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask, ->( - task: T, -): T { +>(task: T): T { return { ...task, generationState: rebaseMiniGameDraftGenerationStateForDisplay( @@ -2216,7 +2216,10 @@ function createPuzzleDraftGenerationStateFromPayload( : undefined; return { - ...createMiniGameDraftGenerationState('puzzle'), + ...createMiniGameDraftGenerationState( + 'puzzle', + resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt), + ), metadata: { puzzleAiRedraw: payload?.aiRedraw ?? true, puzzleActivePhaseId: @@ -2269,7 +2272,7 @@ function mergePuzzleSessionProgressIntoGenerationState( ...state.metadata, puzzleActivePhaseId: nextPhaseId, puzzleActiveStepStartedAtMs: shouldResetActiveStepStart - ? Date.now() + ? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt) : state.metadata?.puzzleActiveStepStartedAtMs, puzzleProgressPercent: isCompiledGenerationSession ? session.progressPercent @@ -2734,7 +2737,8 @@ function buildPendingBarkBattleWorks( uiBackgroundImageSrc: null, difficultyPreset: 'normal', status: 'draft', - generationStatus: state.status === 'generating' ? 'pending_assets' : 'ready', + generationStatus: + state.status === 'generating' ? 'pending_assets' : 'ready', publishReady: false, playCount: 0, updatedAt: state.updatedAt, @@ -2852,6 +2856,20 @@ const CustomWorldGenerationView = lazy(async () => { }; }); +const UnifiedCreationPage = lazy(async () => { + const module = await import('../unified-creation/UnifiedCreationPage'); + return { + default: module.UnifiedCreationPage, + }; +}); + +const UnifiedGenerationPage = lazy(async () => { + const module = await import('../unified-creation/UnifiedGenerationPage'); + return { + default: module.UnifiedGenerationPage, + }; +}); + const RpgCreationResultView = lazy(async () => { const module = await import('../rpg-creation-result/RpgCreationResultView'); return { @@ -2983,7 +3001,9 @@ const BarkBattleConfigEditor = lazy(async () => { }); const BarkBattleGeneratingView = lazy(async () => { - const module = await import('../bark-battle-creation/BarkBattleGeneratingView'); + const module = await import( + '../bark-battle-creation/BarkBattleGeneratingView' + ); return { default: module.BarkBattleGeneratingView, }; @@ -3276,13 +3296,16 @@ export function PlatformEntryFlowShellImpl({ useState(null); const [barkBattleDraftConfig, setBarkBattleDraftConfig] = useState(null); - const [barkBattleRuntimeMode, setBarkBattleRuntimeMode] = - useState<'draft' | 'published'>('draft'); + const [barkBattleRuntimeMode, setBarkBattleRuntimeMode] = useState< + 'draft' | 'published' + >('draft'); const [barkBattleRuntimeReturnStage, setBarkBattleRuntimeReturnStage] = useState('platform'); const [barkBattleError, setBarkBattleError] = useState(null); - const [barkBattleGenerationPartialFailed, setBarkBattleGenerationPartialFailed] = - useState(false); + const [ + barkBattleGenerationPartialFailed, + setBarkBattleGenerationPartialFailed, + ] = useState(false); const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false); const [bigFishRun, setBigFishRun] = useState(null); @@ -3457,6 +3480,10 @@ export function PlatformEntryFlowShellImpl({ : [], [creationEntryConfig], ); + const unifiedCreationConfigById = useMemo(() => { + const entries = creationEntryConfig?.creationTypes ?? []; + return new Map(entries.map((entry) => [entry.id, entry])); + }, [creationEntryConfig]); const isBigFishCreationVisible = isPlatformCreationTypeVisible( creationEntryTypes, 'big-fish', @@ -3841,12 +3868,10 @@ export function PlatformEntryFlowShellImpl({ return true; } - setDraftGenerationPointNotice( - { - title: '泥点不足', - message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`, - }, - ); + setDraftGenerationPointNotice({ + title: '泥点不足', + message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`, + }); return false; } catch { setDraftGenerationPointNotice({ @@ -4157,26 +4182,36 @@ export function PlatformEntryFlowShellImpl({ (updated: BarkBattleWorkSummary) => { setBarkBattleWorks((current) => { const deduped = mergeBarkBattleWorksByWorkId(current); - const hasExisting = deduped.some((item) => item.workId === updated.workId); + const hasExisting = deduped.some( + (item) => item.workId === updated.workId, + ); if (hasExisting) { - return deduped.map((item) => mergeBarkBattleWorkSummary(item, updated)); + return deduped.map((item) => + mergeBarkBattleWorkSummary(item, updated), + ); } return [updated, ...deduped]; }); setBarkBattleGalleryEntries((current) => { - const hasExisting = current.some((item) => item.workId === updated.workId); + const hasExisting = current.some( + (item) => item.workId === updated.workId, + ); if (updated.status !== 'published') { return hasExisting ? current.map((item) => mergeBarkBattleWorkSummary(item, updated)) : current; } if (hasExisting) { - return current.map((item) => mergeBarkBattleWorkSummary(item, updated)); + return current.map((item) => + mergeBarkBattleWorkSummary(item, updated), + ); } return [updated, ...current]; }); if (updated.status === 'published') { - syncUpdatedPublicWorkDetail(mapBarkBattleWorkToPublicWorkDetail(updated)); + syncUpdatedPublicWorkDetail( + mapBarkBattleWorkToPublicWorkDetail(updated), + ); } }, [syncUpdatedPublicWorkDetail], @@ -4212,7 +4247,10 @@ export function PlatformEntryFlowShellImpl({ return; } - if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') { + if ( + platformBootstrap.platformTab === 'create' || + selectionStage === 'platform' + ) { void refreshBarkBattleShelf(); } }, [ @@ -4228,7 +4266,10 @@ export function PlatformEntryFlowShellImpl({ return; } - if (platformBootstrap.platformTab === 'create' || selectionStage === 'platform') { + if ( + platformBootstrap.platformTab === 'create' || + selectionStage === 'platform' + ) { void refreshJumpHopShelf(); } }, [ @@ -4509,7 +4550,9 @@ export function PlatformEntryFlowShellImpl({ : []), ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), - ...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard), + ...barkBattleGalleryEntries.map( + mapBarkBattleWorkToPlatformGalleryCard, + ), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), ...(barkBattleGalleryEntries.length === 0 ? barkBattleWorks @@ -4557,8 +4600,8 @@ export function PlatformEntryFlowShellImpl({ ...featuredGalleryEntries, ...latestGalleryEntries, ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); return Array.from(entryMap.values()); }, [featuredGalleryEntries, latestGalleryEntries]); @@ -5241,7 +5284,12 @@ export function PlatformEntryFlowShellImpl({ markPendingDraftGenerating('match3d', session.sessionId); selectionStageRef.current = 'match3d-generating'; setSelectionStage('match3d-generating'); - setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d')); + setMatch3DGenerationState( + createMiniGameDraftGenerationState( + 'match3d', + resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt), + ), + ); }, onActionError: async ({ payload, errorMessage, session, setSession }) => { if (payload.action !== 'match3d_compile_draft') { @@ -5870,9 +5918,7 @@ export function PlatformEntryFlowShellImpl({ return ensureEnoughDraftGenerationPointsFromServer( PUZZLE_DRAFT_GENERATION_POINT_COST, ); - }, [ - ensureEnoughDraftGenerationPointsFromServer, - ]); + }, [ensureEnoughDraftGenerationPointsFromServer]); const preflightMatch3DDraftGeneration = useCallback(async () => { setMatch3DError(null); return ensureEnoughDraftGenerationPointsFromServer( @@ -6164,7 +6210,8 @@ export function PlatformEntryFlowShellImpl({ puzzleGenerationViewSession?.sessionId ?? puzzleSession?.sessionId, ), - message: puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + message: + puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, }, { key: 'puzzle-onboarding', @@ -6292,9 +6339,10 @@ export function PlatformEntryFlowShellImpl({ completedAtMs: number | null; }) | null - >(() => pendingPlatformTaskCompletionDialog, [ - pendingPlatformTaskCompletionDialog, - ]); + >( + () => pendingPlatformTaskCompletionDialog, + [pendingPlatformTaskCompletionDialog], + ); const activePlatformTaskCompletionDialogDismissKey = buildPlatformTaskCompletionDialogDismissKey( currentPlatformTaskCompletionDialog, @@ -6429,7 +6477,10 @@ export function PlatformEntryFlowShellImpl({ isMiniGameDraftGenerating(puzzleGenerationViewState); useEffect(() => { - if (!shouldPollPuzzleGenerationSession || !activePuzzleGenerationSessionId) { + if ( + !shouldPollPuzzleGenerationSession || + !activePuzzleGenerationSessionId + ) { return undefined; } @@ -6973,7 +7024,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const generationState = createMiniGameDraftGenerationState('match3d'); + const generationState = createMiniGameDraftGenerationState( + 'match3d', + resolveMiniGameDraftGenerationStartedAtMs(nextSession.updatedAt), + ); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -7687,7 +7741,10 @@ export function PlatformEntryFlowShellImpl({ writeCreationUrlState(buildBarkBattleCreationUrlState(draft)); setBarkBattlePublishedConfig(null); markDraftGenerating('bark-battle', [draft.workId, draft.draftId]); - markPendingDraftGenerating('bark-battle', draft.workId ?? draft.draftId); + markPendingDraftGenerating( + 'bark-battle', + draft.workId ?? draft.draftId, + ); updateBarkBattleWorkCaches( buildBarkBattleWorkSummaryFromDraft( draft, @@ -7780,9 +7837,11 @@ export function PlatformEntryFlowShellImpl({ const nextDraft = { ...persistedDraft, playerCharacterImageSrc: - draft.playerCharacterImageSrc ?? persistedDraft.playerCharacterImageSrc, + draft.playerCharacterImageSrc ?? + persistedDraft.playerCharacterImageSrc, opponentCharacterImageSrc: - draft.opponentCharacterImageSrc ?? persistedDraft.opponentCharacterImageSrc, + draft.opponentCharacterImageSrc ?? + persistedDraft.opponentCharacterImageSrc, uiBackgroundImageSrc: draft.uiBackgroundImageSrc ?? persistedDraft.uiBackgroundImageSrc, }; @@ -7852,7 +7911,9 @@ export function PlatformEntryFlowShellImpl({ setBarkBattleError(null); const workId = draft.workId?.trim(); if (!workId) { - setBarkBattleError('这份汪汪声浪草稿缺少作品ID,请重新生成草稿后再发布。'); + setBarkBattleError( + '这份汪汪声浪草稿缺少作品ID,请重新生成草稿后再发布。', + ); return; } setIsBarkBattleBusy(true); @@ -7885,7 +7946,8 @@ export function PlatformEntryFlowShellImpl({ playerCharacterImageSrc: published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc, opponentCharacterImageSrc: - published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc, + published.opponentCharacterImageSrc ?? + draft.opponentCharacterImageSrc, uiBackgroundImageSrc: published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc, }; @@ -7911,7 +7973,9 @@ export function PlatformEntryFlowShellImpl({ ); selectionStageRef.current = 'work-detail'; setSelectionStage('work-detail'); - pushAppHistoryPath(buildPublicWorkStagePath('work-detail', publicWorkCode)); + pushAppHistoryPath( + buildPublicWorkStagePath('work-detail', publicWorkCode), + ); void Promise.allSettled([ refreshBarkBattleShelf(), refreshBarkBattleGallery(), @@ -8021,10 +8085,7 @@ export function PlatformEntryFlowShellImpl({ await ensureBabyObjectMatchGeneratedAssets(draft); } catch (error) { setBabyObjectMatchError( - resolvePuzzleErrorMessage( - error, - '重新生成宝贝识物素材失败。', - ), + resolvePuzzleErrorMessage(error, '重新生成宝贝识物素材失败。'), ); } finally { setIsBabyObjectMatchBusy(false); @@ -8680,9 +8741,12 @@ export function PlatformEntryFlowShellImpl({ ); setJumpHopGenerationState(readyState); if (response.work) { - setJumpHopWorks((current) => - [response.work!.summary, ...current.filter((item) => item.workId !== response.work!.summary.workId)], - ); + setJumpHopWorks((current) => [ + response.work!.summary, + ...current.filter( + (item) => item.workId !== response.work!.summary.workId, + ), + ]); markPendingDraftReady('jump-hop', created.session.sessionId, false); markDraftReady( 'jump-hop', @@ -8821,9 +8885,12 @@ export function PlatformEntryFlowShellImpl({ try { const response = await jumpHopClient.publishWork(profileId); setJumpHopWork(response.item); - setJumpHopWorks((current) => - [response.item.summary, ...current.filter((item) => item.workId !== response.item.summary.workId)], - ); + setJumpHopWorks((current) => [ + response.item.summary, + ...current.filter( + (item) => item.workId !== response.item.summary.workId, + ), + ]); void refreshJumpHopShelf().catch(() => undefined); openPublishShareModal({ title: response.item.summary.workTitle || '跳一跳', @@ -8967,7 +9034,12 @@ export function PlatformEntryFlowShellImpl({ created: WoodenFishSessionResponse, payload?: WoodenFishWorkspaceCreateRequest, ) => { - const generationState = createMiniGameDraftGenerationState('wooden-fish'); + const generationState = createMiniGameDraftGenerationState( + 'wooden-fish', + resolveMiniGameDraftGenerationStartedAtMs( + created.session.updatedAt ?? created.session.createdAt, + ), + ); setWoodenFishError(null); setWoodenFishSession(created.session); writeCreationUrlState( @@ -8994,7 +9066,8 @@ export function PlatformEntryFlowShellImpl({ payload?.workDescription ?? created.session.draft?.workDescription ?? '', - themeTags: payload?.themeTags ?? created.session.draft?.themeTags ?? ['敲木鱼'], + themeTags: payload?.themeTags ?? + created.session.draft?.themeTags ?? ['敲木鱼'], coverImageSrc: created.session.draft?.coverImageSrc ?? null, publicationStatus: 'draft', playCount: 0, @@ -9128,7 +9201,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const generationState = createMiniGameDraftGenerationState('wooden-fish'); + const generationState = createMiniGameDraftGenerationState( + 'wooden-fish', + resolveMiniGameDraftGenerationStartedAtMs(woodenFishSession.updatedAt), + ); setWoodenFishError(null); setWoodenFishGenerationState(generationState); setIsWoodenFishBusy(true); @@ -9328,7 +9404,10 @@ export function PlatformEntryFlowShellImpl({ const [detail, runResponse] = await Promise.all([ woodenFishClient.getWorkDetail(normalizedProfileId).catch(() => null), options.embedded - ? woodenFishClient.startRun(normalizedProfileId, runtimeGuestOptions) + ? woodenFishClient.startRun( + normalizedProfileId, + runtimeGuestOptions, + ) : woodenFishClient.startRun(normalizedProfileId), ]); if (detail?.item) { @@ -9576,12 +9655,7 @@ export function PlatformEntryFlowShellImpl({ if (selectionStage === 'big-fish-runtime' && !bigFishRun) { setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform'); } - }, [ - bigFishRun, - bigFishSession, - selectionStage, - setSelectionStage, - ]); + }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]); useEffect(() => { if (selectionStage === 'match3d-result' && !match3dSession?.draft) { @@ -9592,12 +9666,7 @@ export function PlatformEntryFlowShellImpl({ if (selectionStage === 'match3d-runtime' && !match3dRun) { setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform'); } - }, [ - match3dRun, - match3dSession, - selectionStage, - setSelectionStage, - ]); + }, [match3dRun, match3dSession, selectionStage, setSelectionStage]); useEffect(() => { if (selectionStage === 'square-hole-result' && !squareHoleSession?.draft) { @@ -9610,12 +9679,7 @@ export function PlatformEntryFlowShellImpl({ squareHoleSession?.draft ? 'square-hole-result' : 'platform', ); } - }, [ - selectionStage, - setSelectionStage, - squareHoleRun, - squareHoleSession, - ]); + }, [selectionStage, setSelectionStage, squareHoleRun, squareHoleSession]); useEffect(() => { if ( @@ -9859,11 +9923,10 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError(null); try { - let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = - profile; + let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = profile; if ( - (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || - !hasMatch3DRuntimeBackgroundAsset(profile)) + !hasMatch3DRuntimeAsset(profile.generatedItemAssets) || + !hasMatch3DRuntimeBackgroundAsset(profile) ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); @@ -10319,21 +10382,19 @@ export function PlatformEntryFlowShellImpl({ ? puzzleGalleryEntries.length > 0 ? puzzleGalleryEntries : await refreshPuzzleGallery() - : ( - puzzleWorks.length > 0 - ? puzzleWorks - : (await listPuzzleWorks().catch(() => ({ items: [] }))).items - ); + : puzzleWorks.length > 0 + ? puzzleWorks + : (await listPuzzleWorks().catch(() => ({ items: [] }))).items; const targetItem = runtimeProfileId || runtimeSessionId || publicWorkCode - ? candidateItems.find( + ? (candidateItems.find( (item) => item.profileId === runtimeProfileId || item.sourceSessionId === runtimeSessionId || (publicWorkCode ? isSamePuzzlePublicWorkCode(publicWorkCode, item.profileId) : false), - ) ?? null + ) ?? null) : null; if (!targetItem) { @@ -10381,9 +10442,7 @@ export function PlatformEntryFlowShellImpl({ } setSelectedPuzzleDetail(targetItem); - setPuzzleRun( - startLocalPuzzleRun(targetItem, runtimeLevelId ?? null), - ); + setPuzzleRun(startLocalPuzzleRun(targetItem, runtimeLevelId ?? null)); setPuzzleRuntimeAuthMode(isPublishedRuntime ? 'isolated' : 'default'); setPuzzleRuntimeReturnStage(fallbackStage); openPuzzleRuntimeStage( @@ -10634,7 +10693,11 @@ export function PlatformEntryFlowShellImpl({ const submitLeaderboardPromise = puzzleRuntimeAuthMode === 'isolated' ? buildRecommendRuntimeGuestOptions().then((runtimeGuestOptions) => - submitPuzzleLeaderboard(puzzleRun.runId, payload, runtimeGuestOptions), + submitPuzzleLeaderboard( + puzzleRun.runId, + payload, + runtimeGuestOptions, + ), ) : submitPuzzleLeaderboard(puzzleRun.runId, payload); @@ -10753,8 +10816,7 @@ export function PlatformEntryFlowShellImpl({ const item = await getPuzzleGalleryDetail(nextProfileId).then( (response) => response.item, ); - const nextRecommendEntry = - mapPuzzleWorkToPlatformGalleryCard(item); + const nextRecommendEntry = mapPuzzleWorkToPlatformGalleryCard(item); setPuzzleGalleryEntries((current) => { const nextEntries = current.filter( (entry) => entry.profileId !== item.profileId, @@ -10873,11 +10935,7 @@ export function PlatformEntryFlowShellImpl({ null, ); returnToCreationFlowSource(); - }, [ - autosaveCoordinator, - returnToCreationFlowSource, - sessionController, - ]); + }, [autosaveCoordinator, returnToCreationFlowSource, sessionController]); const leaveAgentDraftGeneration = useCallback(() => { if (sessionController.isActiveGenerationRunning) { @@ -10897,11 +10955,7 @@ export function PlatformEntryFlowShellImpl({ sessionController.setCustomWorldGenerationViewSource(null); sessionController.setCustomWorldResultViewSource(null); returnToCreationFlowSource(); - }, [ - autosaveCoordinator, - returnToCreationFlowSource, - sessionController, - ]); + }, [autosaveCoordinator, returnToCreationFlowSource, sessionController]); const leaveCustomWorldResult = useCallback(() => { sessionController.setGeneratedCustomWorldProfile(null); @@ -11887,7 +11941,12 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage], + [ + enterCreateTab, + markDraftNoticeSeen, + openPublicWorkDetail, + setSelectionStage, + ], ); const openWoodenFishPublicWorkDetail = useCallback( @@ -12195,16 +12254,14 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, ); const payload = buildPuzzleFormPayloadFromSession(latestSession); - const generationState = createMiniGameDraftGenerationStateForRestoredDraft( - 'puzzle', - { + const generationState = + createMiniGameDraftGenerationStateForRestoredDraft('puzzle', { puzzleAiRedraw: payload.aiRedraw ?? true, puzzleProgressPercent: latestSession.draft && !latestSession.draft.formDraft ? latestSession.progressPercent : undefined, - }, - ); + }); puzzleFlow.setSession(latestSession); setPuzzleFormDraftPayload(payload); setPuzzleGenerationState( @@ -12401,10 +12458,9 @@ export function PlatformEntryFlowShellImpl({ setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); - const generationState = - rebaseMiniGameDraftGenerationStateForDisplay( - createMiniGameDraftGenerationStateForRestoredDraft('match3d'), - ); + const generationState = rebaseMiniGameDraftGenerationStateForDisplay( + createMiniGameDraftGenerationStateForRestoredDraft('match3d'), + ); setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; @@ -12603,14 +12659,18 @@ export function PlatformEntryFlowShellImpl({ }; setBarkBattleDraftConfig(nextDraft); enterCreateTab(); - selectionStageRef.current = - isPersistedBarkBattleDraftGenerating(item) - ? 'bark-battle-generating' - : 'bark-battle-result'; + selectionStageRef.current = isPersistedBarkBattleDraftGenerating(item) + ? 'bark-battle-generating' + : 'bark-battle-result'; setSelectionStage(selectionStageRef.current); writeCreationUrlState(buildBarkBattleCreationUrlState(nextDraft)); }, - [enterCreateTab, markDraftNoticeSeen, openPublicWorkDetail, setSelectionStage], + [ + enterCreateTab, + markDraftNoticeSeen, + openPublicWorkDetail, + setSelectionStage, + ], ); const openVisualNovelDraft = useCallback( @@ -12730,17 +12790,19 @@ export function PlatformEntryFlowShellImpl({ const profileId = normalizeCreationUrlValue( initialCreationUrlState.profileId, ); - const draftId = normalizeCreationUrlValue(initialCreationUrlState.draftId); + const draftId = normalizeCreationUrlValue( + initialCreationUrlState.draftId, + ); const workId = normalizeCreationUrlValue(initialCreationUrlState.workId); if (path.startsWith('/creation/big-fish')) { - const targetSessionId = sessionId ?? workId?.replace(/^big-fish-work-/u, ''); + const targetSessionId = + sessionId ?? workId?.replace(/^big-fish-work-/u, ''); if (targetSessionId) { const matchedWork = - ( - bigFishWorks.length > 0 - ? bigFishWorks - : (await listBigFishWorks().catch(() => ({ items: [] }))).items + (bigFishWorks.length > 0 + ? bigFishWorks + : (await listBigFishWorks().catch(() => ({ items: [] }))).items ).find( (item) => item.sourceSessionId === targetSessionId || @@ -12757,12 +12819,11 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/match3d')) { const matchedWork = - ( - match3dWorks.length > 0 - ? match3dWorks - : mapMatch3DWorksForRuntimeUi( - (await listMatch3DWorks().catch(() => ({ items: [] }))).items, - ) + (match3dWorks.length > 0 + ? match3dWorks + : mapMatch3DWorksForRuntimeUi( + (await listMatch3DWorks().catch(() => ({ items: [] }))).items, + ) ).find( (item) => item.sourceSessionId === sessionId || @@ -12781,10 +12842,9 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/square-hole')) { const matchedWork = - ( - squareHoleWorks.length > 0 - ? squareHoleWorks - : (await listSquareHoleWorks().catch(() => ({ items: [] }))).items + (squareHoleWorks.length > 0 + ? squareHoleWorks + : (await listSquareHoleWorks().catch(() => ({ items: [] }))).items ).find( (item) => item.sourceSessionId === sessionId || @@ -12803,10 +12863,9 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/puzzle')) { const matchedWork = - ( - puzzleWorks.length > 0 - ? puzzleWorks - : (await listPuzzleWorks().catch(() => ({ items: [] }))).items + (puzzleWorks.length > 0 + ? puzzleWorks + : (await listPuzzleWorks().catch(() => ({ items: [] }))).items ).find( (item) => item.sourceSessionId === sessionId || @@ -12825,10 +12884,9 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/visual-novel')) { const matchedWork = - ( - visualNovelWorks.length > 0 - ? visualNovelWorks - : (await listVisualNovelWorks().catch(() => ({ works: [] }))).works + (visualNovelWorks.length > 0 + ? visualNovelWorks + : (await listVisualNovelWorks().catch(() => ({ works: [] }))).works ).find((item) => item.profileId === profileId) ?? null; if (matchedWork) { await openVisualNovelDraft(matchedWork, { forceDraft: true }); @@ -12842,10 +12900,9 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/bark-battle')) { const matchedWork = - ( - barkBattleWorks.length > 0 - ? barkBattleWorks - : (await listBarkBattleWorks().catch(() => ({ items: [] }))).items + (barkBattleWorks.length > 0 + ? barkBattleWorks + : (await listBarkBattleWorks().catch(() => ({ items: [] }))).items ).find( (item) => item.workId === workId || item.draftId === draftId, ) ?? null; @@ -12857,10 +12914,9 @@ export function PlatformEntryFlowShellImpl({ if (path.startsWith('/creation/baby-object-match')) { const matchedDraft = - ( - babyObjectMatchDrafts.length > 0 - ? babyObjectMatchDrafts - : await listLocalBabyObjectMatchDrafts().catch(() => []) + (babyObjectMatchDrafts.length > 0 + ? babyObjectMatchDrafts + : await listLocalBabyObjectMatchDrafts().catch(() => []) ).find( (item) => item.profileId === profileId || @@ -12885,7 +12941,9 @@ export function PlatformEntryFlowShellImpl({ } setJumpHopSession(session); setJumpHopWork(work); - writeCreationUrlState(buildJumpHopCreationUrlState({ session, work })); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session, work }), + ); enterCreateTab(); setSelectionStage( path.includes('/generating') @@ -13019,7 +13077,13 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [authUi, bigFishFlow, resolveBigFishErrorMessage, setBigFishError, setSelectionStage], + [ + authUi, + bigFishFlow, + resolveBigFishErrorMessage, + setBigFishError, + setSelectionStage, + ], ); const startBarkBattleRunFromWork = useCallback( @@ -13334,8 +13398,9 @@ export function PlatformEntryFlowShellImpl({ ); } else if (isBarkBattleGalleryEntry(entry)) { const work = - barkBattleGalleryEntries.find((item) => item.workId === entry.workId) ?? - mapBarkBattlePublicDetailToWorkSummary(entry); + barkBattleGalleryEntries.find( + (item) => item.workId === entry.workId, + ) ?? mapBarkBattlePublicDetailToWorkSummary(entry); if (!work) { setBarkBattleError( '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', @@ -13756,7 +13821,10 @@ export function PlatformEntryFlowShellImpl({ ); } - if (activeRecommendRuntimeKind === 'bark-battle' && barkBattlePublishedConfig) { + if ( + activeRecommendRuntimeKind === 'bark-battle' && + barkBattlePublishedConfig + ) { return ( work.workId === entry.workId) ?? - barkBattleGalleryEntries.find((work) => work.workId === entry.workId) ?? + barkBattleGalleryEntries.find( + (work) => work.workId === entry.workId, + ) ?? mapBarkBattlePublicDetailToWorkSummary(entry); if (!matchedWork?.draftId?.trim()) { setPublicWorkDetailError('这份汪汪声浪缺少可编辑草稿。'); @@ -15626,21 +15696,28 @@ export function PlatformEntryFlowShellImpl({ } > - { - void executeMatch3DAction(payload); - }} - initialFormPayload={match3dFormDraftPayload} - onCreateFromForm={(payload) => { - runProtectedAction(() => { - void createMatch3DDraftFromForm(payload); - }); - }} - /> + + { + void executeMatch3DAction(payload); + }} + initialFormPayload={match3dFormDraftPayload} + onCreateFromForm={(payload) => { + runProtectedAction(() => { + void createMatch3DDraftFromForm(payload); + }); + }} + /> + )} @@ -15656,7 +15733,8 @@ export function PlatformEntryFlowShellImpl({ } > - @@ -16413,14 +16481,21 @@ export function PlatformEntryFlowShellImpl({ } > - { - void compileWoodenFishSession(result, payload); - }} - /> + + { + void compileWoodenFishSession(result, payload); + }} + /> + )} @@ -16436,7 +16511,8 @@ export function PlatformEntryFlowShellImpl({ } > - @@ -16570,25 +16636,32 @@ export function PlatformEntryFlowShellImpl({ } > - { - void submitPuzzleMessage(payload); - }} - onExecuteAction={(payload) => { - executePuzzleWorkspaceAction(payload); - }} - initialFormPayload={puzzleFormDraftPayload} - onCreateFromForm={(payload) => { - void createPuzzleDraftFromForm(payload); - }} - onAutoSaveForm={(payload) => { - void savePuzzleFormDraft(payload); - }} - /> + + { + void submitPuzzleMessage(payload); + }} + onExecuteAction={(payload) => { + executePuzzleWorkspaceAction(payload); + }} + initialFormPayload={puzzleFormDraftPayload} + onCreateFromForm={(payload) => { + void createPuzzleDraftFromForm(payload); + }} + onAutoSaveForm={(payload) => { + void savePuzzleFormDraft(payload); + }} + /> + )} @@ -16626,7 +16699,8 @@ export function PlatformEntryFlowShellImpl({ } > - @@ -17028,32 +17093,33 @@ export function PlatformEntryFlowShellImpl({ )} - {selectionStage === 'bark-battle-generating' && barkBattleDraftConfig && ( - - } + {selectionStage === 'bark-battle-generating' && + barkBattleDraftConfig && ( + - { - enterCreateTab(); - selectionStageRef.current = 'platform'; - setSelectionStage('platform'); - }} - onComplete={handleBarkBattleGenerationComplete} - onError={setBarkBattleError} - /> - - - )} + } + > + { + enterCreateTab(); + selectionStageRef.current = 'platform'; + setSelectionStage('platform'); + }} + onComplete={handleBarkBattleGenerationComplete} + onError={setBarkBattleError} + /> + + + )} {selectionStage === 'bark-battle-result' && barkBattleDraftConfig && ( )} - {selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && ( - - } + {selectionStage === 'bark-battle-runtime' && + barkBattlePublishedConfig && ( + - { - if ( - barkBattleRuntimeReturnStage === 'bark-battle-result' && - barkBattleDraftConfig - ) { - setSelectionStage('bark-battle-result'); - } else { - enterCreateTab(); - setSelectionStage('platform'); - } - }} - /> - - - )} + } + > + { + if ( + barkBattleRuntimeReturnStage === 'bark-battle-result' && + barkBattleDraftConfig + ) { + setSelectionStage('bark-battle-result'); + } else { + enterCreateTab(); + setSelectionStage('platform'); + } + }} + /> + + + )} {selectionStage === 'custom-world-result' && sessionController.generatedCustomWorldProfile && ( @@ -17319,7 +17386,7 @@ export function PlatformEntryFlowShellImpl({ {creationEntryConfig ? ( - { + test('按后端字段 spec 暴露统一创作页字段契约', () => { + render( + +
敲木鱼工作台
+
, + ); + + const root = screen + .getByText('敲木鱼工作台') + .closest('.unified-creation-page'); + expect(root?.getAttribute('data-play-id')).toBe('wooden-fish'); + expect(root?.getAttribute('data-field-kinds')).toBe( + 'text,image,audio,text', + ); + expect(root?.getAttribute('data-workspace-stage')).toBe( + 'wooden-fish-workspace', + ); + expect(root?.getAttribute('data-generation-stage')).toBe( + 'wooden-fish-generating', + ); + expect(root?.getAttribute('data-result-stage')).toBe('wooden-fish-result'); + + const fields = screen.getAllByTestId('unified-creation-field'); + expect(fields.map((field) => field.getAttribute('data-field-id'))).toEqual([ + 'hitObjectPrompt', + 'hitObjectReferenceImage', + 'hitSoundAsset', + 'floatingWords', + ]); + expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio'); + expect(fields[3]?.getAttribute('data-required')).toBe('true'); + }); +}); diff --git a/src/components/unified-creation/UnifiedCreationPage.tsx b/src/components/unified-creation/UnifiedCreationPage.tsx new file mode 100644 index 00000000..950b01d7 --- /dev/null +++ b/src/components/unified-creation/UnifiedCreationPage.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; + +import type { UnifiedCreationSpec } from './unifiedCreationSpecs'; + +type UnifiedCreationPageProps = { + spec: UnifiedCreationSpec; + children: ReactNode; +}; + +export function UnifiedCreationPage({ + spec, + children, +}: UnifiedCreationPageProps) { + return ( +
field.kind).join(',')} + data-workspace-stage={spec.workspaceStage} + data-generation-stage={spec.generationStage} + data-result-stage={spec.resultStage} + > +
+

{spec.title}

+
    + {spec.fields.map((field) => ( +
  • + {field.label} +
  • + ))} +
+
+ {children} +
+ ); +} + +export default UnifiedCreationPage; diff --git a/src/components/unified-creation/UnifiedGenerationPage.test.tsx b/src/components/unified-creation/UnifiedGenerationPage.test.tsx new file mode 100644 index 00000000..015199b2 --- /dev/null +++ b/src/components/unified-creation/UnifiedGenerationPage.test.tsx @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; +import { UnifiedGenerationPage } from './UnifiedGenerationPage'; + +function createProgress(): CustomWorldGenerationProgress { + return { + phaseId: 'puzzle-cover-image', + phaseLabel: '生成拼图首图', + phaseDetail: '正在生成图片。', + batchLabel: '生成拼图首图', + overallProgress: 36, + completedWeight: 36, + totalWeight: 100, + elapsedMs: 12_000, + estimatedRemainingMs: 30_000, + activeStepIndex: 0, + steps: [ + { + id: 'puzzle-cover-image', + label: '生成拼图首图', + detail: '正在生成图片。', + completed: 0.36, + total: 1, + status: 'active', + }, + ], + }; +} + +describe('UnifiedGenerationPage', () => { + test('按玩法下发统一生成页文案并透传进度', () => { + render( + {}} + onEditSetting={() => {}} + onRetry={() => {}} + />, + ); + + expect(document.body.textContent).toContain('拼图图片生成进度'); + expect(screen.getByText('图片生成中')).toBeTruthy(); + expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0); + expect(screen.getByText('当前拼图信息')).toBeTruthy(); + }); +}); diff --git a/src/components/unified-creation/UnifiedGenerationPage.tsx b/src/components/unified-creation/UnifiedGenerationPage.tsx new file mode 100644 index 00000000..e50f823e --- /dev/null +++ b/src/components/unified-creation/UnifiedGenerationPage.tsx @@ -0,0 +1,91 @@ +import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; +import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress'; +import { CustomWorldGenerationView } from '../CustomWorldGenerationView'; +import type { UnifiedCreationPlayId } from './unifiedCreationSpecs'; + +type UnifiedGenerationPageProps = { + playId: UnifiedCreationPlayId; + settingText: string; + anchorEntries?: CustomWorldStructuredAnchorEntry[]; + progress: CustomWorldGenerationProgress | null; + isGenerating: boolean; + error?: string | null; + onBack: () => void; + onEditSetting: () => void; + onRetry: () => void; + hideBatchModule?: boolean; +}; + +const UNIFIED_GENERATION_COPY = { + puzzle: { + retryLabel: '重新生成图片', + settingTitle: '当前拼图信息', + progressTitle: '拼图图片生成进度', + activeBadgeLabel: '图片生成中', + }, + match3d: { + retryLabel: '重新生成草稿', + settingTitle: '当前抓大鹅信息', + progressTitle: '抓大鹅草稿生成进度', + activeBadgeLabel: '素材生成中', + }, + 'wooden-fish': { + retryLabel: '重新生成草稿', + settingTitle: '当前敲木鱼信息', + progressTitle: '敲木鱼草稿生成进度', + activeBadgeLabel: '素材生成中', + }, +} as const satisfies Record< + UnifiedCreationPlayId, + { + retryLabel: string; + settingTitle: string; + progressTitle: string; + activeBadgeLabel: string; + } +>; + +export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) { + return UNIFIED_GENERATION_COPY[playId]; +} + +export function UnifiedGenerationPage({ + playId, + settingText, + anchorEntries = [], + progress, + isGenerating, + error = null, + onBack, + onEditSetting, + onRetry, + hideBatchModule = false, +}: UnifiedGenerationPageProps) { + const copy = getUnifiedGenerationCopy(playId); + + return ( + + ); +} + +export default UnifiedGenerationPage; diff --git a/src/components/unified-creation/unifiedCreationSpecs.test.ts b/src/components/unified-creation/unifiedCreationSpecs.test.ts new file mode 100644 index 00000000..5d18ca54 --- /dev/null +++ b/src/components/unified-creation/unifiedCreationSpecs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest'; + +import { + getUnifiedCreationSpec, + listUnifiedCreationSpecs, +} from './unifiedCreationSpecs'; + +describe('unified creation specs', () => { + test('一期只接拼图、抓大鹅和敲木鱼', () => { + expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual( + ['match3d', 'puzzle', 'wooden-fish'], + ); + }); + + test('字段模型只包含首期公共能力', () => { + const fieldKinds = new Set( + listUnifiedCreationSpecs().flatMap((spec) => + spec.fields.map((field) => field.kind), + ), + ); + + expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']); + }); + + test('三条链路都映射到统一创作、生成、结果阶段', () => { + expect(getUnifiedCreationSpec('puzzle')).toMatchObject({ + workspaceStage: 'puzzle-agent-workspace', + generationStage: 'puzzle-generating', + resultStage: 'puzzle-result', + }); + expect(getUnifiedCreationSpec('match3d')).toMatchObject({ + workspaceStage: 'match3d-agent-workspace', + generationStage: 'match3d-generating', + resultStage: 'match3d-result', + }); + expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({ + workspaceStage: 'wooden-fish-workspace', + generationStage: 'wooden-fish-generating', + resultStage: 'wooden-fish-result', + }); + }); +}); diff --git a/src/components/unified-creation/unifiedCreationSpecs.ts b/src/components/unified-creation/unifiedCreationSpecs.ts new file mode 100644 index 00000000..7a849fc4 --- /dev/null +++ b/src/components/unified-creation/unifiedCreationSpecs.ts @@ -0,0 +1,107 @@ +import type { + CreationEntryTypeConfig, + UnifiedCreationSpec, +} from '../../services/creationEntryConfigService'; + +export type UnifiedCreationPlayId = UnifiedCreationSpec['playId']; +export type { UnifiedCreationSpec }; + +const FALLBACK_UNIFIED_CREATION_SPECS: Record< + UnifiedCreationPlayId, + UnifiedCreationSpec +> = { + puzzle: { + playId: 'puzzle', + title: '想做个什么玩法?', + workspaceStage: 'puzzle-agent-workspace', + generationStage: 'puzzle-generating', + resultStage: 'puzzle-result', + fields: [ + { + id: 'pictureDescription', + kind: 'text', + label: '画面描述', + required: true, + }, + { + id: 'referenceImage', + kind: 'image', + label: '拼图画面', + required: false, + }, + { + id: 'promptReferenceImages', + kind: 'image', + label: '参考图', + required: false, + }, + ], + }, + match3d: { + playId: 'match3d', + title: '想做个什么玩法?', + workspaceStage: 'match3d-agent-workspace', + generationStage: 'match3d-generating', + resultStage: 'match3d-result', + fields: [ + { + id: 'themeText', + kind: 'text', + label: '题材', + required: true, + }, + { + id: 'difficulty', + kind: 'select', + label: '难度', + required: true, + }, + ], + }, + 'wooden-fish': { + playId: 'wooden-fish', + title: '想做个什么玩法?', + workspaceStage: 'wooden-fish-workspace', + generationStage: 'wooden-fish-generating', + resultStage: 'wooden-fish-result', + fields: [ + { + id: 'hitObjectPrompt', + kind: 'text', + label: '敲什么', + required: false, + }, + { + id: 'hitObjectReferenceImage', + kind: 'image', + label: '参考图', + required: false, + }, + { + id: 'hitSoundAsset', + kind: 'audio', + label: '敲击音效', + required: false, + }, + { + id: 'floatingWords', + kind: 'text', + label: '功德有什么', + required: true, + }, + ], + }, +}; + +export function getUnifiedCreationSpec( + playId: UnifiedCreationPlayId, + configType?: CreationEntryTypeConfig | null, +) { + return ( + configType?.unifiedCreationSpec ?? FALLBACK_UNIFIED_CREATION_SPECS[playId] + ); +} + +export function listUnifiedCreationSpecs() { + return Object.values(FALLBACK_UNIFIED_CREATION_SPECS); +} diff --git a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx index 9cd0e387..3d100f39 100644 --- a/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx +++ b/src/components/wooden-fish-creation/WoodenFishWorkspace.tsx @@ -1,14 +1,11 @@ import { ArrowLeft, Loader2, - Mic, - Pause, Plus, Send, X, - Upload, } from 'lucide-react'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import type { WoodenFishAudioAsset, @@ -21,6 +18,7 @@ import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT, WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET, } from '../../services/wooden-fish/woodenFishDefaults'; +import { CreativeAudioInputPanel } from '../common/CreativeAudioInputPanel'; import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel'; type WoodenFishWorkspaceProps = { @@ -68,182 +66,6 @@ function normalizeFloatingWords(words: string[]) { return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS]; } -function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(new Error('音频读取失败,请重试。')); - reader.onload = () => { - if (typeof reader.result !== 'string') { - reject(new Error('音频读取失败,请重试。')); - return; - } - resolve({ - assetId: `local-${source}-${Date.now()}`, - audioSrc: reader.result, - audioObjectKey: '', - assetObjectId: '', - source, - prompt: file.name, - durationMs: null, - }); - }; - reader.readAsDataURL(file); - }); -} - -function WoodenFishAudioInputPanel({ - disabled, - asset, - onAssetChange, - onError, -}: { - disabled: boolean; - asset: WoodenFishAudioAsset | null; - onAssetChange: (asset: WoodenFishAudioAsset | null) => void; - onError: (message: string | null) => void; -}) { - const [isRecording, setIsRecording] = useState(false); - const recorderRef = useRef(null); - const chunksRef = useRef([]); - - const startRecording = async () => { - if (disabled || isRecording) { - return; - } - - try { - if ( - typeof navigator === 'undefined' || - !navigator.mediaDevices?.getUserMedia || - typeof MediaRecorder === 'undefined' - ) { - throw new Error('当前浏览器不支持录音。'); - } - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const recorder = new MediaRecorder(stream); - chunksRef.current = []; - recorder.ondataavailable = (event) => { - if (event.data.size > 0) { - chunksRef.current.push(event.data); - } - }; - recorder.onstop = () => { - const blob = new Blob(chunksRef.current, { - type: recorder.mimeType || 'audio/webm', - }); - stream.getTracks().forEach((track) => track.stop()); - const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, { - type: blob.type, - }); - void readAudioFileAsAsset(file, 'recorded') - .then(onAssetChange) - .catch((caughtError) => { - onError( - caughtError instanceof Error - ? caughtError.message - : '录音保存失败。', - ); - }); - }; - recorderRef.current = recorder; - recorder.start(); - setIsRecording(true); - onError(null); - } catch (caughtError) { - onError( - caughtError instanceof Error ? caughtError.message : '录音启动失败。', - ); - } - }; - - const stopRecording = () => { - recorderRef.current?.stop(); - recorderRef.current = null; - setIsRecording(false); - }; - - return ( -
-
-
- 敲击音效 -
- {asset ? ( - - ) : null} -
-
- - - {asset?.audioSrc ? ( -
-
- ); -} - export function WoodenFishWorkspace({ isBusy = false, error = null, @@ -410,9 +232,12 @@ export function WoodenFishWorkspace({
- disabled={isBusy || isSubmitting} + title="敲击音效" + defaultLabel="默认木鱼音" asset={formState.hitSoundAsset} + buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`} onAssetChange={(asset) => setFormState((current) => ({ ...current, diff --git a/src/services/creationEntryConfigService.ts b/src/services/creationEntryConfigService.ts index 2f38e371..7e3a33bb 100644 --- a/src/services/creationEntryConfigService.ts +++ b/src/services/creationEntryConfigService.ts @@ -13,6 +13,29 @@ export type CreationEntryTypeConfig = { categoryLabel: string; categorySortOrder: number; updatedAtMicros: number; + unifiedCreationSpec?: UnifiedCreationSpec | null; +}; + +export type UnifiedCreationField = { + id: string; + kind: 'text' | 'select' | 'image' | 'audio'; + label: string; + required: boolean; +}; + +export type UnifiedCreationSpec = { + playId: 'puzzle' | 'match3d' | 'wooden-fish'; + title: string; + workspaceStage: + | 'puzzle-agent-workspace' + | 'match3d-agent-workspace' + | 'wooden-fish-workspace'; + generationStage: + | 'puzzle-generating' + | 'match3d-generating' + | 'wooden-fish-generating'; + resultStage: 'puzzle-result' | 'match3d-result' | 'wooden-fish-result'; + fields: UnifiedCreationField[]; }; export type CreationEntryConfig = { diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index 58232ee9..d92c383a 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -467,6 +467,24 @@ function clampProgress(value: number) { return Math.max(0, Math.min(100, Math.round(value))); } +export function resolveMiniGameDraftGenerationStartedAtMs( + startedAt: string | number | null | undefined, + fallbackMs = Date.now(), +) { + if (typeof startedAt === 'number' && Number.isFinite(startedAt)) { + return startedAt; + } + + if (typeof startedAt === 'string') { + const parsed = Date.parse(startedAt); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return fallbackMs; +} + function getStepDefinitions(kind: MiniGameDraftGenerationKind) { if (kind === 'puzzle') { return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle')); @@ -542,6 +560,7 @@ function buildMiniGameProgressSteps( export function createMiniGameDraftGenerationState( kind: MiniGameDraftGenerationKind, + startedAtMs = Date.now(), ): MiniGameDraftGenerationState { return { kind, @@ -559,7 +578,7 @@ export function createMiniGameDraftGenerationState( : kind === 'wooden-fish' ? 'wooden-fish-draft' : 'compile', - startedAtMs: Date.now(), + startedAtMs, completedAssetCount: 0, totalAssetCount: 0, error: null, From c193a352df4509d02b0231663e2115b5dcd3959d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 31 May 2026 05:57:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E7=BB=9F=E4=B8=80=E6=80=BB=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=AD=89=E5=BE=85=E9=A1=B5=E7=AA=84?= =?UTF-8?q?=E5=B1=8F=E8=A3=81=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 33 +- .hermes/shared-memory/development-workflow.md | 2 +- .hermes/shared-memory/document-map.md | 6 +- .hermes/shared-memory/pitfalls.md | 15 + apps/admin-web/src/api/adminApiTypes.ts | 18 + .../AdminCreationEntrySwitchPage.test.tsx | 112 ++++++ .../pages/AdminCreationEntrySwitchPage.tsx | 198 +++++++++- docs/README.md | 4 +- docs/planning/README.md | 13 + ...玩法创作】创作流程统一总计划-2026-05-30.md | 371 ++++++++++++++++++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- ...玩法创作】生成页圆环布局口径-2026-05-23.md | 8 +- quality-gates/README.md | 20 +- ...法创作】跨玩法回归与冒烟门禁-2026-05-30.md | 94 +++++ scripts/dev.mjs | 6 +- server-rs/Cargo.lock | 1 + server-rs/crates/api-server/src/admin.rs | 22 ++ .../crates/module-runtime/src/application.rs | 14 +- server-rs/crates/module-runtime/src/domain.rs | 2 + .../crates/shared-contracts/src/admin.rs | 6 + .../src/creation_entry_config.rs | 96 ++++- .../spacetime-client/src/mapper/runtime.rs | 3 + ...tion_entry_type_admin_upsert_input_type.rs | 1 + .../creation_entry_type_config_type.rs | 7 + .../creation_entry_type_snapshot_type.rs | 1 + server-rs/crates/spacetime-module/Cargo.toml | 1 + .../crates/spacetime-module/src/migration.rs | 5 +- .../src/runtime/creation_entry_config.rs | 24 ++ .../spacetime-module/src/visual_novel.rs | 6 +- .../CustomWorldGenerationView.test.tsx | 9 +- src/components/GenerationProgressHero.tsx | 68 ++-- .../BarkBattleGeneratingView.test.tsx | 9 +- .../JumpHopWorkspace.test.tsx | 92 +++++ .../JumpHopResultView.test.tsx | 144 +++++++ .../JumpHopRuntimeShell.test.tsx | 212 ++++++++++ ...Match3DAgentWorkspace.interaction.test.tsx | 22 ++ .../Match3DAgentWorkspace.tsx | 35 +- .../PlatformEntryFlowShellImpl.tsx | 144 +++++-- .../PuzzleAgentWorkspace.interaction.test.tsx | 23 ++ .../puzzle-agent/PuzzleAgentWorkspace.tsx | 14 +- ...gEntryFlowShell.agent.interaction.test.tsx | 218 ++++++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 16 +- .../rpg-entry/rpgEntryWorldPresentation.ts | 6 +- .../SquareHoleResultView.test.tsx | 102 +++++ .../UnifiedCreationPage.test.tsx | 12 + .../unified-creation/UnifiedCreationPage.tsx | 19 +- .../UnifiedGenerationPage.tsx | 34 +- .../unified-creation/unifiedGenerationCopy.ts | 34 ++ .../VisualNovelAgentWorkspace.test.tsx | 8 +- .../WoodenFishWorkspace.test.tsx | 21 + .../WoodenFishWorkspace.tsx | 8 +- src/index.css | 6 +- 53 files changed, 2192 insertions(+), 161 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx create mode 100644 docs/planning/README.md create mode 100644 docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md create mode 100644 quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md create mode 100644 src/components/jump-hop-creation/JumpHopWorkspace.test.tsx create mode 100644 src/components/jump-hop-result/JumpHopResultView.test.tsx create mode 100644 src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx create mode 100644 src/components/square-hole-result/SquareHoleResultView.test.tsx create mode 100644 src/components/unified-creation/unifiedGenerationCopy.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 93ddf685..d5a01ba1 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,12 +16,37 @@ --- -## 2026-05-27 生成页总进度圆弧锁定固定画布 +## 2026-05-30 创作流程统一化门禁扩展为跨玩法矩阵 -- 背景:多轮圆环角度微调后,`GenerationProgressHero` 的 SVG 圆弧仍会出现底部开口偏斜的问题,且圆环还会随着容器宽度伸缩,导致 UI 看起来时大时小、位置漂移。 -- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空,圆环本体改为固定 `400x400` 画布,不再跟随页面宽度缩放,外层布局只负责定位,不负责改动圆环样式。 +- 背景:统一创作 / 统一生成门禁已经足够覆盖 Phase 2 的入口与壳层,但当前总计划已经推进到 Phase 3-6,继续只保留单页门禁会让 Phase 4 的特殊工作台、Phase 5 的结果页 / 作品架 / 公开详情和 Phase 6 的冻结验收没有统一入口。 +- 决策:`quality-gates/README.md` 继续保留单页门禁与 `dev-stack` 门禁,同时新增跨玩法回归 / 冒烟门禁,按 Phase 2 到 Phase 5 的最小验证集合分层执行;Phase 6 冻结前以这份矩阵为主,不再另外拆新波次。涉及入口配置、统一字段 spec、普通工作台、RPG / Bark Battle / 视觉小说特殊边界、发布 / 公开 / runtime 或本地 smoke 的变更,优先对照这份矩阵补齐验收命令。 +- 影响范围:`quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`、`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`、后续 Phase 2-6 玩法接入与冻结流程。 +- 验证方式:按矩阵执行 `npm run check:encoding`、`npm run typecheck`、`npm run admin-web:typecheck`、对应分期 `npm run test`、`npm run check:visual-novel-vn11`,以及需要时的 `npm run dev:api-server` + `/healthz` smoke。 +- 关联文档:`quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`、`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`。 + +## 2026-05-30 跳一跳结果页直达必须优先恢复作品而不是白屏 + +- 背景:跳一跳结果页已经接入统一壳,但如果用户直接打开 `/creation/jump-hop/result`,旧路径容易因为缺少 `draft` 恢复信息而看起来像白屏,误导成结果页坏了。 +- 决策:`PlatformEntryFlowShellImpl` 的跳一跳恢复顺序固定为 `profileId -> getWorkDetail`,再 `sessionId -> getSession`;两者都拿不到时必须展示 `跳一跳草稿未恢复` 恢复面板和 `返回创作`,不能继续留空白结果页。进入结果页的 smoke 允许恢复面板,但不允许纯空白。 +- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`、`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`。 +- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"`;手测 `/creation/jump-hop/result` 和 `/creation/jump-hop/result?profileId=`。 +- 关联文档:`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-29 一期统一创作页必须提供可见统一外壳 + +- 背景:`UnifiedCreationPage` 首版只暴露隐藏 spec 元数据并包裹旧玩法工作台,用户打开拼图创作页时仍只能看到旧工作台外观,无法验收“统一创作页”。 +- 决策:一期统一创作页(拼图、抓大鹅、敲木鱼)必须由 `UnifiedCreationPage` 提供统一标题栏、内容区和隐藏字段契约;字段元信息只留给测试和代码,不再额外作为可见 chip 占用首屏。玩法工作台只承载具体输入控件、上传、历史素材、校验和提交,不再各自渲染巨大入口标题。拼图继续复用 `PuzzleAgentWorkspace` 的上传、裁剪、历史图、AI 重绘和提交逻辑,抓大鹅继续复用 `Match3DAgentWorkspace` 的题材与难度表单逻辑;二者在统一壳内启用 `unifiedChrome`,收起旧标题与外层壳。敲木鱼右侧音效和功德面板不得再套内部滚动容器,移动端应自然跟随页面滚动。 +- 追加决策:`UnifiedCreationPage` 不创建自己的纵向滚动窗;拼图、抓大鹅和敲木鱼三个统一创作入口由平台 stage 承担整页滚动,竖屏移动端必须能从统一标题、表单控件一路滑到提交按钮,避免工作台内部或右侧面板形成套滚动。 +- 影响范围:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`、`src/components/wooden-fish-creation/WoodenFishWorkspace.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、玩法链路文档。 +- 验证方式:`UnifiedCreationPage` 测试应断言隐藏契约仍在但 UI 不再出现字段 chip;拼图和抓大鹅工作台测试应断言 `unifiedChrome=true` 时不再渲染旧巨大标题且仍保留表单输入;木鱼工作台测试或手测应确认敲击音效和功德词条不再停留在独立滚动窗内。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-27 生成页总进度圆弧锁定固定 SVG 坐标系 + +- 背景:多轮圆环角度微调后,`GenerationProgressHero` 的 SVG 圆弧仍会出现底部开口偏斜的问题;后来窄屏验收又发现固定 `400px` 外层宽度会让等待页右侧被裁切。 +- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空。SVG 内部坐标系固定为 `400x400`,圆弧使用 `r=166` 和 `strokeWidth=18`;外层显示宽度以 `400px` 为上限,窄屏按 `min(400px, calc(100vw - 2.5rem))` 等比收缩。预计等待 / 已耗时信息卡在窄屏下落到圆环下方两列,`sm` 及以上再回到左右悬浮。 - 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。 -- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135`、`data-ring-fill-start-degrees=135`,且圆环容器固定为 `h-[400px] w-[400px]`,track / fill transform 都是 `rotate(135 200 200)`。 +- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135`、`data-ring-fill-start-degrees=135`,且圆环容器包含 `w-[min(400px,calc(100vw-2.5rem))]`、`max-w-full` 与 `aspect-square`,track / fill transform 都是 `rotate(135 200 200)`;竖屏 smoke 至少覆盖 `280px / 320px / 360px / 390px` 宽度。 - 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 ## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 23e61f20..6bafa510 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -256,7 +256,7 @@ npm run check:server-rs-ddd ## 提交前建议让 Hermes 执行 -涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路或本地 dev 栈时,先按 `quality-gates/README.md` 和对应门禁文档执行自动脚本与体验检查。 +涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路、Phase 2 之后的跨玩法回归或本地 dev 栈时,先按 `quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 和对应单项门禁文档执行自动脚本与体验检查。 ```text 请检查当前 git diff,指出: diff --git a/.hermes/shared-memory/document-map.md b/.hermes/shared-memory/document-map.md index 512c5949..62a5538f 100644 --- a/.hermes/shared-memory/document-map.md +++ b/.hermes/shared-memory/document-map.md @@ -11,6 +11,7 @@ | 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` | | 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | | 创作入口、草稿架和玩法链路 | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` | +| 创作流程统一阶段计划 | `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` | | 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | @@ -32,8 +33,9 @@ 玩法 / 创作入口 / 运行态: 1. `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` -2. `docs/【项目基线】当前产品与工程约束-2026-05-15.md` -3. 相关前端组件、service、shared contract 和后端 module +2. 若任务涉及跨玩法创作流程统一,读取 `docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md` +3. `docs/【项目基线】当前产品与工程约束-2026-05-15.md` +4. 相关前端组件、service、shared contract 和后端 module 生产部署 / 服务器 / Jenkins: diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 3dea8cd4..caca24d6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1537,6 +1537,13 @@ - 现象:移动端创作 Tab 里进入汪汪声浪表单后,页面右侧出现不自然的内层滚动条,最后的形象描述输入框容易被“生成草稿”按钮、键盘或底部 TabBar 挤压 / 遮挡;顶部玩法卡首尾也可能贴边显得被裁。 - 原因:外层 `.platform-tab-panel` 已经是纵向滚动容器,创作页中间又有多层 `overflow-hidden`,旧的 `BarkBattleConfigEditor` 根节点再加 `overflow-y-auto`,形成外层 Tab 面板 + 内层表单的套滚动;底部按钮只预留 safe-area,不预留真实操作区距离;顶部玩法卡横向滚动条隐藏且首尾没有 scroll padding。 - 处理:移动端让 Bark Battle 表单跟随父级滚动,`lg` 以上才恢复表单内滚动;创作页容器移动端使用 `overflow-visible` 和 safe-area 底部 padding;顶部模板 tablist 加 `scroll-px-3` / 横向 padding,移动端卡片宽度收窄,避免首尾 ring 和圆角贴边裁切。 + +## 统一创作页不要把竖屏滚动锁进内部内容区 + +- 现象:竖屏打开拼图、抓大鹅或敲木鱼创作页时,浏览器页面本身无法滚动,生成按钮或右侧表单面板落到视口外;木鱼的敲击音效和功德词条看起来像被塞进单独滑动窗口。 +- 原因:平台根壳固定一屏并隐藏溢出,`UnifiedCreationPage` 又使用 `h-full min-h-0 overflow-hidden` 和内容区 `overflow-y-auto`,导致滚动责任落到内部内容窗,而不是整个创作 stage。 +- 处理:`UnifiedCreationPage` 只保留统一标题、隐藏字段契约和内容包装,不再设置内部纵向滚动;拼图、抓大鹅和敲木鱼三个统一创作入口的 `motion.div` stage 负责 `overflow-y-auto overflow-x-hidden`。拼图和抓大鹅在 `unifiedChrome` 下收起旧 `h-full overflow-hidden` 外壳,让表单主体跟随 stage 滚动。 +- 验证:用竖屏浏览器视口打开 `/creation/wooden-fish`、`/creation/puzzle` 和 `/creation/match3d`,页面级 stage 应可滚动到生成按钮;`.unified-creation-page__content` 不应包含 `overflow-y-auto`,木鱼工作台内部也不应出现独立纵向滚动容器,拼图 / 抓大鹅可见标题不应重复。 - 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab shows template tabs"`、移动端视口检查最后一个输入框与“生成草稿”按钮不重叠。 - 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1596,6 +1603,14 @@ - 验证:移动端视口检查视频 `rect` 应覆盖整个视口,`paused` 应最终变为 `false`,`currentTime` 应持续前进。 - 关联:`src/components/GenerationProgressHero.tsx`、`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 +## 跳一跳结果页直达时不要把恢复面板当成空白页 + +- 现象:浏览器直接打开 `/creation/jump-hop/result`,如果没有 `sessionId`、`profileId`、`draftId` 或 `workId`,页面以前会看起来像空白,容易误判成结果页坏了。 +- 原因:跳一跳结果页恢复原先只盯 `jumpHopSession.draft`,没有把“缺恢复信息”明确兜成可见恢复面板;直达结果页时也没有优先用 `profileId -> getWorkDetail` 补回完整作品。 +- 处理:`PlatformEntryFlowShellImpl` 的跳一跳恢复逻辑改成先尝试 `profileId -> getWorkDetail`,再尝试 `sessionId -> getSession`;两者都没有时显示 `跳一跳草稿未恢复` 和 `返回创作`,不再留空白页。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"`,并手测 `/creation/jump-hop/result` 与 `/creation/jump-hop/result?profileId=` 两种情况。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md`。 + 2026-05-24 补充:`GenerationPageBackdrop` 不要通过 portal 挂到 `document.body`。body 级 fixed 背景会逃离生成页自己的 stacking context,即使业务内容有局部 `z-10`,真实浏览器里也可能把整页 UI 压住。背景视频应作为生成页根容器子节点保留 `fixed inset-0 z-0`,生成页内容保持 `relative z-10`;相关测试应同时断言背景容器低层级、生成页根容器高层级,以及视频节点仍在生成页 DOM 内部。视觉调整时还要记住:空心圆环的中心块要抽掉,时间卡与总进度标题都应缩小,不要让生成页再回到“纯色底 + 大字号说明卡”的状态。顶部返回和右上状态也不能沿用 `text-lg` / `sm:text-2xl` 这类展示级字号;当前步骤名、步骤状态和底部玩法信息标题要维持普通 UI 字号档位,优先保持 `text-xs` 到 `text-sm` 区间。 2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟`、`1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 6bcb7c11..3ba26abc 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -161,6 +161,7 @@ export interface AdminCreationEntryTypeConfigPayload { categoryLabel: string; categorySortOrder: number; updatedAtMicros: number; + unifiedCreationSpec?: UnifiedCreationSpecPayload | null; } export interface AdminUpsertCreationEntryTypeConfigRequest { @@ -175,6 +176,23 @@ export interface AdminUpsertCreationEntryTypeConfigRequest { categoryId: string; categoryLabel: string; categorySortOrder: number; + unifiedCreationSpec?: UnifiedCreationSpecPayload | null; +} + +export interface UnifiedCreationSpecPayload { + playId: string; + title: string; + workspaceStage: string; + generationStage: string; + resultStage: string; + fields: UnifiedCreationFieldPayload[]; +} + +export interface UnifiedCreationFieldPayload { + id: string; + kind: 'text' | 'select' | 'image' | 'audio'; + label: string; + required: boolean; } export interface AdminWorkVisibilityEntryPayload { diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx new file mode 100644 index 00000000..75c84504 --- /dev/null +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -0,0 +1,112 @@ +/* @vitest-environment jsdom */ + +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import { + getAdminCreationEntryConfig, + upsertAdminCreationEntryConfig, +} from '../api/adminApiClient'; +import type { + AdminCreationEntryConfigResponse, + UnifiedCreationSpecPayload, +} from '../api/adminApiTypes'; +import {AdminCreationEntrySwitchPage} from './AdminCreationEntrySwitchPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminCreationEntryConfig: vi.fn(), + isAdminApiError: vi.fn(() => false), + upsertAdminCreationEntryConfig: vi.fn(), +})); + +const puzzleSpec: UnifiedCreationSpecPayload = { + playId: 'puzzle', + title: '想做个什么玩法?', + workspaceStage: 'puzzle-agent-workspace', + generationStage: 'puzzle-generating', + resultStage: 'puzzle-result', + fields: [ + { + id: 'pictureDescription', + kind: 'text', + label: '画面描述', + required: true, + }, + ], +}; + +const configResponse: AdminCreationEntryConfigResponse = { + entries: [ + { + id: 'puzzle', + title: '拼图', + subtitle: '拼图关卡创作', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 30, + categoryId: 'recent', + categoryLabel: '最近创作', + categorySortOrder: 10, + updatedAtMicros: 1, + unifiedCreationSpec: puzzleSpec, + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse); + vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse); +}); + +test('创作入口后台展示并保存统一创作契约', async () => { + const user = userEvent.setup(); + const {container} = render( + , + ); + + await screen.findByText('pictureDescription'); + expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull(); + expect(container.querySelector('.admin-panel .admin-panel')).toBeNull(); + expect(container.querySelector('.admin-muted')).toBeNull(); + + await user.click(screen.getByRole('button', {name: '保存入库'})); + await user.click(screen.getByRole('button', {name: '确认'})); + + await waitFor(() => { + expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + id: 'puzzle', + unifiedCreationSpec: puzzleSpec, + }), + ); + }); +}); + +test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => { + const user = userEvent.setup(); + render( + , + ); + + const textarea = await screen.findByLabelText('契约 JSON'); + fireEvent.change(textarea, { + target: { + value: JSON.stringify({ + ...puzzleSpec, + playId: 'match3d', + }), + }, + }); + await user.click(screen.getByRole('button', {name: '保存入库'})); + + expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy(); + expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index fb817c65..4a06ddd9 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -5,7 +5,11 @@ import { getAdminCreationEntryConfig, upsertAdminCreationEntryConfig, } from '../api/adminApiClient'; -import type {AdminCreationEntryTypeConfigPayload} from '../api/adminApiTypes'; +import type { + AdminCreationEntryTypeConfigPayload, + UnifiedCreationFieldPayload, + UnifiedCreationSpecPayload, +} from '../api/adminApiTypes'; import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; import {handlePageError} from './pageUtils'; @@ -30,6 +34,7 @@ export function AdminCreationEntrySwitchPage({ const [categoryId, setCategoryId] = useState('recent'); const [categoryLabel, setCategoryLabel] = useState('最近创作'); const [categorySortOrder, setCategorySortOrder] = useState('10'); + const [unifiedCreationSpecJson, setUnifiedCreationSpecJson] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); @@ -66,6 +71,14 @@ export function AdminCreationEntrySwitchPage({ const targetId = selectedId.trim(); setErrorMessage(''); + const unifiedCreationSpecResult = parseUnifiedCreationSpecJson( + targetId, + unifiedCreationSpecJson, + ); + if (!unifiedCreationSpecResult.ok) { + setErrorMessage(unifiedCreationSpecResult.message); + return; + } const confirmed = await confirmWrite({ action: '保存创作入口开关', target: targetId, @@ -88,6 +101,7 @@ export function AdminCreationEntrySwitchPage({ categoryId: categoryId.trim(), categoryLabel: categoryLabel.trim(), categorySortOrder: parseInteger(categorySortOrder), + unifiedCreationSpec: unifiedCreationSpecResult.spec, }); const nextEntries = sortEntries(response.entries); setEntries(nextEntries); @@ -114,6 +128,7 @@ export function AdminCreationEntrySwitchPage({ setCategoryId(entry.categoryId); setCategoryLabel(entry.categoryLabel); setCategorySortOrder(String(entry.categorySortOrder)); + setUnifiedCreationSpecJson(formatUnifiedCreationSpecJson(entry.unifiedCreationSpec)); } return ( @@ -224,6 +239,26 @@ export function AdminCreationEntrySwitchPage({ /> +
+
+ 统一创作契约 + {unifiedCreationSpecJson.trim() ? '已配置' : '未配置'} +
+ {unifiedCreationSpecJson.trim() ? ( + + ) : ( +
未配置统一创作页契约
+ )} +