收口创作入口契约后台表单

将统一创作契约泥点消耗改为数字字段并由前端格式化展示
将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单
隐藏玩法阶段等内部标识并按玩法默认映射自动带出
更新创作入口文档、团队记忆和回归测试
This commit is contained in:
2026-06-07 23:53:26 +08:00
parent 2a6da01307
commit 17662916cd
13 changed files with 822 additions and 108 deletions
+10 -1
View File
@@ -1346,6 +1346,15 @@
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture``npm run check:spacetime-schema``npm run check:encoding``cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-07 创作入口泥点消耗改由统一契约驱动
- 背景:创作入口玩法卡封面右下角长期固定显示 `10-20泥点数`,无法在后台按玩法调整,也容易和真实钱包余额或活动奖池混淆。
- 决策:`creationTypes[].unifiedCreationSpec.mudPointCost` 作为入口卡泥点消耗数量字段,旧契约缺失时后端和前端都兜底为 `10`;入口卡由前端格式化为 `X泥点数` 展示,后端和后台不保存单位文案。该字段只表达入口卡展示数量,不替代各玩法提交、生成或发布链路中的真实扣费校验。
- 决策补充:后台创作入口开关页不再直接暴露统一创作契约 JSON textarea;页面按契约结构展示为卡片和字段列表,点击“修改契约”后通过弹窗表单编辑 `title``mudPointCost` 和 fields,再组装回统一契约 payload 保存。`workspaceStage``generationStage``resultStage` 属于内部阶段标识,后台不展示也不允许编辑;保存时沿用已有契约值,新增契约时按 `playId` 的固定阶段映射自动带出。
- 影响范围:`shared-contracts``UnifiedCreationSpecResponse``/api/creation-entry/config` 响应、前端入口卡派生、后台入口开关页、玩法链路文档和创作入口回归测试。
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;关闭态卡片仍只显示 `暂未开放`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
@@ -1357,7 +1366,7 @@
## 2026-06-06 统一创作页表头按契约 title 原样显示
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约 JSON 原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应直接修改后台契约 JSON 的 `title` 字段。
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约内容原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应在后台契约结构卡片中点击修改并编辑 `title` 字段。
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
+1 -1
View File
@@ -211,7 +211,7 @@
- 现象:创作 Tab 两列玩法卡上图能看到,但标题、描述或预计消耗泥点在白底信息区里看不见,或只剩泥点小图标。
- 原因:旧 `platform-creation-reference-card` 是给暗图蒙版卡用的全局样式,会把卡片及全部子元素强制成白色文字;参考图要求的是“上图 + 下方白底信息区”,继续复用旧类会让白底上的文字消失。
- 处理:创作 Tab 首屏模板卡使用独立 `creation-template-card``creation-template-card__body``creation-template-card__title``creation-template-card__subtitle``creation-template-card__cost` 结构,不挂 `platform-creation-reference-card`;旧弹层如果仍是暗图蒙版卡,可以继续保留旧类。
- 验证:浏览器创作 Tab 中每张卡都应显示标题、描述和“预计消耗 10-20 泥点”`npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
- 验证:浏览器创作 Tab 中每张开放态卡都应显示标题、描述和后台契约 `mudPointCost` 数量经前端格式化后的泥点消耗文案;旧契约缺字段时兜底显示 `10泥点数``npm test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx -t "creation start card renders reference-aligned banner and template metadata"` 应通过。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/index.css``src/components/custom-world-home/CustomWorldCreationHub.test.tsx`
## 创作首屏开放态卡片不要再显示左上状态标签
+1
View File
@@ -205,6 +205,7 @@ export interface AdminUpsertCreationEntryEventBannersRequest {
export interface UnifiedCreationSpecPayload {
playId: string;
title: string;
mudPointCost: number;
workspaceStage: string;
generationStage: string;
resultStage: string;
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
@@ -28,6 +28,7 @@ vi.mock('../api/adminApiClient', () => ({
const puzzleSpec: UnifiedCreationSpecPayload = {
playId: 'puzzle',
title: '拼图',
mudPointCost: 10,
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
@@ -93,6 +94,22 @@ test('创作入口后台展示并保存统一创作契约', async () => {
).toContain('拼图');
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
expect(container.querySelector('.admin-muted')).toBeNull();
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
expect(screen.queryByText('puzzle-generating')).toBeNull();
await user.click(screen.getByRole('button', {name: '修改契约'}));
const dialog = screen.getByRole('dialog', {name: '统一创作契约'});
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
target: {value: '12'},
});
await user.click(within(dialog).getByRole('button', {name: '应用修改'}));
expect(screen.queryByRole('dialog', {name: '统一创作契约'})).toBeNull();
expect(screen.getByText('12泥点数')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '保存入库'}));
await user.click(screen.getByRole('button', {name: '确认'}));
@@ -102,7 +119,10 @@ test('创作入口后台展示并保存统一创作契约', async () => {
'admin-token',
expect.objectContaining({
id: 'puzzle',
unifiedCreationSpec: puzzleSpec,
unifiedCreationSpec: {
...puzzleSpec,
mudPointCost: 12,
},
}),
);
});
@@ -110,6 +130,18 @@ test('创作入口后台展示并保存统一创作契约', async () => {
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
const user = userEvent.setup();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
entries: [
{
...configResponse.entries[0]!,
unifiedCreationSpec: {
...puzzleSpec,
playId: 'match3d',
},
},
],
});
render(
<AdminCreationEntrySwitchPage
token="admin-token"
@@ -117,15 +149,7 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
/>,
);
const textarea = await screen.findByLabelText('契约 JSON');
fireEvent.change(textarea, {
target: {
value: JSON.stringify({
...puzzleSpec,
playId: 'match3d',
}),
},
});
await screen.findByText('pictureDescription');
await user.click(screen.getByRole('button', {name: '保存入库'}));
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
File diff suppressed because it is too large Load Diff
+63 -1
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
@@ -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<UnifiedCreationSpecResponse> {
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<UnifiedCreati
Some(UnifiedCreationSpecResponse {
play_id: play_id.to_string(),
title: default_unified_creation_title(play_id)?.to_string(),
mud_point_cost: default_unified_creation_mud_point_cost(),
workspace_stage: workspace_stage.to_string(),
generation_stage: generation_stage.to_string(),
result_stage: result_stage.to_string(),
@@ -235,6 +247,9 @@ pub fn validate_unified_creation_spec_response(
if spec.title.trim().is_empty() {
return Err("统一创作契约标题不能为空".to_string());
}
if spec.mud_point_cost == 0 {
return Err("统一创作契约泥点消耗数量必须大于 0".to_string());
}
let workspace_stage = spec.workspace_stage.trim();
let generation_stage = spec.generation_stage.trim();
@@ -305,6 +320,31 @@ pub fn decode_unified_creation_spec_response(
Ok(spec)
}
fn deserialize_unified_creation_mud_point_cost<'de, D>(deserializer: D) -> Result<u32, D::Error>
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<u32> {
let digits: String = value
.chars()
.skip_while(|character| !character.is_ascii_digit())
.take_while(|character| character.is_ascii_digit())
.collect();
digits.parse::<u32>().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]
@@ -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(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
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', () => {
@@ -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({
<Coins className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{item.locked ? '暂未开放' : '10-20泥点数'}
{item.locked ? '暂未开放' : item.mudPointCostLabel}
</span>
</span>
</div>
@@ -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泥点数',
}),
]);
});
@@ -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),
@@ -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;