fix: 按契约显示统一创作表头
This commit is contained in:
@@ -1306,10 +1306,10 @@
|
||||
- 验证方式:`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`。
|
||||
|
||||
## 2026-06-06 统一创作页表头跟随后台玩法入口配置
|
||||
## 2026-06-06 统一创作页表头按契约 title 原样显示
|
||||
|
||||
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
|
||||
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但默认值改为玩法入口中文名称;读取和保存时如果发现旧公共表头或代码默认中文名,则归一为当前入口 `title`,后台手动配置成其它标题时保留自定义值。
|
||||
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存归一化、后台入口开关页摘要和前端 fallback spec。
|
||||
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 默认等于入口中文名;后台修改入口名称后,未自定义表头的统一创作页跟随变化。
|
||||
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约 JSON 原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应直接修改后台契约 JSON 的 `title` 字段。
|
||||
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
|
||||
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
@@ -66,6 +66,7 @@ npm run check:server-rs-ddd
|
||||
- 方洞挑战:`/api/creation/square-hole/*`、`/api/runtime/square-hole/*`。
|
||||
- 视觉小说:`/api/creation/visual-novel/*`、`/api/runtime/visual-novel/*`。
|
||||
- 大鱼吃小鱼:`/api/runtime/big-fish/*`。
|
||||
- 跳一跳:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`。
|
||||
- 汪汪声浪:`/api/runtime/bark-battle/*`。
|
||||
- 儿童向创作:`/api/creation/edutainment/*`。
|
||||
- AI task:`/api/ai/tasks*`。
|
||||
@@ -341,7 +342,7 @@ 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`、`unified_creation_spec_json`。
|
||||
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle`、`match3d`、`wooden-fish` 默认 spec。
|
||||
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时按 `shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖。
|
||||
|
||||
### `custom_world_agent_message`
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
创作恢复参数只保留 `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`。统一创作页表头属于后台玩法入口配置,默认使用同一入口的中文名称;旧库里仍持久化为 `想做个什么玩法?` 或代码默认中文名的 spec title,会在读取和保存时归一到当前入口名称,后台单独写成其它标题时保留该自定义值。`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`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,读取和保存时不再用入口名称自动覆盖;需要改表头时应直接修改后台契约 JSON 的 `title` 字段。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||
|
||||
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::format_utc_micros;
|
||||
use shared_contracts::creation_entry_config::{
|
||||
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
|
||||
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response_with_entry_title,
|
||||
encode_unified_creation_spec_response, resolve_unified_creation_spec_response,
|
||||
};
|
||||
|
||||
/// 将创作入口领域快照转换为前后台共享的 HTTP 契约响应。
|
||||
@@ -45,9 +45,8 @@ pub fn build_creation_entry_config_response(
|
||||
.creation_types
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response_with_entry_title(
|
||||
let unified_creation_spec = resolve_unified_creation_spec_response(
|
||||
item.id.as_str(),
|
||||
item.title.as_str(),
|
||||
item.unified_creation_spec_json.as_deref(),
|
||||
);
|
||||
CreationEntryTypeResponse {
|
||||
|
||||
@@ -494,7 +494,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_response_uses_entry_title_for_legacy_unified_creation_title() {
|
||||
fn creation_entry_response_uses_unified_creation_contract_title() {
|
||||
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
|
||||
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
|
||||
start_card: CreationEntryStartCardSnapshot {
|
||||
@@ -543,7 +543,7 @@ mod tests {
|
||||
.unified_creation_spec
|
||||
.as_ref()
|
||||
.map(|spec| spec.title.as_str()),
|
||||
Some("定制拼图")
|
||||
Some("想做个什么玩法?")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ pub struct UnifiedCreationFieldResponse {
|
||||
}
|
||||
|
||||
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> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
@@ -138,16 +137,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
|
||||
"jump-hop-workspace",
|
||||
"jump-hop-generating",
|
||||
"jump-hop-result",
|
||||
vec![
|
||||
unified_creation_field("workTitle", "text", "作品标题", true),
|
||||
unified_creation_field("workDescription", "text", "作品简介", true),
|
||||
unified_creation_field("themeTags", "text", "主题标签", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
unified_creation_field("stylePreset", "select", "风格", true),
|
||||
unified_creation_field("characterPrompt", "text", "角色提示词", true),
|
||||
unified_creation_field("tilePrompt", "text", "地块提示词", true),
|
||||
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
|
||||
],
|
||||
vec![unified_creation_field("themeText", "text", "主题", true)],
|
||||
),
|
||||
"wooden-fish" => (
|
||||
"wooden-fish-workspace",
|
||||
@@ -236,26 +226,6 @@ pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
@@ -339,27 +309,10 @@ pub fn resolve_unified_creation_spec_response(
|
||||
play_id: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
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 {
|
||||
match value {
|
||||
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
|
||||
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(
|
||||
@@ -400,18 +353,8 @@ mod tests {
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert_eq!(jump_hop.title, "跳一跳");
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "stylePreset")
|
||||
);
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "endMoodPrompt")
|
||||
);
|
||||
assert_eq!(jump_hop.fields.len(), 1);
|
||||
assert_eq!(jump_hop.fields[0].id, "themeText");
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
@@ -438,7 +381,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_creation_spec_title_can_fallback_to_entry_title() {
|
||||
fn unified_creation_spec_title_uses_contract_content() {
|
||||
let raw = r#"{
|
||||
"playId": "puzzle",
|
||||
"title": "想做个什么玩法?",
|
||||
@@ -455,25 +398,10 @@ mod tests {
|
||||
]
|
||||
}"#;
|
||||
|
||||
let spec = resolve_unified_creation_spec_response_with_entry_title(
|
||||
"puzzle",
|
||||
"定制拼图",
|
||||
Some(raw),
|
||||
)
|
||||
.expect("puzzle spec");
|
||||
let spec =
|
||||
resolve_unified_creation_spec_response("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, "你的跳一跳是...");
|
||||
assert_eq!(spec.title, "想做个什么玩法?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -122,8 +122,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.title.trim(), &input)?;
|
||||
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?;
|
||||
let row = CreationEntryTypeConfig {
|
||||
id: id.clone(),
|
||||
title: input.title.trim().to_string(),
|
||||
@@ -298,7 +297,6 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
migrate_baby_object_match_entry_from_old_coming_soon_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_unified_creation_titles_to_entry_defaults(ctx, now);
|
||||
}
|
||||
|
||||
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
@@ -479,51 +477,6 @@ 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> {
|
||||
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
|
||||
.into_iter()
|
||||
@@ -547,7 +500,6 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
|
||||
|
||||
fn normalize_unified_creation_spec_json(
|
||||
id: &str,
|
||||
entry_title: &str,
|
||||
input: &CreationEntryTypeAdminUpsertInput,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
|
||||
@@ -560,12 +512,6 @@ fn normalize_unified_creation_spec_json(
|
||||
|
||||
let spec =
|
||||
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::encode_unified_creation_spec_response(&spec).map(Some)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user