feat: 统一创作页表头跟随后台入口配置

This commit is contained in:
2026-06-06 22:49:38 +08:00
parent 95f17cd920
commit 18908609fc
14 changed files with 276 additions and 53 deletions

View File

@@ -1305,3 +1305,11 @@
- 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。 - 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。
- 验证方式:`npm run spacetime:generate``npm run check:encoding``npm run check:server-rs-ddd``cargo test -p module-puzzle-clear``cargo test -p spacetime-client puzzle_clear -- --nocapture``npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts` - 验证方式:`npm run spacetime:generate``npm run check:encoding``npm run check:server-rs-ddd``cargo test -p module-puzzle-clear``cargo test -p spacetime-client puzzle_clear -- --nocapture``npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-06 统一创作页表头跟随后台玩法入口配置
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但默认值改为玩法入口中文名称;读取和保存时如果发现旧公共表头或代码默认中文名,则归一为当前入口 `title`,后台手动配置成其它标题时保留自定义值。
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存归一化、后台入口开关页摘要和前端 fallback spec。
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 默认等于入口中文名;后台修改入口名称后,未自定义表头的统一创作页跟随变化。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -27,7 +27,7 @@ vi.mock('../api/adminApiClient', () => ({
const puzzleSpec: UnifiedCreationSpecPayload = { const puzzleSpec: UnifiedCreationSpecPayload = {
playId: 'puzzle', playId: 'puzzle',
title: '想做个什么玩法?', title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
@@ -88,6 +88,9 @@ test('创作入口后台展示并保存统一创作契约', async () => {
await screen.findByText('pictureDescription'); await screen.findByText('pictureDescription');
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull(); expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
).toContain('拼图');
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull(); expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
expect(container.querySelector('.admin-muted')).toBeNull(); expect(container.querySelector('.admin-muted')).toBeNull();

View File

@@ -707,6 +707,10 @@ function UnifiedCreationSpecSummary({specJson}: {specJson: string}) {
<dt></dt> <dt></dt>
<dd>{parsed.spec.playId}</dd> <dd>{parsed.spec.playId}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{parsed.spec.title}</dd>
</div>
<div> <div>
<dt></dt> <dt></dt>
<dd> <dd>

View File

@@ -14,7 +14,7 @@
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg``big-fish``puzzle``match3d``jump-hop``wooden-fish``square-hole``bark-battle``visual-novel``baby-object-match``creative-agent``airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec字段类型只保留 `text``select``image``audio``UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg``big-fish``puzzle``match3d``jump-hop``wooden-fish``square-hole``bark-battle``visual-novel``baby-object-match``creative-agent``airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单和表头由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec字段类型只保留 `text``select``image``audio`统一创作页表头属于后台玩法入口配置,默认使用同一入口的中文名称;旧库里仍持久化为 `想做个什么玩法?` 或代码默认中文名的 spec title会在读取和保存时归一到当前入口名称后台单独写成其它标题时保留该自定义值。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。

View File

@@ -12,7 +12,7 @@ use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{ use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse, CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse, CreationEntryTypeModalResponse, CreationEntryTypeResponse,
encode_unified_creation_spec_response, resolve_unified_creation_spec_response, encode_unified_creation_spec_response, resolve_unified_creation_spec_response_with_entry_title,
}; };
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。 /// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
@@ -45,8 +45,9 @@ pub fn build_creation_entry_config_response(
.creation_types .creation_types
.into_iter() .into_iter()
.map(|item| { .map(|item| {
let unified_creation_spec = resolve_unified_creation_spec_response( let unified_creation_spec = resolve_unified_creation_spec_response_with_entry_title(
item.id.as_str(), item.id.as_str(),
item.title.as_str(),
item.unified_creation_spec_json.as_deref(), item.unified_creation_spec_json.as_deref(),
); );
CreationEntryTypeResponse { CreationEntryTypeResponse {
@@ -161,10 +162,9 @@ fn normalize_creation_entry_announcement_banner_value(
); );
} }
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object( let banner =
object.clone(), serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
)) .map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
normalize_creation_entry_event_banner_response(index, banner) normalize_creation_entry_event_banner_response(index, banner)
} }
@@ -243,8 +243,8 @@ pub fn resolve_creation_entry_event_banner_responses(
banners banners
} }
.into_iter() .into_iter()
.map(build_creation_entry_event_banner_response) .map(build_creation_entry_event_banner_response)
.collect() .collect()
} }
/// 把领域公告快照转换为 HTTP 响应字段。 /// 把领域公告快照转换为 HTTP 响应字段。
@@ -332,10 +332,7 @@ fn normalize_banner_html_code(
} }
let lower_html_code = html_code.to_ascii_lowercase(); let lower_html_code = html_code.to_ascii_lowercase();
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") { if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
return Err(format!( return Err(format!("{} 条 HTML 公告含有不允许的脚本代码", index + 1));
"{} 条 HTML 公告含有不允许的脚本代码",
index + 1
));
} }
Ok(Some(html_code)) Ok(Some(html_code))

View File

@@ -339,12 +339,20 @@ mod tests {
assert_eq!(banners.len(), 1); assert_eq!(banners.len(), 1);
assert_eq!(banners[0].render_mode, "html"); assert_eq!(banners[0].render_mode, "html");
assert_eq!(banners[0].title, "创作公告"); assert_eq!(banners[0].title, "创作公告");
assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告")); assert!(
assert!(banners[0] banners[0]
.html_code .html_code
.as_deref() .as_deref()
.unwrap_or("") .unwrap_or("")
.contains("/creation-type-references/puzzle.webp")); .contains("创作公告")
);
assert!(
banners[0]
.html_code
.as_deref()
.unwrap_or("")
.contains("/creation-type-references/puzzle.webp")
);
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src); assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
} }
@@ -485,6 +493,60 @@ mod tests {
assert_eq!(jump_hop.category_sort_order, 20); assert_eq!(jump_hop.category_sort_order, 20);
} }
#[test]
fn creation_entry_response_uses_entry_title_for_legacy_unified_creation_title() {
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: CreationEntryStartCardSnapshot {
title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
},
type_modal: CreationEntryTypeModalSnapshot {
title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
event_banner: default_creation_entry_event_banner_snapshots()
.into_iter()
.next()
.expect("default banner"),
event_banners_json: Some(default_creation_entry_event_banners_json()),
creation_types: vec![CreationEntryTypeSnapshot {
id: "puzzle".to_string(),
title: "定制拼图".to_string(),
subtitle: "拼图关卡创作".to_string(),
badge: "可创建".to_string(),
image_src: "/creation-type-references/puzzle.webp".to_string(),
visible: true,
open: true,
sort_order: 30,
category_id: "recommended".to_string(),
category_label: "热门推荐".to_string(),
category_sort_order: 20,
updated_at_micros: 1,
unified_creation_spec_json: Some(
r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"#
.to_string(),
),
}],
updated_at_micros: 1,
});
let puzzle = response
.creation_types
.iter()
.find(|item| item.id == "puzzle")
.expect("puzzle entry");
assert_eq!(
puzzle
.unified_creation_spec
.as_ref()
.map(|spec| spec.title.as_str()),
Some("定制拼图")
);
}
#[test] #[test]
fn normalized_clamps_music_volume_into_valid_range() { fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);

View File

@@ -99,6 +99,7 @@ pub struct UnifiedCreationFieldResponse {
} }
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"]; pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
pub const LEGACY_UNIFIED_CREATION_TITLE: &str = "想做个什么玩法?";
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> { pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
let (workspace_stage, generation_stage, result_stage, fields) = match play_id { let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
@@ -172,18 +173,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
vec![ vec![
unified_creation_field("title", "text", "作品标题", true), unified_creation_field("title", "text", "作品标题", true),
unified_creation_field("themeDescription", "text", "主题/场景描述", true), unified_creation_field("themeDescription", "text", "主题/场景描述", true),
unified_creation_field( unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
"playerImageDescription", unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
"text",
"玩家形象描述",
true,
),
unified_creation_field(
"opponentImageDescription",
"text",
"对手形象描述",
true,
),
unified_creation_field("onomatopoeia", "text", "拟声词", false), unified_creation_field("onomatopoeia", "text", "拟声词", false),
unified_creation_field("difficultyPreset", "select", "难度", true), unified_creation_field("difficultyPreset", "select", "难度", true),
], ],
@@ -220,7 +211,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
Some(UnifiedCreationSpecResponse { Some(UnifiedCreationSpecResponse {
play_id: play_id.to_string(), play_id: play_id.to_string(),
title: "想做个什么玩法?".to_string(), title: default_unified_creation_title(play_id)?.to_string(),
workspace_stage: workspace_stage.to_string(), workspace_stage: workspace_stage.to_string(),
generation_stage: generation_stage.to_string(), generation_stage: generation_stage.to_string(),
result_stage: result_stage.to_string(), result_stage: result_stage.to_string(),
@@ -228,6 +219,43 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
}) })
} }
pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
match play_id {
"rpg" => Some("文字冒险"),
"big-fish" => Some("摸鱼"),
"puzzle" => Some("拼图"),
"match3d" => Some("抓大鹅"),
"jump-hop" => Some("跳一跳"),
"wooden-fish" => Some("敲木鱼"),
"square-hole" => Some("方洞"),
"bark-battle" => Some("汪汪声浪"),
"visual-novel" => Some("视觉小说"),
"baby-object-match" => Some("宝贝识物"),
"creative-agent" => Some("智能体创作"),
_ => None,
}
}
pub fn normalize_unified_creation_spec_title_for_entry(
play_id: &str,
entry_title: &str,
mut spec: UnifiedCreationSpecResponse,
) -> UnifiedCreationSpecResponse {
let entry_title = entry_title.trim();
if entry_title.is_empty() {
return spec;
}
let spec_title = spec.title.trim();
let default_title = default_unified_creation_title(play_id).unwrap_or_default();
if spec_title == LEGACY_UNIFIED_CREATION_TITLE
|| (!default_title.is_empty() && spec_title == default_title)
{
spec.title = entry_title.to_string();
}
spec
}
pub fn validate_unified_creation_spec_response( pub fn validate_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse, spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -311,10 +339,27 @@ pub fn resolve_unified_creation_spec_response(
play_id: &str, play_id: &str,
value: Option<&str>, value: Option<&str>,
) -> Option<UnifiedCreationSpecResponse> { ) -> Option<UnifiedCreationSpecResponse> {
match value { resolve_unified_creation_spec_response_with_entry_title(
play_id,
default_unified_creation_title(play_id).unwrap_or_default(),
value,
)
}
pub fn resolve_unified_creation_spec_response_with_entry_title(
play_id: &str,
entry_title: &str,
value: Option<&str>,
) -> Option<UnifiedCreationSpecResponse> {
let spec = match value {
Some(raw) => decode_unified_creation_spec_response(raw).ok(), Some(raw) => decode_unified_creation_spec_response(raw).ok(),
None => build_phase1_unified_creation_spec(play_id), None => build_phase1_unified_creation_spec(play_id),
} }?;
Some(normalize_unified_creation_spec_title_for_entry(
play_id,
entry_title,
spec,
))
} }
fn unified_creation_field( fn unified_creation_field(
@@ -338,10 +383,12 @@ mod tests {
#[test] #[test]
fn phase1_unified_creation_specs_cover_existing_templates() { fn phase1_unified_creation_specs_cover_existing_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.title, "拼图");
assert_eq!(puzzle.fields[0].id, "pictureDescription"); assert_eq!(puzzle.fields[0].id, "pictureDescription");
assert_eq!(puzzle.fields[1].kind, "image"); assert_eq!(puzzle.fields[1].kind, "image");
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec"); let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
assert_eq!(match3d.title, "抓大鹅");
assert_eq!( assert_eq!(
match3d match3d
.fields .fields
@@ -352,6 +399,7 @@ mod tests {
); );
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert_eq!(jump_hop.title, "跳一跳");
assert!( assert!(
jump_hop jump_hop
.fields .fields
@@ -389,6 +437,45 @@ mod tests {
); );
} }
#[test]
fn unified_creation_spec_title_can_fallback_to_entry_title() {
let raw = r#"{
"playId": "puzzle",
"title": "想做个什么玩法?",
"workspaceStage": "puzzle-agent-workspace",
"generationStage": "puzzle-generating",
"resultStage": "puzzle-result",
"fields": [
{
"id": "pictureDescription",
"kind": "text",
"label": "画面描述",
"required": true
}
]
}"#;
let spec = resolve_unified_creation_spec_response_with_entry_title(
"puzzle",
"定制拼图",
Some(raw),
)
.expect("puzzle spec");
assert_eq!(spec.title, "定制拼图");
}
#[test]
fn unified_creation_spec_title_keeps_admin_custom_copy() {
let mut spec = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
spec.title = "你的跳一跳是...".to_string();
let normalized =
normalize_unified_creation_spec_title_for_entry("jump-hop", "跳一跳", spec);
assert_eq!(normalized.title, "你的跳一跳是...");
}
#[test] #[test]
fn creation_entry_event_banner_defaults_to_structured_render_mode() { fn creation_entry_event_banner_defaults_to_structured_render_mode() {
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>( let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(

View File

@@ -122,7 +122,8 @@ fn upsert_creation_entry_type_config_in_tx(
if input.title.trim().is_empty() { if input.title.trim().is_empty() {
return Err("入口标题不能为空".to_string()); return Err("入口标题不能为空".to_string());
} }
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?; let unified_creation_spec_json =
normalize_unified_creation_spec_json(&id, input.title.trim(), &input)?;
let row = CreationEntryTypeConfig { let row = CreationEntryTypeConfig {
id: id.clone(), id: id.clone(),
title: input.title.trim().to_string(), title: input.title.trim().to_string(),
@@ -297,6 +298,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now); migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now);
migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now);
migrate_jump_hop_entry_from_old_puzzle_default(ctx, now); migrate_jump_hop_entry_from_old_puzzle_default(ctx, now);
migrate_unified_creation_titles_to_entry_defaults(ctx, now);
} }
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
@@ -477,6 +479,51 @@ fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Tim
}); });
} }
fn migrate_unified_creation_titles_to_entry_defaults(ctx: &ReducerContext, now: Timestamp) {
let rows = ctx
.db
.creation_entry_type_config()
.iter()
.collect::<Vec<_>>();
for row in rows {
let Some(raw_spec_json) = row.unified_creation_spec_json.as_deref() else {
continue;
};
let Ok(spec) =
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(
raw_spec_json,
)
else {
continue;
};
let normalized =
shared_contracts::creation_entry_config::normalize_unified_creation_spec_title_for_entry(
&row.id,
&row.title,
spec.clone(),
);
if normalized.title == spec.title {
continue;
}
let Ok(unified_creation_spec_json) =
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(
&normalized,
)
else {
continue;
};
ctx.db
.creation_entry_type_config()
.id()
.update(CreationEntryTypeConfig {
unified_creation_spec_json: Some(unified_creation_spec_json),
updated_at: now,
..row
});
}
}
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> { fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch()) module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
.into_iter() .into_iter()
@@ -500,6 +547,7 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
fn normalize_unified_creation_spec_json( fn normalize_unified_creation_spec_json(
id: &str, id: &str,
entry_title: &str,
input: &CreationEntryTypeAdminUpsertInput, input: &CreationEntryTypeAdminUpsertInput,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else { let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
@@ -512,6 +560,12 @@ fn normalize_unified_creation_spec_json(
let spec = let spec =
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?; shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
let spec =
shared_contracts::creation_entry_config::normalize_unified_creation_spec_title_for_entry(
id,
entry_title,
spec,
);
shared_contracts::creation_entry_config::validate_unified_creation_spec_for_play(id, &spec)?; 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) shared_contracts::creation_entry_config::encode_unified_creation_spec_response(&spec).map(Some)
} }

View File

@@ -36,41 +36,49 @@ describe('unified creation specs', () => {
test('主要链路都映射到统一创作、生成、结果阶段', () => { test('主要链路都映射到统一创作、生成、结果阶段', () => {
expect(getUnifiedCreationSpec('rpg')).toMatchObject({ expect(getUnifiedCreationSpec('rpg')).toMatchObject({
title: '文字冒险',
workspaceStage: 'agent-workspace', workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating', generationStage: 'custom-world-generating',
resultStage: 'custom-world-result', resultStage: 'custom-world-result',
}); });
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({ expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
}); });
expect(getUnifiedCreationSpec('match3d')).toMatchObject({ expect(getUnifiedCreationSpec('match3d')).toMatchObject({
title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace', workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating', generationStage: 'match3d-generating',
resultStage: 'match3d-result', resultStage: 'match3d-result',
}); });
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({ expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
title: '跳一跳',
workspaceStage: 'jump-hop-workspace', workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating', generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result', resultStage: 'jump-hop-result',
}); });
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({ expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace', workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating', generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result', resultStage: 'wooden-fish-result',
}); });
expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({ expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({
title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace', workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating', generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result', resultStage: 'bark-battle-result',
}); });
expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({ expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({
title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace', workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating', generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result', resultStage: 'visual-novel-result',
}); });
expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({ expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({
title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace', workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating', generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result', resultStage: 'baby-object-match-result',

View File

@@ -27,7 +27,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
> = { > = {
rpg: { rpg: {
playId: 'rpg', playId: 'rpg',
title: '想做个什么玩法?', title: '文字冒险',
workspaceStage: 'agent-workspace', workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating', generationStage: 'custom-world-generating',
resultStage: 'custom-world-result', resultStage: 'custom-world-result',
@@ -42,7 +42,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'big-fish': { 'big-fish': {
playId: 'big-fish', playId: 'big-fish',
title: '想做个什么玩法?', title: '摸鱼',
workspaceStage: 'big-fish-agent-workspace', workspaceStage: 'big-fish-agent-workspace',
generationStage: 'big-fish-generating', generationStage: 'big-fish-generating',
resultStage: 'big-fish-result', resultStage: 'big-fish-result',
@@ -57,7 +57,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
puzzle: { puzzle: {
playId: 'puzzle', playId: 'puzzle',
title: '想做个什么玩法?', title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
@@ -84,7 +84,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
match3d: { match3d: {
playId: 'match3d', playId: 'match3d',
title: '想做个什么玩法?', title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace', workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating', generationStage: 'match3d-generating',
resultStage: 'match3d-result', resultStage: 'match3d-result',
@@ -105,7 +105,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'jump-hop': { 'jump-hop': {
playId: 'jump-hop', playId: 'jump-hop',
title: '想做个什么玩法?', title: '跳一跳',
workspaceStage: 'jump-hop-workspace', workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating', generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result', resultStage: 'jump-hop-result',
@@ -120,7 +120,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'wooden-fish': { 'wooden-fish': {
playId: 'wooden-fish', playId: 'wooden-fish',
title: '想做个什么玩法?', title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace', workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating', generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result', resultStage: 'wooden-fish-result',
@@ -153,7 +153,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'square-hole': { 'square-hole': {
playId: 'square-hole', playId: 'square-hole',
title: '想做个什么玩法?', title: '方洞',
workspaceStage: 'square-hole-agent-workspace', workspaceStage: 'square-hole-agent-workspace',
generationStage: 'square-hole-generating', generationStage: 'square-hole-generating',
resultStage: 'square-hole-result', resultStage: 'square-hole-result',
@@ -168,7 +168,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'bark-battle': { 'bark-battle': {
playId: 'bark-battle', playId: 'bark-battle',
title: '想做个什么玩法?', title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace', workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating', generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result', resultStage: 'bark-battle-result',
@@ -213,7 +213,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'visual-novel': { 'visual-novel': {
playId: 'visual-novel', playId: 'visual-novel',
title: '想做个什么玩法?', title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace', workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating', generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result', resultStage: 'visual-novel-result',
@@ -234,7 +234,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'baby-object-match': { 'baby-object-match': {
playId: 'baby-object-match', playId: 'baby-object-match',
title: '想做个什么玩法?', title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace', workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating', generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result', resultStage: 'baby-object-match-result',
@@ -255,7 +255,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'creative-agent': { 'creative-agent': {
playId: 'creative-agent', playId: 'creative-agent',
title: '想做个什么玩法?', title: '智能体创作',
workspaceStage: 'creative-agent-workspace', workspaceStage: 'creative-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',

View File

@@ -78,7 +78,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
/>, />,
); );
expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy(); expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.queryByText('2D素材风格')).toBeNull(); expect(screen.queryByText('2D素材风格')).toBeNull();
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull(); expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
@@ -130,7 +130,7 @@ test('match3d workspace can defer visible chrome to the unified creation page',
expect(workspace?.className).not.toContain('h-full'); expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden'); expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface'); expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); expect(screen.queryByRole('heading', { name: '抓大鹅' })).toBeNull();
const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?'); const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?');
expect(themeInput).toBeTruthy(); expect(themeInput).toBeTruthy();
expect(themeInput.className).not.toContain('h-full'); expect(themeInput.className).not.toContain('h-full');

View File

@@ -115,7 +115,7 @@ export function Match3DCreationWorkspace({
onCreateFromForm, onCreateFromForm,
initialFormPayload = null, initialFormPayload = null,
showBackButton = true, showBackButton = true,
title = '想做个什么玩法?', title = '抓大鹅',
unifiedChrome = false, unifiedChrome = false,
}: Match3DCreationWorkspaceProps) { }: Match3DCreationWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() => const [formState, setFormState] = useState<Match3DFormState>(() =>

View File

@@ -188,7 +188,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByLabelText('作品名称')).toBeNull(); expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull(); expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); expect(screen.getByText('拼图')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull(); expect(screen.queryByText('try')).toBeNull();
expect(screen.queryByText('Template')).toBeNull(); expect(screen.queryByText('Template')).toBeNull();
@@ -238,7 +238,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
expect(workspace?.className).not.toContain('platform-remap-surface'); expect(workspace?.className).not.toContain('platform-remap-surface');
expect(imagePanel?.className).toContain('flex-none'); expect(imagePanel?.className).toContain('flex-none');
expect(imagePanel?.className).not.toContain('flex-1'); expect(imagePanel?.className).not.toContain('flex-1');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); expect(screen.queryByRole('heading', { name: '拼图' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy(); expect(screen.getByLabelText('画面描述')).toBeTruthy();
}); });

View File

@@ -246,7 +246,7 @@ export function PuzzleCreationWorkspace({
onAutoSaveForm, onAutoSaveForm,
initialFormPayload = null, initialFormPayload = null,
showBackButton = true, showBackButton = true,
title = '想做个什么玩法?', title = '拼图',
unifiedChrome = false, unifiedChrome = false,
}: PuzzleCreationWorkspaceProps) { }: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() => const [formState, setFormState] = useState<PuzzleFormState>(() =>