收口创作流程统一总计划并修复等待页窄屏裁切

This commit is contained in:
2026-05-31 05:57:34 +00:00
parent 551d436919
commit c193a352df
53 changed files with 2192 additions and 161 deletions

View File

@@ -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=<id>`
- 关联文档:`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 平台跨流程错误统一用可复制来源弹窗展示

View File

@@ -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指出

View File

@@ -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

View File

@@ -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=<id>` 两种情况。
- 关联:`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%`

View File

@@ -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 {

View File

@@ -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(
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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();
});

View File

@@ -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({
/>
</label>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span></span>
<span>{unifiedCreationSpecJson.trim() ? '已配置' : '未配置'}</span>
</div>
{unifiedCreationSpecJson.trim() ? (
<UnifiedCreationSpecSummary specJson={unifiedCreationSpecJson} />
) : (
<div className="admin-muted-text"></div>
)}
<label className="admin-field">
<span> JSON</span>
<textarea
rows={12}
value={unifiedCreationSpecJson}
onChange={(event) => setUnifiedCreationSpecJson(event.target.value)}
/>
</label>
</section>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
@@ -246,6 +281,7 @@ export function AdminCreationEntrySwitchPage({
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -264,6 +300,7 @@ export function AdminCreationEntrySwitchPage({
</td>
<td>{entry.visible ? '是' : '否'}</td>
<td>{entry.open ? '是' : '否'}</td>
<td>{entry.unifiedCreationSpec ? '是' : '否'}</td>
<td>{entry.categoryLabel || entry.categoryId}</td>
<td>{entry.sortOrder}</td>
</tr>
@@ -295,3 +332,162 @@ function parseInteger(value: string) {
}
return parsed;
}
function formatUnifiedCreationSpecJson(
spec: UnifiedCreationSpecPayload | null | undefined,
) {
return spec ? JSON.stringify(spec, null, 2) : '';
}
function parseUnifiedCreationSpecJson(entryId: string, value: string) {
const parsed = parseUnifiedCreationSpecSummaryJson(value);
if (!parsed.ok || !parsed.spec) {
return parsed;
}
if (parsed.spec.playId !== entryId) {
return {ok: false as const, message: '统一创作契约 playId 必须与入口 ID 一致'};
}
return parsed;
}
function validateUnifiedCreationSpec(value: unknown) {
if (!isRecord(value)) {
return {ok: false as const, message: '统一创作契约必须是对象'};
}
const playId = readRequiredString(value, 'playId');
if (!playId) {
return {ok: false as const, message: '统一创作契约 playId 不能为空'};
}
const title = readRequiredString(value, 'title');
const workspaceStage = readRequiredString(value, 'workspaceStage');
const generationStage = readRequiredString(value, 'generationStage');
const resultStage = readRequiredString(value, 'resultStage');
if (!title || !workspaceStage || !generationStage || !resultStage) {
return {ok: false as const, message: '统一创作契约标题和阶段不能为空'};
}
if (new Set([workspaceStage, generationStage, resultStage]).size !== 3) {
return {ok: false as const, message: '统一创作契约阶段不能重复'};
}
if (!Array.isArray(value.fields) || value.fields.length === 0) {
return {ok: false as const, message: '统一创作契约 fields 不能为空'};
}
const fieldIds = new Set<string>();
const fields: UnifiedCreationFieldPayload[] = [];
for (const item of value.fields) {
if (!isRecord(item)) {
return {ok: false as const, message: '统一创作契约字段必须是对象'};
}
const id = readRequiredString(item, 'id');
const label = readRequiredString(item, 'label');
if (!id || !label) {
return {ok: false as const, message: '统一创作契约字段 id 和 label 不能为空'};
}
if (fieldIds.has(id)) {
return {ok: false as const, message: `统一创作契约字段 id 重复:${id}`};
}
fieldIds.add(id);
if (!isUnifiedCreationFieldKind(item.kind)) {
return {ok: false as const, message: `统一创作契约字段 kind 非法:${id}`};
}
if (typeof item.required !== 'boolean') {
return {ok: false as const, message: `统一创作契约字段 required 非法:${id}`};
}
fields.push({
id,
kind: item.kind,
label,
required: item.required,
});
}
return {
ok: true as const,
spec: {
playId,
title,
workspaceStage,
generationStage,
resultStage,
fields,
},
};
}
function UnifiedCreationSpecSummary({specJson}: {specJson: string}) {
const parsed = parseUnifiedCreationSpecSummaryJson(specJson);
if (!parsed.ok || !parsed.spec) {
return (
<div className="admin-alert" role="status">
{'message' in parsed ? parsed.message : '未配置统一创作页契约'}
</div>
);
}
return (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{parsed.spec.playId}</dd>
</div>
<div>
<dt></dt>
<dd>
{parsed.spec.workspaceStage} / {parsed.spec.generationStage} /{' '}
{parsed.spec.resultStage}
</dd>
</div>
<div>
<dt></dt>
<dd>{parsed.spec.fields.map((field) => field.id).join('、')}</dd>
</div>
</dl>
);
}
function parseUnifiedCreationSpecSummaryJson(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return {ok: true as const, spec: null};
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (error) {
return {
ok: false as const,
message: error instanceof Error ? `契约 JSON 非法:${error.message}` : '契约 JSON 非法',
};
}
const validation = validateUnifiedCreationSpec(parsed);
if (!validation.ok) {
return validation;
}
return {ok: true as const, spec: validation.spec};
}
function readRequiredString(value: Record<string, unknown>, key: string) {
const raw = value[key];
return typeof raw === 'string' ? raw.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isUnifiedCreationFieldKind(
value: unknown,
): value is UnifiedCreationFieldPayload['kind'] {
return (
value === 'text' ||
value === 'select' ||
value === 'image' ||
value === 'audio'
);
}

View File

@@ -8,7 +8,7 @@
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级;创作流程统一总计划见 [【玩法创作】创作流程统一总计划-2026-05-30.md](./planning/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E6%B5%81%E7%A8%8B%E7%BB%9F%E4%B8%80%E6%80%BB%E8%AE%A1%E5%88%92-2026-05-30.md)
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
@@ -39,7 +39,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。
3. 需要排期时看 [规划与优先级](./planning/README.md)。
3. 需要排期时看 [规划与优先级](./planning/README.md),创作链路相关任务优先看创作流程统一总计划
4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md);涉及后端先看 [当前后端实现基线](./technical/CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md),涉及生产发布链路先看 [生产部署计划](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),涉及 SpacetimeDB 表结构变更时再看 [表结构变更约束](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
5. 需要对齐目标边界时再进入 [PRD](./prd)。

13
docs/planning/README.md Normal file
View File

@@ -0,0 +1,13 @@
# 规划与优先级
本目录保存仍处于推进中的阶段计划、并行任务拆分、可派发任务包和验收顺序。长期稳定的产品与架构口径仍以根部融合文档为准。
## 当前计划
- [【玩法创作】创作流程统一总计划-2026-05-30.md](./【玩法创作】创作流程统一总计划-2026-05-30.md):创作入口、统一创作页、统一生成页、结果页、发布、作品架、广场和运行态的阶段计划、进度记录、并行波次和可直接派发的任务包。
## 维护规则
- 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``.hermes/shared-memory/`
- 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。

View File

@@ -0,0 +1,371 @@
# 创作流程统一总计划
更新时间:`2026-05-30`
## 总览
| 项目 | 当前值 |
| --- | --- |
| 总轮次 | 5 |
| 当前轮次 | Round 4已收口 |
| 当前阶段 | Phase 6 |
| 当前状态 | Phase 0~6 已收口;跨玩法回归、移动端竖屏 smoke、API smoke 和冻结材料已补齐 |
| 当前并行波次 | 波次 D验收与冻结 |
| 当前重点 | 以跨玩法门禁作为后续新增玩法和回归的常规质量基线 |
## 目标与范围
本计划统一 Genarrative 所有玩法的创作链路,不再只跟踪首批的拼图、抓大鹅和敲木鱼。最终目标是让各玩法按同一条平台链路交付:
```text
创作入口 -> 统一创作页/工作台 -> 统一生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情/作品架/广场 -> 正式 runtime
```
统一不是把所有玩法 UI 做成同一个表单,而是统一阶段、契约、恢复、生成反馈、错误承接、发布后去向和验收门禁。各玩法工作台仍负责真实输入控件、资产槽位、校验和提交。
## 当前进度
| 阶段 | 状态 | 说明 |
| --- | --- | --- |
| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md``docs/README.md``.hermes/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 |
| Phase 1 首批统一壳 | 已收口 | `puzzle``match3d``wooden-fish` 已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,竖屏滚动和字段契约已回归。 |
| Phase 2 契约与配置治理 | 已完成 | `creationTypes[].unifiedCreationSpec`、前端 fallback、后台配置校验和文档门禁已按现有测试与 schema 检查收口。 |
| Phase 3 剩余表单/图片工作台接入 | 已收口 | 跳一跳、宝贝识物、方洞结果页与首批普通工作台回归已通过;方洞、大鱼按当前形态纳入最小回归,后续若迁移工作台再单独立项。 |
| Phase 4 特殊工作台接入策略 | 已收口 | RPG、视觉小说、汪汪声浪的最小例外/闭环回归已通过,例外口径已落到平台总链路文档。 |
| Phase 5 结果页、发布、作品架与广场收口 | 已收口 | 结果页、发布、公开详情、推荐 runtime 与公开 read model 最小自动回归已通过,公开详情作者展示口径已统一。 |
| Phase 6 全链路验收与冻结 | 已收口 | 跨玩法 smoke、移动端优先验收、回归矩阵、长期维护规则和冻结证据已补齐。 |
此表即总进度记录,后续每次 phase 收口、启动或回退时,只更新这里和下方任务状态。
当前已完成 Round 0~4 / Phase 0~6。后续新增玩法或统一链路改动以本文档和 `quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 为常规质量基线。
## 执行轮次
按可交付批次拆成 5 轮Round 0~4。当前已完成 Round 0~4Round 4 / 波次 D 已作为冻结基线收口。
| 轮次 | 覆盖阶段 | 目标 | 状态 |
| --- | --- | --- | --- |
| Round 0 | Phase 0 | 补齐总计划、文档入口和并行任务表 | 已完成 |
| Round 1 | Phase 2 | 契约与配置治理 | 已完成 |
| Round 2 | Phase 3 + Phase 4 | 普通工作台并行接入,特殊工作台先定例外边界 | 已完成 |
| Round 3 | Phase 5 | 结果页、发布、作品架与广场收口 | 已完成 |
| Round 4 | Phase 6 | 全链路验收与冻结 | 已完成 |
## 阶段拆分
### Phase 0总计划与执行门禁
目标:让团队有一个唯一可查的总计划、进度表和并行任务清单。
状态:已完成。
- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md``.hermes/shared-memory/document-map.md` 中补上规划入口。
- 补齐 `当前进度``执行轮次` 和可并行任务表,后续每个 phase 完成后更新本文档的状态、验收命令和风险。
退出条件:
- 文档入口可从 `docs/README.md` 找到。
- 总计划包含阶段、进度、并行任务、验收和风险。
### Phase 1首批统一壳收口
目标:用低风险的三条链路验证统一创作/生成壳。
- 范围:`puzzle``match3d``wooden-fish`
- 创作页统一经过 `UnifiedCreationPage`,工作台保留各自真实输入能力。
- 生成页统一经过 `UnifiedGenerationPage``CustomWorldGenerationView`
- 竖屏滚动由外层 stage 承担,避免内层滚动窗。
状态:已收口。
### Phase 2契约与配置治理
目标:把统一创作页从前端“能渲染”推进到平台配置“可治理”。
- 明确 `unifiedCreationSpec` 的字段种类、必填语义、阶段映射和兼容 fallback。
- 后台配置、前台读取和 `api-server` 路由熔断继续以 SpacetimeDB 入口配置为事实源。
- 前端本地 fallback 只服务旧后端或本地异常,不作为新增玩法事实源。
- 为字段契约、入口开放状态、阶段映射补自动测试。
退出条件:
- `creation-entry config` 契约在前后端文档中闭合。
- 新增玩法不能绕过统一入口配置接线。
状态:已完成。
验证:`npm run check:encoding``npm run typecheck``npm run admin-web:typecheck``npm run check:spacetime-schema` 和统一创作页 / 统一生成页相关测试已通过。
### Phase 3剩余表单/图片工作台接入
目标:把结构相近的玩法先迁到统一创作与生成壳,扩大覆盖面。
候选范围:
- 第一批直接迁移:`jump-hop``baby-object-match`
- 需要先做工作台形态评估:`square-hole``big-fish`
- 其它已经是表单/图片输入工作台、且无复杂多阶段编辑器的玩法
统一要求:
- 继续复用 `CreativeImageInputPanel``CreativeAudioInputPanel` 等现有通用输入组件。
- 不在工作台 UI 中默认写规则说明或功能解释。
- 自动素材生成走统一生成页;没有自动生成的玩法需要明确跳过生成页的阶段策略。
- 结果页和 runtime 不因迁移创作页而改业务真相。
- `square-hole``big-fish` 先评估是否保留 Agent 形态还是迁到表单/图片工作台,再决定是否进入直接迁移实现。
退出条件:
- 每个接入玩法都有创作页、生成页或跳过生成页的明确验收。
- 移动端竖屏能从标题、表单滚动到提交按钮。
状态:已收口。
2026-05-30 回归记录:
- `jump-hop``baby-object-match``big-fish``square-hole` 的核心工作台 / 结果页 / runtime 测试已补齐并通过。
- `jump-hop` 结果页刷新恢复已补齐 `profileId -> getWorkDetail` 回读;直达 `/creation/jump-hop/result` 且缺少恢复参数时显示“跳一跳草稿未恢复”恢复面板,不再白屏。
- `square-hole` 结果页的试玩 / 发布路径已补齐服务 mock 回归,确认保存成功后才触发试玩和发布回调。
- 首批普通工作台回归通过:拼图、抓大鹅、敲木鱼、跳一跳、宝贝识物、方洞结果页。
- 竖屏浏览器 smoke 已覆盖 `/creation/jump-hop``/creation/baby-object-match``/creation/square-hole``/creation/bark-battle``/creation/visual-novel``/creation/jump-hop/generating``/creation/jump-hop/result``/runtime/jump-hop`,截图保存在 `.app/browser-check/phase-flow-20260530/`
### Phase 4特殊工作台接入策略
目标:处理不能直接套表单/图片工作台的玩法,先定边界再迁移。
候选范围:
- RPG / 自定义世界
- 视觉小说
- 汪汪声浪(`bark-battle`
- 其它多阶段编辑器、对话式 Agent 或特殊创作流
统一要求:
- 必须在玩法文档中写明“创作工具模式例外”。
- 例外只影响工作台内部,不影响入口配置、生成反馈、结果页、发布、作品架、广场和 runtime 的平台主链路。
- 对话式或多阶段编辑器仍需和统一作品、统一错误、统一生成完成反馈对齐。
- `bark-battle` 默认按特殊工作台收口;若后续产品决策确认可完全表单化,再单独前移到 Phase 3不在本轮默认假设里。
退出条件:
- 每个特殊玩法都有例外声明、阶段映射和验收清单。
- 没有新增平行入口系统、平行作品架或平行公开列表。
状态:已收口。
2026-05-30 回归记录:
- RPG 例外边界指定 interaction 测试通过。
- Bark Battle 创作入口、结果页试玩/发布和正式 runtime 指定 interaction 测试通过。
- 视觉小说工作台、生成阶段、结果页和运行态测试通过,`npm run check:visual-novel-vn11` 通过。
### Phase 5结果页、发布、作品架与广场收口
目标:把“创作页统一”推进到“交付链路统一”。
- 发布成功默认进入统一作品详情或明确的 runtime 去向。
- 草稿架能恢复生成中、失败、待发布和已发布状态。
- 公开列表、发现流、详情页优先消费后端 read model 或 BFF 缓存。
- 跨流程错误统一进入 `PlatformErrorDialog`,异步完成统一进入 `PlatformTaskCompletionDialog`
- 私有 generated 图片展示前必须换签。
退出条件:
- 每个可发布玩法都有作品架、公开详情、广场或明确不公开声明。
- 生成中刷新、失败重试、发布后回读和登录切换都有测试或手测记录。
状态:已收口。
2026-05-30 回归记录:
- 发布到作品详情、已发布作品进入详情、体验按钮直达 runtime、详情 profile 回读指定 interaction 测试通过。
- 拼图已发布作品进入首页和移动端游戏分类、Big Fish 公开隐藏口径、Match3D 推荐 runtime 资源回读指定 interaction 测试通过。
- 公开详情作者展示统一为“公开昵称 · 陶泥号”,`PlatformWorkDetailView` 与公共作者展示 helper 测试已回归。
- 本地 API smoke 已在重新拉起 `dev:spacetime``dev:api-server` 后通过:`GET http://127.0.0.1:8082/healthz` 返回 `{"ok":true,"service":"genarrative-api-server"}`
### Phase 6全链路验收与冻结
目标:形成后续新增玩法可复用的稳定门禁。
- 按玩法输出创作入口、生成页、结果页、试玩、发布、作品架、广场、runtime smoke 矩阵。
- 增补 `quality-gates/README.md` 与跨玩法回归 / 冒烟门禁,不再只覆盖首批三条链路。
- 固化移动端竖屏优先验收,桌面端作为兼容验证。
- 补齐“新增玩法接入 PRD 检查块”和代码评审检查清单。
退出条件:
- 全部计划内玩法均有明确状态:已统一、例外接入、暂不接入。
- `npm run typecheck``npm run check:encoding` 和对应玩法门禁通过。
状态:已收口。
2026-05-30 冻结记录:
- Phase 2 自动回归通过:入口配置、统一字段 spec、统一创作页和统一生成页测试共 11 项。
- Phase 3 自动回归通过:拼图、抓大鹅、敲木鱼、跳一跳、宝贝识物、大鱼、方洞结果页相关测试共 67 项;跳一跳直达结果页恢复测试通过。
- Phase 4 / Phase 5 指定交互回归通过RPG 例外边界、Bark Battle 闭环、作品详情、推荐 runtime、公开 read model 与跳一跳恢复相关 interaction 测试共 18 项。
- Phase 5 详情 / 弹窗 / 作品架回归通过:公开详情、错误弹窗、反馈弹窗、作品展示 helper、作品架交互测试共 63 项。
- `npm run check:visual-novel-vn11``npm run check:spacetime-schema``npm run check:encoding` 均通过。
- API smoke 通过:`GET http://127.0.0.1:8082/healthz` 返回 `{"ok":true,"service":"genarrative-api-server"}`
- 竖屏浏览器 smoke 通过并保存截图:`.app/browser-check/phase-flow-20260530-round5/`,覆盖 `/creation/jump-hop``/creation/visual-novel``/creation/square-hole``/creation/bark-battle``/creation/baby-object-match``/creation/jump-hop/generating``/creation/jump-hop/result``/runtime/jump-hop`
### Phase 6 补充:跨玩法最小验收口径
Phase 6 不再继续拆新波次,当前只把 Phase 2 到 Phase 5 的最小验证集合收束成一份可直接执行的门禁矩阵。建议顺序如下:
| 阶段 | 最小命令 | 说明 |
| --- | --- | --- |
| Phase 2 | `npm run check:encoding``npm run typecheck``npm run admin-web:typecheck``npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx` | 校验入口配置、统一字段 spec、统一创作页和统一生成页。 |
| Phase 3 | `npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx src/components/jump-hop-creation/JumpHopWorkspace.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"` | 校验普通表单 / 图片 / 音频工作台仍按结构化 payload 提交,跳一跳结果页直达恢复不白屏,并把 BabyObjectMatch / BigFish 一并纳入最小回归。 |
| Phase 4 | `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "opening RPG agent workspace does not refetch session snapshot in a render loop|create tab resumes agent workspace when draft has no compiled result yet|create tab resumes agent workspace when session has no draft profile even if summary counts look compiled|opening a compiled draft with a missing agent session falls back to draft hub"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens bark battle entry form from the template card|bark battle draft result can test before publish and publish to work detail|direct bark battle runtime public code opens published runtime"``npm run check:visual-novel-vn11` | 校验特殊工作台例外、Bark Battle 公开闭环和视觉小说负向门禁。 |
| Phase 5 | `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "agent draft result publishes to gallery from publish panel|creation hub published work enters existing detail view|creation hub published work experience button enters world directly|creation hub published work start uses loaded detail profile instead of library summary"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "published puzzle works appear on home and mobile game category channel|published big fish works stay hidden from platform home and mobile game category channel|home recommendation Match3D runtime keeps profile generated models when card summary is stale|home recommendation Match3D runtime passes top-level UI background assets|home recommendation Match3D runtime reloads detail when card only has UI assets"``npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/rpg-entry/rpgEntryWorldPresentation.test.ts``npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` | 校验结果页、发布、作品架、公开详情、推荐 runtime 和公开 read model并补公开详情作者展示口径与作品架恢复矩阵。 |
如果这轮还改了 SpacetimeDB schema追加 `npm run check:spacetime-schema`;如果还改了 API 路由、BFF、公开列表或 `/works/detail` 回读,追加 `npm run dev:api-server`,并另开终端从 `.app/dev-stack.json` 读取实际 `api-server` URL 后检查 `/healthz`
## 可并行任务列表
| 任务 ID | 可并行 | 状态 | 任务 | 主要产出 | 依赖 | 建议 Owner |
| --- | --- | --- | --- | --- | --- | --- |
| T0-1 | 否 | 已完成 | 总计划与文档入口 | 本文档、`docs/planning/README.md`、文档索引更新 | 无 | 文档 owner |
| T1-1 | 否 | 已完成 | Phase 1 收口确认 | 三条首批链路验收记录、已知风险 | T0-1 | 前端 owner |
| T2-1 | 是 | 已完成 | `unifiedCreationSpec` 契约审计 | 字段种类、必填、阶段映射、fallback 规则 | T0-1 | 契约 owner |
| T2-2 | 是 | 已完成 | 后台入口配置治理 | 后台配置校验与配置说明 | T2-1 | 后台 owner |
| T2-3 | 是 | 已完成 | 前端入口读取与 fallback 测试 | 入口配置单测、异常兜底测试 | T2-1 | 前端 owner |
| T3-1 | 是 | 自动回归通过 | 跳一跳统一接入方案与实现 | 创作页/生成页迁移、验收 | T2-1 | 玩法 owner A |
| T3-2 | 是 | 自动回归通过 | 宝贝识物统一接入方案与实现 | 创作页/生成页迁移、验收 | T2-1 | 玩法 owner B |
| T3-3 | 是 | 最小回归通过 | 方洞工作台形态评估与迁移方案 | 保留 Agent 形态还是迁表单的决策、边界和风险清单 | T2-1 | 玩法 owner C |
| T3-4 | 是 | 最小回归通过 | 大鱼工作台形态评估与迁移方案 | 保留 Agent 形态还是迁表单的决策、边界和风险清单 | T2-1 | 玩法 owner D |
| T4-1 | 是 | 自动回归通过 | RPG 例外边界设计 | 例外声明、阶段映射、验收清单 | T2-1 | 特殊玩法 owner |
| T4-2 | 是 | 自动回归通过 | 视觉小说例外边界设计 | 例外声明、阶段映射、验收清单 | T2-1 | 特殊玩法 owner |
| T4-3 | 是 | 自动回归通过 | 汪汪声浪bark-battle统一/例外决策 | 接入或例外方案、验收清单 | T2-1 | 特殊玩法 owner |
| T5-1 | 是 | 最小回归通过 | 统一结果页能力矩阵 | 每个玩法结果页能力与缺口表 | T3/T4 方案稳定 | 结果页 owner |
| T5-2 | 是 | 最小回归通过 | 作品架恢复矩阵 | 生成中、失败、待发布、已发布恢复验收 | T3/T4 方案稳定 | 作品架 owner |
| T5-3 | 是 | 最小回归通过 | 公开 read model 对齐 | 广场/详情/分享码缺口与执行清单 | T3/T4 方案稳定 | 后端 owner |
| T5-4 | 是 | 最小回归通过 | 统一错误与完成反馈回归 | `PlatformErrorDialog``PlatformTaskCompletionDialog` 覆盖 | T3/T4 方案稳定 | 平台壳 owner |
| T6-1 | 否 | 已完成 | 全链路质量门禁扩展 | `quality-gates/README.md`、跨玩法回归 / 冒烟门禁、Phase 2 到 Phase 5 最小验证集合 | T3/T4/T5 完成 | QA owner |
| T6-2 | 否 | 已完成 | 全量验收与冻结 | 状态表、未接入声明、最终测试记录 | T6-1 | Release owner |
并行原则:
- T2 系列已完成T3/T4 已进入回归;后续缺口修补仍不得绕过 T2 已固定的入口配置和统一 spec 规则。
- T3 各玩法可并行,但同一文件同一时间只允许一个 owner尤其是 `PlatformEntryFlowShellImpl.tsx`、路由和 shared contracts。
- T5 可以在 T3/T4 各玩法方案稳定后分块并行,不必等所有玩法实现完再开始。
- T6 必须串行收尾,避免验收矩阵和实际实现漂移。
## 可直接派发的任务包
任务包是执行层最小分工单元。每个包都可以单独开分支、单独验收;如果两个包需要改同一个公共文件,先由公共 owner 合并接口或壳层改动,再由玩法 owner 接入,避免互相覆盖。
| 包 ID | 可并行对象 | 状态 | 目标 | 不做什么 | 交付物 | 验收 |
| --- | --- | --- | --- | --- | --- | --- |
| P2-A 契约包 | 可与 P2-B、P2-C 并行 | 已完成 | 固定 `unifiedCreationSpec` 字段类型、必填、阶段映射、fallback 规则 | 不接新玩法,不改 UI 设计方向 | 前后端契约、配置字段文档、契约测试 | `npm run test -- src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/platform-entry/platformEntryCreationTypes.test.ts`;涉及 schema 时追加 `npm run check:spacetime-schema` |
| P2-B 后台配置包 | 可与 P2-A、P2-C 并行 | 已完成 | 后台入口配置能编辑、校验、保存统一创作契约 | 不做玩法工作台迁移 | 后台表单、保存校验、异常提示、后台单测 | `npm run admin-web:typecheck``npm run test -- apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx` |
| P2-C 前台读取包 | 可与 P2-A、P2-B 并行 | 已完成 | 前台从 `/api/creation-entry/config` 读取统一 spec旧后端只走兜底 | 不把 fallback 当事实源,不恢复硬编码入口 | 入口派生、fallback 单测、统一创作/生成页回归 | `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx` |
| P3-A 跳一跳接入包 | 可与 P3-B、P3-C、P3-D 并行 | 自动回归通过 | `jump-hop` 接入统一创作壳、生成页或明确跳过策略 | 不改正式 runtime 规则真相,不重做作品架 | 入口阶段映射、工作台接入、生成/结果跳转、竖屏验收 | 跳一跳相关单测、统一创作页回归、移动端 `/creation/jump-hop` smoke |
| P3-B 宝贝识物接入包 | 可与 P3-A、P3-C、P3-D 并行 | 自动回归通过 | `baby-object-match` 接入统一创作壳、生成页或明确跳过策略 | 不复制上传/历史素材逻辑 | 工作台接入、资产槽位复用、结果跳转、竖屏验收 | 宝贝识物相关单测、统一创作页回归、移动端 `/creation/baby-object-match` smoke |
| P3-C 方洞评估包 | 可与 P3-A、P3-B、P3-D 并行 | 部分回归通过 | 判断 `square-hole` 保留 Agent 形态还是迁表单/图片工作台 | 不直接大改实现 | 例外或迁移方案、字段清单、风险、验收用例 | 文档评审通过;若改代码,补对应工作台测试 |
| P3-D 大鱼评估包 | 可与 P3-A、P3-B、P3-C 并行 | 部分回归通过 | 判断 `big-fish` 保留 Agent 形态还是迁表单/图片工作台 | 不直接大改实现 | 例外或迁移方案、字段清单、风险、验收用例 | 文档评审通过;若改代码,补对应工作台测试 |
| P4-A RPG 例外包 | 可与 P4-B、P4-C 并行 | 自动回归通过 | 明确 RPG 对话式工作台如何接入统一阶段、错误、完成、发布去向 | 不把 RPG 当新增玩法默认模板 | 例外声明、阶段映射、刷新恢复和发布验收 | RPG 指定 interaction 测试、作品详情/进入世界回归 |
| P4-B 视觉小说例外包 | 可与 P4-A、P4-C 并行 | 自动回归通过 | 明确视觉小说特殊生成和结果页边界 | 不迁入外部平台社区、支付、榜单、回放 | 例外声明、上传资产口径、生成/结果/发布验收 | `npm run check:visual-novel-vn11` 和视觉小说相关测试 |
| P4-C 汪汪声浪决策包 | 可与 P4-A、P4-B 并行 | 自动回归通过 | 判断 `bark-battle` 是特殊工作台例外还是回到表单模式 | 不同时做两套入口 | 决策记录、阶段映射、发布和 runtime 验收 | Bark Battle 创作、发布、runtime 指定测试 |
| P5-A 结果页矩阵包 | 可与 P5-B、P5-C、P5-D 并行 | 最小回归通过 | 列清每个玩法结果页能力、缺口和最小补丁 | 不在结果页新增无需求的大功能 | 结果页能力矩阵、局部重试/上传/发布边界 | 对应结果页测试和手测记录 |
| P5-B 作品架恢复包 | 可与 P5-A、P5-C、P5-D 并行 | 最小回归通过 | 生成中、失败、待发布、已发布都能从作品架恢复 | 不只靠前端内存 notice | 作品摘要字段、作品架 adapter、恢复测试 | 作品架相关 interaction 测试;移动端草稿 Tab smoke |
| P5-C 公开 read model 包 | 可与 P5-A、P5-B、P5-D 并行 | 最小回归通过 | 公开列表、详情、分享和推荐 runtime 对齐后端 read model | 不让前端拼源表当事实源 | 后端 read model/BFF 缺口清单和实现 | 公开列表/详情/API smoke必要时 `npm run dev:api-server` + `/healthz` |
| P5-D 统一反馈包 | 可与 P5-A、P5-B、P5-C 并行 | 最小回归通过 | 错误和异步完成统一进入平台弹窗 | 不在页面内重复裸错误 banner | `PlatformErrorDialog``PlatformTaskCompletionDialog` 覆盖矩阵 | 平台弹窗测试、跨流程失败/完成手测 |
| P6-A 门禁包 | 串行,依赖 P3/P4/P5 | 已完成 | 固化跨玩法自动测试、竖屏手测和 API smoke | 不继续接新玩法 | `quality-gates/`、冻结前命令集合 | 跨玩法回归与冒烟门禁通过 |
| P6-B 冻结包 | 串行,依赖 P6-A | 已完成 | 更新总状态表,标记已统一、例外接入、暂不接入 | 不遗留“状态未知”玩法 | 总进度、风险清单、后续维护规则 | `npm run check:encoding`、关键门禁通过、文档索引有效 |
### 并行执行注意事项
- 公共壳层 owner 统一负责 `PlatformEntryFlowShellImpl.tsx``appPageRoutes.ts`、入口阶段类型、共享 contract 和统一生成页主流程;玩法 owner 只接自己的工作台和映射。
- 后端 schema owner 统一负责 SpacetimeDB 表、`migration.rs`、表目录和 bindings玩法 owner 不单独改 schema 后跳过生成绑定。
- 文档 owner 每轮只更新本计划的状态、波次和风险,不把一次性聊天记录写进长期文档。
- 同一波次内如果发现计划和代码事实冲突,先改计划和对应融合文档,再继续实现;不要在代码里临时绕开统一链路。
### 依赖关系摘要
- 可以并行启动:`T3-1``T3-2``T3-3``T3-4`,其中 `T3-3``T3-4` 先做形态评估,`T3-1``T3-2` 可以直接进入实现。
- 可以并行启动:`T4-1``T4-2``T4-3`,但它们只做例外边界和决策,不直接扩散到新的玩法实现。
- `T5-1``T5-2``T5-3``T5-4` 可以并行预研,但最好等至少一批 Phase 3 / 4 方案稳定后再落代码。
- `T6-1``T6-2` 必须串行,且都依赖 Phase 3 到 Phase 5 的验证结果。
## 并行波次
### 波次 A先定契约
状态:已完成。
- `T2-1` `unifiedCreationSpec` 契约审计
- `T2-2` 后台入口配置治理
- `T2-3` 前端入口读取与 fallback 测试
说明:三项可以并行,先把统一创作入口的真值源、后台编辑面和前端兜底一起收紧。
### 波次 B第一批迁移与例外评估
状态:自动回归已通过,遗留形态评估按缺口任务继续跟踪。
- `T3-1` `jump-hop` 统一接入
- `T3-2` `baby-object-match` 统一接入
- `T3-3` `square-hole` 工作台形态评估
- `T3-4` `big-fish` 工作台形态评估
- `T4-1` `RPG` 例外边界设计
- `T4-2` `视觉小说` 例外边界设计
- `T4-3` `汪汪声浪bark-battle` 统一/例外决策
说明:`jump-hop``baby-object-match` 已完成最小接入回归;`square-hole``big-fish` 的形态评估继续按缺口任务跟踪;特殊工作台例外边界已完成最小回归。
### 波次 C交付链路收口
状态:最小回归通过。
- `T5-1` 统一结果页能力矩阵
- `T5-2` 作品架恢复矩阵
- `T5-3` 公开 read model 对齐
- `T5-4` 统一错误与完成反馈回归
说明:交付链路已按页面 / read model / 弹窗分块完成最小回归,后续只处理验收发现的真实缺口。
### 波次 D验收与冻结
状态:已收口。
- `T6-1` 全链路质量门禁扩展
- `T6-2` 全量验收与冻结
说明:必须串行,先补门禁再冻结状态表。
## 验收矩阵
每个玩法推进时至少记录:
| 验收项 | 要求 |
| --- | --- |
| 创作入口 | 从创作 Tab 或直达 URL 能进入对应工作台,入口事实源来自 `/api/creation-entry/config`。 |
| 创作页 | 统一标题/阶段壳存在,工作台不重复渲染巨大旧标题,移动端可滚动到提交按钮。 |
| 输入控件 | 图片、音频、文本、选择器复用现有通用组件,不复制上传/历史图逻辑。 |
| 生成页 | 自动生成玩法使用统一圆环生成页;无生成页玩法有明确跳转策略。 |
| 结果页 | 能展示草稿、编辑作品信息、处理局部重生成、试玩和发布。 |
| 恢复 | 刷新、退出登录、生成中、失败、作品架恢复都有可观察行为。 |
| 发布与公开 | 发布后能进入统一详情或 runtime公开列表/read model 不靠前端拼源表。 |
| runtime | 试玩与正式运行态区分清楚,正式业务真相以后端为准。 |
| 移动端 | 竖屏优先,无按钮遮挡、套滚动、文字溢出或固定底栏遮挡。 |
## 统一约束
- 不恢复前端硬编码入口配置。
- 不新建平行创作入口系统、平行作品架或平行公开列表。
- 不把功能说明、规则说明或开发解释默认写进 UI 面板。
- 不让前端承接发布、计分、胜负、资产持久化或公开状态等业务真相。
- 后端仍按 `server-rs + Axum + SpacetimeDB` 和 DDD 分层推进。
- 涉及 SpacetimeDB schema 时必须同步 migration、表目录、生成绑定并运行 schema 检查。
## 推荐推进顺序
1. 将 Round 4 / Phase 6 作为当前冻结基线,后续只做同口径回归,不再新增波次。
2. 对 Phase 3、Phase 4、Phase 5 只处理回归发现的真实缺口,不扩新玩法。
3. 更新冻结状态表,明确每个玩法是已统一、例外接入还是暂不接入。
4. 后续新增玩法默认遵循本文档和 `quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md`,无需再补新的总计划轮次。
当前轮次看 Round 4 / Phase 6Round 0~4 已作为历史完成记录保留。

View File

@@ -337,8 +337,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`CreationEntryTypeConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`id``title``subtitle``badge``image_src``visible``open``sort_order``updated_at``category_id``category_label``category_sort_order`
- 迁移兼容:旧迁移包缺少入口分类字段时,由 `migration.rs` 写入 `None` / `0` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费。
- 字段:`id``title``subtitle``badge``image_src``visible``open``sort_order``updated_at``category_id``category_label``category_sort_order``unified_creation_spec_json`
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle``match3d``wooden-fish` 默认 spec
### `custom_world_agent_message`

View File

@@ -10,7 +10,7 @@
创作恢复参数只保留 `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`、汪汪声浪、方洞、大鱼、跳一跳和宝贝识物不进入一期接线范围,已有链路保持现状。
一期创作流程统一化只覆盖拼图、抓大鹅和敲木鱼。三者在前端统一经过 `UnifiedCreationPage``UnifiedGenerationPage`:创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec首期字段类型只保留 `text``select``image``audio``UnifiedCreationPage` 只提供统一标题栏、内容区和隐藏字段契约,不在 UI 中额外展示字段说明 chip也不创建自己的纵向滚动窗三条统一创作入口由平台 stage 承担整页纵向滚动,竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交。拼图工作台仍复用既有 `PuzzleAgentWorkspace` 的上传、裁剪、历史图、AI 重绘和提交逻辑,抓大鹅继续复用 `Match3DAgentWorkspace` 的题材与难度表单逻辑,但二者在统一壳内都只展示表单主体,保证打开创作页时能看到统一创作页外观且不出现双标题或旧外层壳。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。视觉小说、`airp``component`、汪汪声浪、方洞、大鱼、跳一跳和宝贝识物不进入一期接线范围,已有链路保持现状。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
@@ -38,7 +38,7 @@
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,避免右侧裁切。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
## 草稿与作品架

View File

@@ -1,6 +1,6 @@
# 生成页圆环布局口径
更新时间:`2026-05-24`
更新时间:`2026-05-30`
## 目标
@@ -12,8 +12,9 @@
- 生成页背景视频必须留在生成页容器内部,直接作为 `fixed inset-0` 的底层背景,不要再通过 portal 挂到 `document.body`;页面根容器使用 `z-[1]`、背景容器使用 `z-0`,确保顶部导航、圆环和当前步骤卡都稳定覆盖在视频之上。
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中``草稿生成中` 等调用侧传入文案。
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。圆环本体固定在 `400x400` 的 SVG 画布上,圆弧使用 `r=166``strokeWidth=18` 的 SVG 描边,不再跟随页面宽度缩放,也不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
- 圆弧描边以圆心为中心整体按 `135deg` 起始;`270deg` 扫描角配合 `90deg` 正下方缺口时,轨道和填充都从同一个对称起点出发,轨道保持 `rotate(135 200 200)`,填充端点也使用 `rotate(135 200 200)`。圆环本体尺寸固定,不允许再随容器边长伸缩,只能由外层布局决定放置位置
- 圆弧区域不再包独立大卡片,信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。圆环内部坐标固定在 `400x400` 的 SVG `viewBox` 上,圆弧使用 `r=166``strokeWidth=18` 的 SVG 描边,外层显示宽度上限为 `400px`;窄屏视口按 `min(400px, calc(100vw - 2.5rem))` 等比收缩,避免等待页右侧被裁切。圆弧不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
- 圆弧描边以圆心为中心整体按 `135deg` 起始;`270deg` 扫描角配合 `90deg` 正下方缺口时,轨道和填充都从同一个对称起点出发,轨道保持 `rotate(135 200 200)`,填充端点也使用 `rotate(135 200 200)`。圆环只允许在可用宽度不足 `400px` 时等比收缩,不随宽屏容器继续放大
- 在窄屏下,预计等待 / 已耗时信息卡放到圆环下方两列排布;`sm` 及以上视口再回到圆环左右悬浮,避免左右悬浮卡和圆环共同超过视口宽度。
- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。
- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`
@@ -28,4 +29,5 @@
- `src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx` 覆盖汪汪声浪生成页对齐后的圆环布局。
- 两个生成页都应在测试里断言页面根容器层级高于背景视频容器,且背景视频确实是页面子节点,避免 portal 背景把业务 UI 压住。
- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[2%]`,桌面端保持 `sm:pt-[1.5%]`,圆弧 DOM 为 SVG包含清晰的 track/fill circle 描边。
- 还应断言圆环容器使用 `w-[min(400px,calc(100vw-2.5rem))]``max-w-full``aspect-square`,窄屏截图至少覆盖 `280px / 320px / 360px / 390px` 宽度,确认右侧没有硬性裁切。
- 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。

View File

@@ -1,18 +1,26 @@
# 提交前质量门禁
本目录记录一期创作流程统一化提交前必须执行的检查。门禁既可以是自动脚本,也可以是需要人工确认的体验流程。
本目录记录当前创作流程统一化提交前必须执行的检查。门禁既可以是自动脚本,也可以是需要人工确认的体验流程。
## 一期必跑
## 当前必跑
- [【玩法创作】统一创作页门禁-2026-05-29.md](./【玩法创作】统一创作页门禁-2026-05-29.md)
- [【玩法创作】统一生成页门禁-2026-05-29.md](./【玩法创作】统一生成页门禁-2026-05-29.md)
- [【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md](./【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.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
npm run typecheck
npm run admin-web:typecheck
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx
npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx
```
## 使用说明
- `统一创作页门禁``统一生成页门禁` 仍作为 Phase 2 的基础门禁。
- `跨玩法回归与冒烟门禁` 覆盖 Phase 2 之后的最小验证集合Phase 5 / Phase 6 冻结前以它为主。
- `dev-stack` 状态文件门禁继续单独维护,不并入玩法回归矩阵。

View File

@@ -0,0 +1,94 @@
# 跨玩法回归与冒烟门禁
## 适用范围
- Phase 2 的配置与统一壳收口。
- Phase 3 的普通工作台接入。
- Phase 4 的特殊工作台与例外边界。
- Phase 5 的结果页、发布、作品架、公开详情与运行态收口。
- Phase 6 冻结前的最小验证集合。
## 共同前置
```bash
npm run check:encoding
npm run typecheck
npm run admin-web:typecheck
npm run check:spacetime-schema
```
## Phase 2配置与统一壳
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/unified-creation/unifiedCreationSpecs.test.ts src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedGenerationPage.test.tsx
```
- 验证 `creation-entry config``unifiedCreationSpec`、统一创作页和统一生成页的阶段映射一致。
- 需要改 SpacetimeDB schema 时,再追加 `npm run check:spacetime-schema`
## Phase 3普通工作台
```bash
npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx src/components/wooden-fish-creation/WoodenFishWorkspace.test.tsx src/components/jump-hop-creation/JumpHopWorkspace.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop result route"
```
- 验证表单 / 图片 / 音频工作台仍提交结构化 payload不会退回聊天式输入。
- 跳一跳需同时覆盖工作台结构化提交、结果页试玩 / 发布 / 局部重生成入口、结果页恢复面板和 runtime 起跳 / 重开 / 返回。
- 宝贝识物与大鱼吃小鱼也要纳入同一批最小回归,避免只验首批或单一工作台。
- 方洞结果页需覆盖“保存成功后再试玩 / 发布”的路径,避免测试绕过真实保存链路。
## Phase 4特殊工作台与例外
```bash
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "opening RPG agent workspace does not refetch session snapshot in a render loop|create tab resumes agent workspace when draft has no compiled result yet|create tab resumes agent workspace when session has no draft profile even if summary counts look compiled|opening a compiled draft with a missing agent session falls back to draft hub"
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab opens bark battle entry form from the template card|bark battle draft result can test before publish and publish to work detail|direct bark battle runtime public code opens published runtime"
npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/components/visual-novel-creation/visualNovelEntryGeneration.test.ts
npm run check:visual-novel-vn11
```
- 验证 RPG 例外边界、Bark Battle 的创作 / 发布 / 正式 runtime 闭环,以及视觉小说入口、生成页、结果页、运行态和负向扫描门禁。
## Phase 5结果页与公开闭环
```bash
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "agent draft result publishes to gallery from publish panel|creation hub published work enters existing detail view|creation hub published work experience button enters world directly|creation hub published work start uses loaded detail profile instead of library summary"
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "published puzzle works appear on home and mobile game category channel|published big fish works stay hidden from platform home and game category channel|home recommendation Match3D runtime keeps profile generated models when card summary is stale|home recommendation Match3D runtime passes top-level UI background assets|home recommendation Match3D runtime reloads detail when card only has UI assets"
npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
```
- 验证结果页、发布、作品架、公开详情、推荐 runtime 和公开 read model 的最小回归面。
- 验证作品架恢复矩阵、公开详情作者展示和跨流程错误 / 反馈弹窗仍和统一链路一致。
- 若本轮还改了 API 路由、BFF、公开列表或 `/works/detail` 回读,再补 API smoke。`npm run dev:api-server` 是长驻进程,另开终端执行:
```bash
curl -fsS "$(node -e 'const fs=require("fs"); const s=JSON.parse(fs.readFileSync(".app/dev-stack.json","utf8")); const services=s.services||{}; const svc=services["api-server"]||services.apiServer||{}; const url=String(svc.url||s.apiTarget||"http://127.0.0.1:8082").replace(/\/$/,""); process.stdout.write(`${url}/healthz`);')"
```
## 竖屏浏览器冒烟
自动测试通过后,仍需用竖屏视口确认页面不是空白、未跳错入口、底部操作不被遮挡。当前仓库没有专用 Playwright 脚本时,可用系统 Chrome 生成截图:
```bash
out=.app/browser-check/phase-flow-$(date +%Y%m%d)
mkdir -p "$out"
for route in \
creation-jump-hop:/creation/jump-hop \
creation-visual-novel:/creation/visual-novel \
creation-square-hole:/creation/square-hole \
creation-bark-battle:/creation/bark-battle \
creation-baby-object-match:/creation/baby-object-match \
generating-jump-hop:/creation/jump-hop/generating \
result-jump-hop:/creation/jump-hop/result \
runtime-jump-hop:/runtime/jump-hop
do
name=${route%%:*}
path=${route#*:}
google-chrome --headless=new --no-sandbox --disable-gpu --window-size=390,844 --virtual-time-budget=4000 --screenshot="$out/$name.png" "http://127.0.0.1:3000$path"
done
ls -lh "$out"
```
- 执行前确保 `npm run dev:spacetime``npm run dev:api-server``npm run dev:web` 已按实际端口启动;若 `.app/dev-stack.json` 和实际端口不一致,以 `ss -ltnp``/healthz` 为准。
- 截图只保存本地 `.app/`,不提交 Git。检查时至少确认首屏有对应玩法工作台 / 生成页 / 结果页 / runtime 内容,且竖屏下无明显空白死页、入口跳错、按钮遮挡或套滚动。跳一跳结果页若缺少恢复信息,应显示恢复面板而不是空白页。

View File

@@ -9,7 +9,7 @@ import {
watch,
writeFileSync,
} from 'node:fs';
import {basename, relative, resolve} from 'node:path';
import {basename, join, relative, resolve} from 'node:path';
import {createInterface} from 'node:readline';
import {fileURLToPath} from 'node:url';
@@ -26,7 +26,7 @@ import {
} from './dev-utils.mjs';
const repoRoot = process.cwd();
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const devStackStatePath = join(repoRoot, '.app/dev-stack.json');
const serverRsDir = resolve(repoRoot, 'server-rs');
const manifestPath = resolve(serverRsDir, 'Cargo.toml');
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
@@ -247,7 +247,7 @@ function normalizeServiceName(rawName) {
}
function resolveDevStackStatePath(root = repoRoot) {
return resolve(root, '.app/dev-stack.json');
return join(root, '.app/dev-stack.json');
}
function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {

1
server-rs/Cargo.lock generated
View File

@@ -3363,6 +3363,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"shared-contracts",
"shared-kernel",
"spacetimedb",
"spacetimedb-lib",

View File

@@ -28,6 +28,9 @@ use shared_contracts::admin::{
AdminUpdateWorkVisibilityResponse, AdminUpsertCreationEntryTypeConfigRequest,
AdminWorkVisibilityListResponse,
};
use shared_contracts::creation_entry_config::{
encode_unified_creation_spec_response, validate_unified_creation_spec_for_play,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{
@@ -291,6 +294,7 @@ fn map_admin_creation_entry_type_config(
category_label: entry.category_label,
category_sort_order: entry.category_sort_order,
updated_at_micros: entry.updated_at_micros,
unified_creation_spec: entry.unified_creation_spec,
}
}
@@ -305,6 +309,23 @@ fn validate_admin_creation_entry_config(
if title.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("入口标题不能为空"));
}
let unified_creation_spec = match payload.unified_creation_spec {
Some(spec) => {
validate_unified_creation_spec_for_play(&id, &spec).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
})?;
Some(spec)
}
None => None,
};
let unified_creation_spec_json = unified_creation_spec
.as_ref()
.map(|spec| {
encode_unified_creation_spec_response(spec).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error)
})
})
.transpose()?;
Ok(module_runtime::CreationEntryTypeAdminUpsertInput {
id,
title,
@@ -317,6 +338,7 @@ fn validate_admin_creation_entry_config(
category_id: payload.category_id.trim().to_string(),
category_label: payload.category_label.trim().to_string(),
category_sort_order: payload.category_sort_order,
unified_creation_spec_json,
})
}

View File

@@ -11,7 +11,8 @@ use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, build_phase1_unified_creation_spec,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
};
pub fn build_creation_entry_config_response(
@@ -40,7 +41,10 @@ pub fn build_creation_entry_config_response(
.creation_types
.into_iter()
.map(|item| {
let unified_creation_spec = build_phase1_unified_creation_spec(item.id.as_str());
let unified_creation_spec = resolve_unified_creation_spec_response(
item.id.as_str(),
item.unified_creation_spec_json.as_deref(),
);
CreationEntryTypeResponse {
id: item.id,
title: item.title,
@@ -264,9 +268,15 @@ fn build_default_creation_entry_type_snapshot(
category_label: category_label.to_string(),
category_sort_order,
updated_at_micros,
unified_creation_spec_json: default_unified_creation_spec_json(id),
}
}
pub fn default_unified_creation_spec_json(play_id: &str) -> Option<String> {
shared_contracts::creation_entry_config::build_phase1_unified_creation_spec(play_id)
.and_then(|spec| encode_unified_creation_spec_response(&spec).ok())
}
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
RuntimeSettingsRecord {
user_id: snapshot.user_id,

View File

@@ -102,6 +102,7 @@ pub struct CreationEntryTypeSnapshot {
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
pub unified_creation_spec_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -129,6 +130,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::creation_entry_config::UnifiedCreationSpecResponse;
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -34,6 +36,8 @@ pub struct AdminCreationEntryTypeConfigPayload {
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<UnifiedCreationSpecResponse>,
}
/// 后台保存创作入口开关配置请求。
@@ -51,6 +55,8 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
/// 后台作品可见性列表项。

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -55,7 +56,7 @@ pub struct CreationEntryTypeResponse {
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationSpecResponse {
pub play_id: String,
@@ -66,7 +67,7 @@ pub struct UnifiedCreationSpecResponse {
pub fields: Vec<UnifiedCreationFieldResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UnifiedCreationFieldResponse {
pub id: String,
@@ -75,6 +76,8 @@ pub struct UnifiedCreationFieldResponse {
pub required: bool,
}
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
"puzzle" => (
@@ -120,6 +123,95 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
})
}
pub fn validate_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim().is_empty() {
return Err("统一创作契约 playId 不能为空".to_string());
}
if spec.title.trim().is_empty() {
return Err("统一创作契约标题不能为空".to_string());
}
let workspace_stage = spec.workspace_stage.trim();
let generation_stage = spec.generation_stage.trim();
let result_stage = spec.result_stage.trim();
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
return Err("统一创作契约阶段不能为空".to_string());
}
if workspace_stage == generation_stage
|| workspace_stage == result_stage
|| generation_stage == result_stage
{
return Err("统一创作契约阶段不能重复".to_string());
}
if spec.fields.is_empty() {
return Err("统一创作契约 fields 不能为空".to_string());
}
let mut field_ids = BTreeSet::new();
for field in &spec.fields {
let field_id = field.id.trim();
if field_id.is_empty() {
return Err("统一创作契约字段 id 不能为空".to_string());
}
if !field_ids.insert(field_id.to_string()) {
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
}
if field.label.trim().is_empty() {
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
}
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
return Err(format!(
"统一创作契约字段 {field_id} kind 非法:{}",
field.kind
));
}
}
Ok(())
}
pub fn validate_unified_creation_spec_for_play(
play_id: &str,
spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> {
if spec.play_id.trim() != play_id.trim() {
return Err(format!(
"统一创作契约 playId 必须与入口 ID 一致:{}",
play_id.trim()
));
}
validate_unified_creation_spec_response(spec)
}
pub fn encode_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse,
) -> Result<String, String> {
validate_unified_creation_spec_response(spec)?;
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
}
pub fn decode_unified_creation_spec_response(
value: &str,
) -> Result<UnifiedCreationSpecResponse, String> {
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
validate_unified_creation_spec_response(&spec)?;
Ok(spec)
}
pub fn resolve_unified_creation_spec_response(
play_id: &str,
value: Option<&str>,
) -> Option<UnifiedCreationSpecResponse> {
match value {
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
None => build_phase1_unified_creation_spec(play_id),
}
}
fn unified_creation_field(
id: &str,
kind: &str,

View File

@@ -14,6 +14,7 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
category_id: input.category_id,
category_label: input.category_label,
category_sort_order: input.category_sort_order,
unified_creation_spec_json: input.unified_creation_spec_json,
}
}
}
@@ -299,6 +300,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
),
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: item.unified_creation_spec_json,
})
.collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
@@ -345,6 +347,7 @@ fn map_creation_entry_config_snapshot(
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
unified_creation_spec_json: item.unified_creation_spec_json,
})
.collect(),
updated_at_micros: snapshot.updated_at_micros,

View File

@@ -18,6 +18,7 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryTypeConfig {
pub category_id: Option<String>,
pub category_label: Option<String>,
pub category_sort_order: i32,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeConfig {
@@ -41,6 +42,8 @@ pub struct CreationEntryTypeConfigCols {
pub category_id: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_label: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
pub unified_creation_spec_json:
__sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
@@ -62,6 +65,10 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
table_name,
"category_sort_order",
),
unified_creation_spec_json: __sdk::__query_builder::Col::new(
table_name,
"unified_creation_spec_json",
),
}
}
}

View File

@@ -19,6 +19,7 @@ pub struct CreationEntryTypeSnapshot {
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
pub unified_creation_spec_json: Option<String>,
}
impl __sdk::InModule for CreationEntryTypeSnapshot {

View File

@@ -11,6 +11,7 @@ crate-type = ["cdylib"]
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-bark-battle = { workspace = true }

View File

@@ -1184,7 +1184,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "creation_entry_type_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
// 中文注释:入口分类和统一创作契约字段晚于入口类型配置表加入,旧迁移包按空配置兼容。
object
.entry("category_id".to_string())
.or_insert(serde_json::Value::Null);
@@ -1194,6 +1194,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("category_sort_order".to_string())
.or_insert_with(|| serde_json::Value::from(0));
object
.entry("unified_creation_spec_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "user_account" {

View File

@@ -46,6 +46,8 @@ pub struct CreationEntryTypeConfig {
pub(crate) category_label: Option<String>,
#[default(0)]
pub(crate) category_sort_order: i32,
#[default(None::<String>)]
pub(crate) unified_creation_spec_json: Option<String>,
}
#[spacetimedb::procedure]
@@ -96,6 +98,7 @@ fn upsert_creation_entry_type_config_in_tx(
if input.title.trim().is_empty() {
return Err("入口标题不能为空".to_string());
}
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?;
let row = CreationEntryTypeConfig {
id: id.clone(),
title: input.title.trim().to_string(),
@@ -109,6 +112,7 @@ fn upsert_creation_entry_type_config_in_tx(
category_id: Some(normalize_category_id(&input.category_id)),
category_label: Some(normalize_category_label(&input.category_label)),
category_sort_order: input.category_sort_order,
unified_creation_spec_json,
};
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
ctx.db.creation_entry_type_config().id().update(row);
@@ -145,6 +149,7 @@ fn get_or_seed_creation_entry_config_snapshot(
category_label: normalize_optional_category_label(row.category_label.as_deref()),
category_sort_order: row.category_sort_order,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: row.unified_creation_spec_json,
})
.collect::<Vec<_>>();
creation_types.sort_by(|left, right| {
@@ -404,10 +409,29 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
category_id: Some(snapshot.category_id),
category_label: Some(snapshot.category_label),
category_sort_order: snapshot.category_sort_order,
unified_creation_spec_json: snapshot.unified_creation_spec_json,
})
.collect()
}
fn normalize_unified_creation_spec_json(
id: &str,
input: &CreationEntryTypeAdminUpsertInput,
) -> Result<Option<String>, String> {
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
return Ok(None);
};
let normalized = spec_json.trim();
if normalized.is_empty() {
return Ok(None);
}
let spec =
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
shared_contracts::creation_entry_config::validate_unified_creation_spec_for_play(id, &spec)?;
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(&spec).map(Some)
}
fn normalize_category_id(value: &str) -> String {
let normalized = value.trim();
if normalized.is_empty() {

View File

@@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
// 管理端可见性开关;默认显示,隐藏后不进入广场列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
@@ -171,9 +171,9 @@ pub struct VisualNovelRuntimeEvent {
pub(crate) occurred_at: Timestamp,
}
/// 视觉小说公开广场列表投影。
/// 视觉小说广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅
/// 该 view 只暴露已发布作品卡片需要的展示字段HTTP gallery 缓存刷新
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {

View File

@@ -142,12 +142,17 @@ describe('CustomWorldGenerationView', () => {
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: progressTitle })

View File

@@ -133,44 +133,14 @@ export function GenerationProgressHero({
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
</div>
<div className="relative w-full max-w-[56rem] sm:max-w-[60rem]">
<div className="relative w-full min-w-0 max-w-[56rem] sm:max-w-[60rem]">
<div
className="absolute left-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="absolute right-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
<div
className="relative mx-auto h-[400px] w-[400px] shrink-0 overflow-visible rounded-full"
className="relative mx-auto aspect-square w-[min(400px,calc(100%_-_0.75rem))] max-w-full shrink-0 overflow-visible rounded-full"
role="progressbar"
aria-label={title}
aria-valuemin={0}
@@ -244,6 +214,38 @@ export function GenerationProgressHero({
</div>
</div>
</div>
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:right-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -130,12 +130,17 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })

View File

@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type { JumpHopSessionResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { JumpHopWorkspace } from './JumpHopWorkspace';
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
},
}));
const mockCreateSession = vi.mocked(jumpHopClient.createSession);
beforeEach(() => {
mockCreateSession.mockReset();
});
function createSessionResponse(): JumpHopSessionResponse {
return {
session: {
sessionId: 'jump-session-1',
ownerUserId: 'user-1',
status: 'draft',
draft: null,
createdAt: '2026-05-30T10:00:00.000Z',
updatedAt: '2026-05-30T10:00:00.000Z',
},
};
}
test('jump hop workspace submits structured payload after required fields are filled', async () => {
const user = userEvent.setup();
const onSubmitted = vi.fn();
const sessionResponse = createSessionResponse();
mockCreateSession.mockResolvedValue(sessionResponse);
render(
<JumpHopWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
);
const submitButton = screen.getByRole('button', { name: '生成' });
expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
await user.type(screen.getByLabelText('终点氛围'), '星光门');
expect(submitButton).toHaveProperty('disabled', false);
await user.click(submitButton);
await waitFor(() => {
expect(mockCreateSession).toHaveBeenCalledWith({
templateId: 'jump-hop',
workTitle: '云朵跳台',
workDescription: '在云端一路跳到星星。',
themeTags: ['云朵', '星星'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '一只纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
});
});
expect(onSubmitted).toHaveBeenCalledWith(
sessionResponse,
expect.objectContaining({
templateId: 'jump-hop',
workTitle: '云朵跳台',
}),
);
});
test('jump hop workspace calls back when return button is clicked', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(<JumpHopWorkspace onBack={onBack} onSubmitted={() => {}} />);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,144 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopResultView } from './JumpHopResultView';
const draft: JumpHopDraftResponse = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish',
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'finish',
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
};
test('jump hop result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onEdit = vi.fn();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onRegenerateCharacter = vi.fn();
const onRegenerateTiles = vi.fn();
render(
<JumpHopResultView
profile={draft}
onBack={onBack}
onEdit={onEdit}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateCharacter={onRegenerateCharacter}
onRegenerateTiles={onRegenerateTiles}
/>,
);
expect(screen.getByText('云端跳台')).toBeTruthy();
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: '地块' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
const profile: JumpHopWorkProfileResponse = {
summary: {
runtimeKind: 'jump-hop',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: 'data:image/png;base64,cover',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
},
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
};
const run: JumpHopRuntimeRunSnapshotResponse = {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 0,
score: 0,
combo: 0,
path: profile.path,
lastJump: null,
startedAtMs: 1000,
finishedAtMs: null,
};
test('jump hop runtime shell supports jump, restart and exit actions', async () => {
const user = userEvent.setup();
const onJump = vi.fn().mockResolvedValue(undefined);
const onRestart = vi.fn();
const onExit = vi.fn();
render(
<JumpHopRuntimeShell
profile={profile}
run={run}
onJump={onJump}
onRestart={onRestart}
onExit={onExit}
/>,
);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' },
]);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' },
]);
await waitFor(() => {
expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) });
});
await user.click(screen.getByRole('button', { name: '重开' }));
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledTimes(1);
});

View File

@@ -112,6 +112,28 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
title={null}
unifiedChrome
/>,
);
const workspace = container.querySelector('.match3d-agent-workspace');
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(workspace?.className).toContain('max-w-none');
expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
});
test('match3d workspace omits legacy asset style fields from entry payload', () => {
const onCreateFromForm = vi.fn();

View File

@@ -19,6 +19,7 @@ type Match3DAgentWorkspaceProps = {
initialFormPayload?: CreateMatch3DSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type Match3DFormState = {
@@ -116,6 +117,7 @@ export function Match3DAgentWorkspace({
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: Match3DAgentWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -183,7 +185,14 @@ export function Match3DAgentWorkspace({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
<div
className={
unifiedChrome
? 'match3d-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'match3d-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
}
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
@@ -197,8 +206,14 @@ export function Match3DAgentWorkspace({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<div
className={
unifiedChrome
? 'flex flex-col pr-0'
: 'flex min-h-0 flex-1 flex-col overflow-hidden pr-0'
}
>
{title && !unifiedChrome ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
@@ -211,9 +226,19 @@ export function Match3DAgentWorkspace({
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<section
className={
unifiedChrome
? 'flex flex-col'
: 'flex min-h-0 flex-1 flex-col overflow-hidden'
}
>
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
className={`grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] ${
unifiedChrome
? ''
: 'min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] lg:grid-rows-1'
} ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">

View File

@@ -3138,6 +3138,38 @@ function LazyPanelFallback({ label }: { label: string }) {
);
}
function CreationResultRecoveryPanel({
title,
message,
actionLabel,
onAction,
}: {
title: string;
message: string;
actionLabel: string;
onAction: () => void;
}) {
return (
<div className="flex h-full min-h-0 items-center justify-center px-3 py-6">
<div className="platform-subpanel w-full max-w-sm rounded-[1.5rem] p-5 text-center">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{message}
</div>
<button
type="button"
onClick={onAction}
className="platform-button platform-button--primary mt-4 min-h-11 justify-center px-4 py-3 text-sm"
>
{actionLabel}
</button>
</div>
</div>
);
}
function mergePuzzleServiceRuntimeState(
currentRun: PuzzleRunSnapshot,
serviceRun: PuzzleRunSnapshot,
@@ -12930,15 +12962,26 @@ export function PlatformEntryFlowShellImpl({
}
if (path.startsWith('/creation/jump-hop')) {
if (!sessionId) {
return;
}
try {
const { session } = await jumpHopClient.getSession(sessionId);
let session: JumpHopSessionSnapshotResponse | null = null;
let work: JumpHopWorkProfileResponse | null = null;
try {
if (profileId) {
work = (await jumpHopClient.getWorkDetail(profileId)).item;
}
} catch {
work = null;
}
try {
if (sessionId) {
session = (await jumpHopClient.getSession(sessionId)).session;
}
} catch {
session = null;
}
if (!session && !work) {
return;
}
try {
setJumpHopSession(session);
setJumpHopWork(work);
writeCreationUrlState(
@@ -12948,7 +12991,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage(
path.includes('/generating')
? 'jump-hop-generating'
: session.draft
: session?.draft || work
? 'jump-hop-result'
: 'jump-hop-workspace',
);
@@ -15689,7 +15732,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={
@@ -15711,6 +15754,8 @@ export function PlatformEntryFlowShellImpl({
void executeMatch3DAction(payload);
}}
initialFormPayload={match3dFormDraftPayload}
title={null}
unifiedChrome
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createMatch3DDraftFromForm(payload);
@@ -16411,7 +16456,12 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'jump-hop-result' && jumpHopSession?.draft && (
{selectionStage === 'jump-hop-result' &&
(() => {
const activeJumpHopResultProfile =
jumpHopWork ?? jumpHopSession?.draft ?? null;
return (
<motion.div
key="jump-hop-result"
initial={{ opacity: 0, y: 12 }}
@@ -16419,11 +16469,14 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
{activeJumpHopResultProfile ? (
<Suspense
fallback={<LazyPanelFallback label="正在加载跳一跳结果..." />}
fallback={
<LazyPanelFallback label="正在加载跳一跳结果..." />
}
>
<JumpHopResultView
profile={jumpHopWork ?? jumpHopSession.draft}
profile={activeJumpHopResultProfile}
error={jumpHopError}
onBack={leaveJumpHopFlow}
onEdit={() => {
@@ -16439,8 +16492,19 @@ export function PlatformEntryFlowShellImpl({
}}
/>
</Suspense>
</motion.div>
) : (
<CreationResultRecoveryPanel
title="跳一跳草稿未恢复"
message="当前链接缺少可恢复的跳一跳草稿信息。"
actionLabel="返回创作"
onAction={() => {
setSelectionStage('jump-hop-workspace');
}}
/>
)}
</motion.div>
);
})()}
{selectionStage === 'jump-hop-runtime' && (
<motion.div
@@ -16476,7 +16540,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载敲木鱼创作..." />}
@@ -16631,7 +16695,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
@@ -16654,6 +16718,8 @@ export function PlatformEntryFlowShellImpl({
executePuzzleWorkspaceAction(payload);
}}
initialFormPayload={puzzleFormDraftPayload}
title={null}
unifiedChrome
onCreateFromForm={(payload) => {
void createPuzzleDraftFromForm(payload);
}}

View File

@@ -215,6 +215,29 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
unifiedChrome
title={null}
/>,
);
const workspace = container.querySelector('.puzzle-agent-workspace');
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(workspace?.className).toContain('max-w-none');
expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy();
});
test('puzzle workspace keeps the reference image upload as a primary panel', () => {
const onCreateFromForm = vi.fn();
const { container } = render(

View File

@@ -49,6 +49,7 @@ type PuzzleAgentWorkspaceProps = {
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type PuzzleFormState = {
@@ -246,6 +247,7 @@ export function PuzzleAgentWorkspace({
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -592,7 +594,14 @@ export function PuzzleAgentWorkspace({
};
return (
<div className="platform-remap-surface puzzle-agent-workspace mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
<div
className={
unifiedChrome
? 'puzzle-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'puzzle-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
}
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
@@ -609,7 +618,7 @@ export function PuzzleAgentWorkspace({
</div>
) : null}
{title ? (
{title && !unifiedChrome ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
@@ -623,6 +632,7 @@ export function PuzzleAgentWorkspace({
) : null}
<CreativeImageInputPanel
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
disabled={isBusy}
isSubmitting={isBusy}
uploadedImageSrc={formState.referenceImageSrc}

View File

@@ -17,6 +17,10 @@ import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -82,6 +86,7 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
createServerMatch3DRuntimeAdapter,
@@ -625,6 +630,22 @@ vi.mock('../../services/edutainment-baby-object', () => ({
saveBabyObjectMatchDraft: vi.fn(),
}));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
submitJump: vi.fn(),
},
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
@@ -1465,6 +1486,139 @@ function buildMockBabyObjectMatchDraft(
};
}
function buildMockJumpHopWork(
overrides: Partial<JumpHopWorkProfileResponse> = {},
): JumpHopWorkProfileResponse {
const profileId = overrides.summary?.profileId ?? 'jump-hop-profile-1';
const path = overrides.path ?? {
seed: 'jump-hop-seed',
difficulty: 'standard' as const,
platforms: [
{
platformId: 'platform-start',
tileType: 'start' as const,
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-finish',
tileType: 'finish' as const,
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
};
const characterAsset = overrides.characterAsset ?? {
assetId: 'jump-hop-character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-jump-hop-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '纸片小兔',
width: 1024,
height: 1024,
};
const tileAtlasAsset = overrides.tileAtlasAsset ?? {
assetId: 'jump-hop-tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-jump-hop-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '柔软云朵平台',
width: 1024,
height: 1024,
};
const tileAssets = overrides.tileAssets ?? [
{
tileType: 'start' as const,
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-jump-hop-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish' as const,
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-jump-hop-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
];
const draft = overrides.draft ?? {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId,
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard' as const,
stylePreset: 'paper-toy' as const,
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset,
tileAtlasAsset,
tileAssets,
path,
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready' as const,
};
return {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-1',
profileId,
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
themeTags: draft.themeTags,
difficulty: draft.difficulty,
stylePreset: draft.stylePreset,
coverImageSrc: draft.coverComposite,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
...overrides.summary,
},
draft,
path,
characterAsset,
tileAtlasAsset,
tileAssets,
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
@@ -2520,6 +2674,18 @@ beforeEach(() => {
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
items: [],
hasMore: false,
nextCursor: null,
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
new Error('未找到跳一跳会话'),
);
vi.mocked(jumpHopClient.getWorkDetail).mockRejectedValue(
new Error('未找到跳一跳作品'),
);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
draft: payload.draft,
}));
@@ -7215,6 +7381,58 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
).toBeTruthy();
});
test('direct jump hop result route shows recovery panel when no draft pointer exists', async () => {
window.history.replaceState(null, '', '/creation/jump-hop/result');
render(<TestWrapper withAuth />);
expect(await screen.findByText('跳一跳草稿未恢复')).toBeTruthy();
expect(screen.getByRole('button', { name: '返回创作' })).toBeTruthy();
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('direct jump hop result route restores work detail by profile id', async () => {
const work = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-restore-1',
profileId: 'jump-hop-profile-restore-1',
ownerUserId: 'user-1',
sourceSessionId: null,
workTitle: '恢复后的云端跳台',
workDescription: '从 profileId 回读完整跳一跳结果。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
item: work,
} satisfies JumpHopWorkDetailResponse);
window.history.replaceState(
null,
'',
'/creation/jump-hop/result?profileId=jump-hop-profile-restore-1',
);
render(<TestWrapper withAuth />);
expect(await screen.findByText('恢复后的云端跳台')).toBeTruthy();
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-restore-1',
);
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();

View File

@@ -6038,6 +6038,11 @@ export function RpgEntryHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : myEntries.length > 0 ? (
@@ -6074,7 +6079,16 @@ export function RpgEntryHomeView({
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const savesContent: ReactNode = (
<>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{draftTabContent ?? fallbackDraftContent}
</>
);
const profileContent: ReactNode = (
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>

View File

@@ -870,6 +870,10 @@ export function resolvePlatformWorkAuthorDisplayName(
const displayName = authorSummary?.displayName?.trim();
const publicUserCode = authorSummary?.publicUserCode?.trim();
if (displayName && publicUserCode) {
return `${displayName} · ${publicUserCode}`;
}
return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家';
}

View File

@@ -0,0 +1,102 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { SquareHoleResultView } from './SquareHoleResultView';
vi.mock('../../services/square-hole-works', () => ({
publishSquareHoleWork: vi.fn(),
regenerateSquareHoleWorkImage: vi.fn(),
squareHoleAssetClient: {
listHistoryAssets: vi.fn(),
},
updateSquareHoleWork: vi.fn(),
}));
function createProfile(): SquareHoleWorkProfile {
return {
profileId: 'profile-1',
workId: 'work-1',
ownerUserId: 'user-1',
gameName: '方洞挑战',
themeText: '几何反差',
twistRule: '形状要投进对应洞口',
summary: '把所有形状投入正确洞口。',
tags: ['方洞', '反差'],
coverImageSrc: 'data:image/png;base64,cover',
backgroundPrompt: '几何场景',
backgroundImageSrc: 'data:image/png;base64,background',
shapeOptions: [
{
optionId: 'shape-1',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-1',
imagePrompt: '方块贴图',
imageSrc: 'data:image/png;base64,shape-1',
},
],
holeOptions: [
{
holeId: 'hole-1',
holeKind: 'hole-1',
label: '洞口 1',
imagePrompt: '洞口 1 贴图',
imageSrc: 'data:image/png;base64,hole-1',
},
],
shapeCount: 6,
difficulty: 2,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
};
}
test('square hole result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onStartTestRun = vi.fn();
const onPublished = vi.fn();
const { publishSquareHoleWork, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
const mockUpdateSquareHoleWork = vi.mocked(updateSquareHoleWork);
const mockPublishSquareHoleWork = vi.mocked(publishSquareHoleWork);
const nextProfile = createProfile();
mockUpdateSquareHoleWork.mockResolvedValue({
item: nextProfile,
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
mockPublishSquareHoleWork.mockResolvedValue({
item: nextProfile,
} as Awaited<ReturnType<typeof publishSquareHoleWork>>);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={onBack}
onStartTestRun={onStartTestRun}
onPublished={onPublished}
/>,
);
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -38,5 +38,17 @@ describe('UnifiedCreationPage', () => {
]);
expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio');
expect(fields[3]?.getAttribute('data-required')).toBe('true');
expect(screen.getByTestId('unified-creation-play-badge').textContent).toBe(
'wooden-fish',
);
expect(screen.queryByLabelText('创作字段')).toBeNull();
expect(screen.queryByTestId('unified-creation-visible-field')).toBeNull();
expect(
screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).not.toContain('overflow-y-auto');
expect(root?.className).not.toContain('overflow-hidden');
});
});

View File

@@ -13,13 +13,26 @@ export function UnifiedCreationPage({
}: UnifiedCreationPageProps) {
return (
<div
className="unified-creation-page flex h-full min-h-0 flex-col"
className="unified-creation-page platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pt-2 sm:px-4 sm:pt-3"
data-play-id={spec.playId}
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
data-workspace-stage={spec.workspaceStage}
data-generation-stage={spec.generationStage}
data-result-stage={spec.resultStage}
>
<header className="unified-creation-page__header shrink-0 pb-3">
<div className="flex items-center justify-between gap-3">
<h1 className="m-0 min-w-0 truncate text-[1.35rem] font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-[1.65rem]">
{spec.title}
</h1>
<span
className="unified-creation-page__play-badge shrink-0 rounded-full border border-[var(--platform-subpanel-border)] bg-white/80 px-3 py-1 text-[11px] font-black text-[var(--platform-text-soft)]"
data-testid="unified-creation-play-badge"
>
{spec.playId}
</span>
</div>
</header>
<div className="sr-only" data-testid="unified-creation-spec">
<h1>{spec.title}</h1>
<ul>
@@ -36,8 +49,10 @@ export function UnifiedCreationPage({
))}
</ul>
</div>
<div className="unified-creation-page__content pb-3 sm:pb-4">
{children}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import type { CustomWorldGenerationProgress } from '../../../packages/shared/src
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
type UnifiedGenerationPageProps = {
playId: UnifiedCreationPlayId;
@@ -16,39 +17,6 @@ type UnifiedGenerationPageProps = {
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,

View File

@@ -0,0 +1,34 @@
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
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];
}

View File

@@ -118,6 +118,11 @@ test('visual novel generation helpers build process page data', () => {
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
]);
const progress = buildVisualNovelEntryGenerationProgress(
@@ -126,7 +131,8 @@ test('visual novel generation helpers build process page data', () => {
8_000,
);
expect(progress.phaseId).toBe('generating');
expect(progress.phaseId).toBe('visual-novel-world');
expect(progress.overallProgress).toBeGreaterThan(0);
expect(progress.phaseLabel).toBe('扩展世界观');
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
});

View File

@@ -107,6 +107,27 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});
test('敲击音效和功德词条不放进独立滚动窗', () => {
const { container } = render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const audioSection = screen.getByText('敲击音效').closest('section');
const floatingWordsSection = screen.getByText('功德有什么').closest('section');
const sidePanel = audioSection?.parentElement;
expect(audioSection).not.toBeNull();
expect(floatingWordsSection).not.toBeNull();
expect(sidePanel).toBe(floatingWordsSection?.parentElement);
expect(sidePanel?.className).not.toContain('overflow-y-auto');
expect(sidePanel?.className).not.toContain('min-h-0');
expect(container.querySelector('.overflow-y-auto')).toBeNull();
expect(container.firstElementChild?.className).not.toContain('h-full');
});
test('工作台只保留一个生成按钮', () => {
render(
<WoodenFishWorkspace

View File

@@ -155,7 +155,7 @@ export function WoodenFishWorkspace({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
@@ -167,7 +167,7 @@ export function WoodenFishWorkspace({
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
<div className="flex min-h-[26rem] min-w-0 flex-col">
<CreativeImageInputPanel
disabled={isBusy || isSubmitting}
@@ -231,7 +231,7 @@ export function WoodenFishWorkspace({
/>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<CreativeAudioInputPanel<WoodenFishAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
@@ -251,7 +251,7 @@ export function WoodenFishWorkspace({
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid gap-2">
<div className="grid gap-2 sm:grid-cols-2">
{formState.floatingWords.map((word, index) => (
<div key={index} className="relative">
<input

View File

@@ -470,12 +470,14 @@ body {
.platform-ui-shell,
.platform-ui-shell * {
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important;
font-family: 'Inter', 'Noto Sans CJK SC', 'WenQuanYi Zen Hei',
'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif !important;
}
.platform-theme,
.platform-theme * {
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important;
font-family: 'Inter', 'Noto Sans CJK SC', 'WenQuanYi Zen Hei',
'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif !important;
}
.platform-theme {