-
玩法
-
{parsed.spec.playId}
+
+
+
+
- 玩法
+ - {spec.playId}
+
+
+
- 表头
+ - {spec.title}
+
+
+
- 泥点消耗
+ - {formatMudPointCostText(spec.mudPointCost)}
+
+
+
+ {spec.fields.map((field) => (
+
+ {field.id}
+ {field.label}
+
+ {field.kind} / {field.required ? '必填' : '选填'}
+
+
+ ))}
-
-
表头
- {parsed.spec.title}
-
-
-
阶段
-
- {parsed.spec.workspaceStage} / {parsed.spec.generationStage} /{' '}
- {parsed.spec.resultStage}
-
-
-
-
字段
- {parsed.spec.fields.map((field) => field.id).join('、')}
-
-
+
);
}
-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
, key: string) {
const raw = value[key];
return typeof raw === 'string' ? raw.trim() : '';
}
+function readPositiveInteger(value: Record, key: string) {
+ const raw = value[key];
+ const numberValue =
+ typeof raw === 'number'
+ ? raw
+ : typeof raw === 'string'
+ ? Number(raw.trim())
+ : NaN;
+ if (!Number.isInteger(numberValue) || numberValue <= 0) {
+ return null;
+ }
+ return numberValue;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css
index 162c9678..4f8b1faa 100644
--- a/apps/admin-web/src/styles/admin.css
+++ b/apps/admin-web/src/styles/admin.css
@@ -791,6 +791,67 @@ button:disabled {
overflow-wrap: anywhere;
}
+.admin-contract-card {
+ display: grid;
+ gap: 12px;
+ border: 1px solid #eaded2;
+ border-radius: 8px;
+ background: #fffdf9;
+ padding: 12px;
+}
+
+.admin-contract-field-list,
+.admin-contract-field-editor-list {
+ display: grid;
+ gap: 10px;
+}
+
+.admin-contract-field-list {
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+}
+
+.admin-contract-field-card,
+.admin-contract-field-editor {
+ display: grid;
+ gap: 6px;
+ border: 1px solid #eaded2;
+ border-radius: 8px;
+ background: #fff8f1;
+ padding: 10px;
+}
+
+.admin-contract-field-card strong,
+.admin-contract-field-card span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.admin-contract-field-card strong {
+ color: #3d1f10;
+ font-size: 13px;
+}
+
+.admin-contract-field-card span {
+ color: #8f7868;
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.admin-contract-dialog {
+ width: min(100%, 860px);
+}
+
+.admin-contract-field-editor-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.1fr) minmax(120px, 0.6fr) minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: end;
+}
+
+.admin-contract-required-toggle {
+ min-height: 42px;
+}
+
.admin-status {
display: inline-flex;
max-width: 460px;
@@ -948,7 +1009,8 @@ button:disabled {
.admin-two-column-wide,
.admin-form-row,
.admin-filter-grid,
- .admin-table-query-grid {
+ .admin-table-query-grid,
+ .admin-contract-field-editor-grid {
grid-template-columns: 1fr;
}
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 5b82cf27..6c5e7740 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -6,7 +6,9 @@
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
-当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下显示 `creationTypes[].unifiedCreationSpec.mudPointCost` 经前端格式化后的泥点消耗、下方白底标题/描述”结构展示,旧契约缺少该字段时兜底 `10` 并由前端显示为 `10泥点数`,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+
+旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
@@ -28,7 +30,7 @@
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
-统一创作入口覆盖当前可进入创作链路的已有模板:`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`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,入口卡泥点消耗按 `unifiedCreationSpec.mudPointCost` 由前端格式化为 `X泥点数`,读取和保存时不再用入口名称或前端固定文案自动覆盖;需要改表头或入口卡消耗数量时应在后台契约结构卡片点击修改,并通过弹窗表单编辑 `title` 或 `mudPointCost` 字段,不再要求直接编辑 JSON。`workspaceStage`、`generationStage` 和 `resultStage` 属于内部阶段标识,后台弹窗不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的前端固定阶段映射自动带出。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底;H5 根节点在 `data-mobile-keyboard-open=true` 时必须把 `html` / `body` / `#root` 背景切到当前平台浅色底,但不得再用 `.platform-viewport-shell` 全局 `transform` 二次上推页面;小程序 `web-view` 页面原生宿主也必须使用浅色背景,不能沿用全局黑色 page 背景。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
@@ -38,7 +40,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
-创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示 `10-20泥点数` 这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
+创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示泥点消耗这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
diff --git a/server-rs/crates/shared-contracts/src/creation_entry_config.rs b/server-rs/crates/shared-contracts/src/creation_entry_config.rs
index 7a9ded46..c85dc133 100644
--- a/server-rs/crates/shared-contracts/src/creation_entry_config.rs
+++ b/server-rs/crates/shared-contracts/src/creation_entry_config.rs
@@ -83,6 +83,12 @@ pub struct CreationEntryTypeResponse {
pub struct UnifiedCreationSpecResponse {
pub play_id: String,
pub title: String,
+ #[serde(
+ default = "default_unified_creation_mud_point_cost",
+ alias = "mudPointCostText",
+ deserialize_with = "deserialize_unified_creation_mud_point_cost"
+ )]
+ pub mud_point_cost: u32,
pub workspace_stage: String,
pub generation_stage: String,
pub result_stage: String,
@@ -99,6 +105,11 @@ pub struct UnifiedCreationFieldResponse {
}
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
+pub const DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: u32 = 10;
+
+pub fn default_unified_creation_mud_point_cost() -> u32 {
+ DEFAULT_UNIFIED_CREATION_MUD_POINT_COST
+}
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option {
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
@@ -202,6 +213,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option(deserializer: D) -> Result
+where
+ D: serde::Deserializer<'de>,
+{
+ let value = serde_json::Value::deserialize(deserializer)?;
+ match value {
+ serde_json::Value::Number(number) => number
+ .as_u64()
+ .and_then(|raw| u32::try_from(raw).ok())
+ .ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是非负整数")),
+ serde_json::Value::String(text) => parse_unified_creation_mud_point_cost_text(&text)
+ .ok_or_else(|| serde::de::Error::custom("泥点消耗数量必须是数字")),
+ _ => Err(serde::de::Error::custom("泥点消耗数量必须是数字")),
+ }
+}
+
+fn parse_unified_creation_mud_point_cost_text(value: &str) -> Option {
+ let digits: String = value
+ .chars()
+ .skip_while(|character| !character.is_ascii_digit())
+ .take_while(|character| character.is_ascii_digit())
+ .collect();
+ digits.parse::().ok()
+}
+
pub fn resolve_unified_creation_spec_response(
play_id: &str,
value: Option<&str>,
@@ -337,6 +377,7 @@ mod tests {
fn phase1_unified_creation_specs_cover_existing_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.title, "拼图");
+ assert_eq!(puzzle.mud_point_cost, 10);
assert_eq!(puzzle.fields[0].id, "pictureDescription");
assert_eq!(puzzle.fields[1].kind, "image");
@@ -385,6 +426,7 @@ mod tests {
let raw = r#"{
"playId": "puzzle",
"title": "想做个什么玩法?",
+ "mudPointCost": 12,
"workspaceStage": "puzzle-agent-workspace",
"generationStage": "puzzle-generating",
"resultStage": "puzzle-result",
@@ -402,6 +444,56 @@ mod tests {
resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec");
assert_eq!(spec.title, "想做个什么玩法?");
+ assert_eq!(spec.mud_point_cost, 12);
+ }
+
+ #[test]
+ fn unified_creation_spec_reads_legacy_mud_point_cost_text() {
+ let raw = r#"{
+ "playId": "puzzle",
+ "title": "想做个什么玩法?",
+ "mudPointCostText": "12泥点数",
+ "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("puzzle", Some(raw)).expect("puzzle spec");
+
+ assert_eq!(spec.mud_point_cost, 12);
+ }
+
+ #[test]
+ fn unified_creation_spec_uses_default_mud_point_cost_for_legacy_json() {
+ 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("puzzle", Some(raw)).expect("puzzle spec");
+
+ assert_eq!(spec.mud_point_cost, 10);
}
#[test]
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx
index a6df7f93..2edabec5 100644
--- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx
+++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx
@@ -241,7 +241,7 @@ test('creation start card renders reference-aligned banner and template metadata
expect(html).toContain('creation-template-card__body');
expect(html).toContain('creation-template-card__cost-badge');
expect(html).toContain('拼图关卡创作');
- expect(html).toContain('10-20泥点数');
+ expect(html).toContain('10泥点数');
expect(html).toContain('即将开放');
expect(html).toContain('data-locked="true"');
expect(html).toContain('暂未开放');
@@ -292,7 +292,62 @@ test('locked creation template card replaces mud point cost with unavailable sta
expect(html).toContain('data-locked="true"');
expect(html).toContain('即将开放');
expect(html).toContain('暂未开放');
- expect(html).not.toContain('10-20泥点数');
+ expect(html).not.toContain('10泥点数');
+});
+
+test('creation template card renders mud point cost from unified creation spec', () => {
+ const config = {
+ ...testEntryConfig,
+ creationTypes: [
+ {
+ id: 'puzzle',
+ title: '拼图',
+ subtitle: '拼图关卡创作',
+ badge: '可创建',
+ imageSrc: '/creation-type-references/puzzle.webp',
+ visible: true,
+ open: true,
+ sortOrder: 30,
+ categoryId: 'recommended',
+ categoryLabel: '热门推荐',
+ categorySortOrder: 20,
+ updatedAtMicros: 1,
+ unifiedCreationSpec: {
+ playId: 'puzzle',
+ title: '拼图',
+ mudPointCost: 12,
+ workspaceStage: 'puzzle-agent-workspace',
+ generationStage: 'puzzle-generating',
+ resultStage: 'puzzle-result',
+ fields: [
+ {
+ id: 'pictureDescription',
+ kind: 'text',
+ label: '画面描述',
+ required: true,
+ },
+ ],
+ },
+ },
+ ],
+ } satisfies CreationEntryConfig;
+ const html = renderToStaticMarkup(
+ {}}
+ onCreateType={noopCreateType}
+ onOpenDraft={() => {}}
+ onEnterPublished={() => {}}
+ entryConfig={config}
+ creationTypes={derivePlatformCreationTypes(config.creationTypes)}
+ mode="start-only"
+ />,
+ );
+
+ expect(html).toContain('12泥点数');
+ expect(html).not.toContain('10泥点数');
});
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx
index 76f5254b..fe6c9b48 100644
--- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx
+++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx
@@ -33,7 +33,7 @@ function shouldShowCreationBadge(badge: string) {
}
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
-export function resolveCreationEntryEventBanners(
+function resolveCreationEntryEventBanners(
entryConfig: CreationEntryConfig,
): CreationEventBannerCard[] {
const configuredBanners = Array.isArray(entryConfig.eventBanners)
@@ -379,7 +379,7 @@ export function CustomWorldCreationStartCard({
)}
- {item.locked ? '暂未开放' : '10-20泥点数'}
+ {item.locked ? '暂未开放' : item.mudPointCostLabel}
diff --git a/src/components/platform-entry/platformEntryCreationTypes.test.ts b/src/components/platform-entry/platformEntryCreationTypes.test.ts
index c1b065e0..833ebb7b 100644
--- a/src/components/platform-entry/platformEntryCreationTypes.test.ts
+++ b/src/components/platform-entry/platformEntryCreationTypes.test.ts
@@ -3,8 +3,8 @@ import { afterEach, expect, test, vi } from 'vitest';
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import {
derivePlatformCreationTypes,
- groupVisiblePlatformCreationTypes,
getVisiblePlatformCreationTypes,
+ groupVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
@@ -42,6 +42,22 @@ test('database entry config controls visibility open state and display order', (
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
+ unifiedCreationSpec: {
+ playId: 'match3d',
+ title: '抓大鹅',
+ mudPointCost: 8,
+ workspaceStage: 'match3d-agent-workspace',
+ generationStage: 'match3d-generating',
+ resultStage: 'match3d-result',
+ fields: [
+ {
+ id: 'themeText',
+ kind: 'text',
+ label: '题材',
+ required: true,
+ },
+ ],
+ },
},
{
id: 'square-hole',
@@ -64,6 +80,7 @@ test('database entry config controls visibility open state and display order', (
id: 'match3d',
locked: false,
hidden: false,
+ mudPointCostLabel: '8泥点数',
}),
expect.objectContaining({
id: 'square-hole',
@@ -75,6 +92,7 @@ test('database entry config controls visibility open state and display order', (
title: '数据库拼图',
locked: true,
hidden: false,
+ mudPointCostLabel: '10泥点数',
}),
]);
});
diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts
index 4c8b7143..ce5d419f 100644
--- a/src/components/platform-entry/platformEntryCreationTypes.ts
+++ b/src/components/platform-entry/platformEntryCreationTypes.ts
@@ -2,7 +2,10 @@ import {
assertPlatformCreationTypeId,
type PlatformCreationTypeId,
} from '../../../packages/shared/src/contracts/playTypes';
-import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
+import {
+ type CreationEntryTypeConfig,
+ DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
+} from '../../services/creationEntryConfigService';
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
export type { PlatformCreationTypeId };
@@ -13,6 +16,7 @@ export type PlatformCreationTypeCard = {
subtitle: string;
badge: string;
imageSrc: string;
+ mudPointCostLabel: string;
locked: boolean;
categoryId: string;
categoryLabel: string;
@@ -32,6 +36,16 @@ const RECENT_CREATION_CATEGORY_ID = 'recent';
const FALLBACK_CREATION_CATEGORY_ID = 'recommended';
const FALLBACK_CREATION_CATEGORY_LABEL = '热门推荐';
+function normalizeMudPointCost(value: number | null | undefined) {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
+ ? Math.trunc(value)
+ : DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
+}
+
+function formatMudPointCostText(value: number | null | undefined) {
+ return `${normalizeMudPointCost(value)}泥点数`;
+}
+
export function getVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
) {
@@ -130,6 +144,9 @@ export function derivePlatformCreationTypes(
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
+ mudPointCostLabel: formatMudPointCostText(
+ item.unifiedCreationSpec?.mudPointCost,
+ ),
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
diff --git a/src/services/creationEntryConfigService.ts b/src/services/creationEntryConfigService.ts
index b3bfab43..15ed0315 100644
--- a/src/services/creationEntryConfigService.ts
+++ b/src/services/creationEntryConfigService.ts
@@ -1,6 +1,8 @@
import type { PlatformCreationTypeId } from '../../packages/shared/src/contracts/playTypes';
import { requestJson } from './apiClient';
+export const DEFAULT_UNIFIED_CREATION_MUD_POINT_COST = 10;
+
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
export type CreationEntryTypeConfig = {
id: PlatformCreationTypeId;
@@ -30,6 +32,7 @@ export type UnifiedCreationField = {
export type UnifiedCreationSpec = {
playId: PlatformCreationTypeId;
title: string;
+ mudPointCost?: number | null;
workspaceStage: string;
generationStage: string;
resultStage: string;