From 17662916cd8db7510b53079bfba3ec20ea09271a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 23:53:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=88=9B=E4=BD=9C=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E5=A5=91=E7=BA=A6=E5=90=8E=E5=8F=B0=E8=A1=A8=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将统一创作契约泥点消耗改为数字字段并由前端格式化展示 将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单 隐藏玩法阶段等内部标识并按玩法默认映射自动带出 更新创作入口文档、团队记忆和回归测试 --- .hermes/shared-memory/decision-log.md | 11 +- .hermes/shared-memory/pitfalls.md | 2 +- apps/admin-web/src/api/adminApiTypes.ts | 1 + .../AdminCreationEntrySwitchPage.test.tsx | 46 +- .../pages/AdminCreationEntrySwitchPage.tsx | 603 +++++++++++++++--- apps/admin-web/src/styles/admin.css | 64 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 6 +- .../src/creation_entry_config.rs | 92 +++ .../CustomWorldCreationHub.test.tsx | 59 +- .../CustomWorldCreationStartCard.tsx | 4 +- .../platformEntryCreationTypes.test.ts | 20 +- .../platformEntryCreationTypes.ts | 19 +- src/services/creationEntryConfigService.ts | 3 + 13 files changed, 822 insertions(+), 108 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6e26d881..19f1ae03 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 2ccd5583..c924e995 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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`。 ## 创作首屏开放态卡片不要再显示左上状态标签 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 83672193..2eb58477 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -205,6 +205,7 @@ export interface AdminUpsertCreationEntryEventBannersRequest { export interface UnifiedCreationSpecPayload { playId: string; title: string; + mudPointCost: number; workspaceStage: string; generationStage: string; resultStage: string; diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx index adad5145..4400b5da 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -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( , ); - 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(); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index 9390f0f1..e00c848b 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -1,4 +1,4 @@ -import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'; +import { Pencil, Plus, RefreshCcw, Save, Trash2, X } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; import { @@ -34,7 +34,100 @@ type AnnouncementFormBuildResult = | { ok: true; json: string } | { ok: false; message: string }; +/** 统一创作契约字段的弹窗表单态。 */ +type UnifiedCreationSpecFieldFormItem = { + id: string; + fieldId: string; + kind: UnifiedCreationFieldPayload['kind']; + label: string; + required: boolean; +}; + +/** 统一创作契约弹窗表单态;保存入库前会重新组装为后端契约。 */ +type UnifiedCreationSpecFormState = { + playId: string; + title: string; + mudPointCost: string; + /** 内部阶段由已有契约或玩法默认映射带出,不在后台表单中开放编辑。 */ + workspaceStage: string; + generationStage: string; + resultStage: string; + fields: UnifiedCreationSpecFieldFormItem[]; +}; + +type UnifiedCreationSpecStageState = Pick< + UnifiedCreationSpecFormState, + 'workspaceStage' | 'generationStage' | 'resultStage' +>; + +const DEFAULT_UNIFIED_CREATION_STAGE_MAP: Record< + string, + UnifiedCreationSpecStageState +> = { + rpg: { + workspaceStage: 'agent-workspace', + generationStage: 'custom-world-generating', + resultStage: 'custom-world-result', + }, + 'big-fish': { + workspaceStage: 'big-fish-agent-workspace', + generationStage: 'big-fish-generating', + resultStage: 'big-fish-result', + }, + puzzle: { + workspaceStage: 'puzzle-agent-workspace', + generationStage: 'puzzle-generating', + resultStage: 'puzzle-result', + }, + 'puzzle-clear': { + workspaceStage: 'puzzle-clear-workspace', + generationStage: 'puzzle-clear-generating', + resultStage: 'puzzle-clear-result', + }, + match3d: { + workspaceStage: 'match3d-agent-workspace', + generationStage: 'match3d-generating', + resultStage: 'match3d-result', + }, + 'jump-hop': { + workspaceStage: 'jump-hop-workspace', + generationStage: 'jump-hop-generating', + resultStage: 'jump-hop-result', + }, + 'wooden-fish': { + workspaceStage: 'wooden-fish-workspace', + generationStage: 'wooden-fish-generating', + resultStage: 'wooden-fish-result', + }, + 'square-hole': { + workspaceStage: 'square-hole-agent-workspace', + generationStage: 'square-hole-generating', + resultStage: 'square-hole-result', + }, + 'bark-battle': { + workspaceStage: 'bark-battle-workspace', + generationStage: 'bark-battle-generating', + resultStage: 'bark-battle-result', + }, + 'visual-novel': { + workspaceStage: 'visual-novel-agent-workspace', + generationStage: 'visual-novel-generating', + resultStage: 'visual-novel-result', + }, + 'baby-object-match': { + workspaceStage: 'baby-object-match-workspace', + generationStage: 'baby-object-match-generating', + resultStage: 'baby-object-match-result', + }, + 'creative-agent': { + workspaceStage: 'creative-agent-workspace', + generationStage: 'puzzle-generating', + resultStage: 'puzzle-result', + }, +}; + let announcementFormItemSequence = 0; +let unifiedCreationSpecFieldSequence = 0; export function AdminCreationEntrySwitchPage({ token, @@ -55,7 +148,12 @@ export function AdminCreationEntrySwitchPage({ const [categoryId, setCategoryId] = useState('recommended'); const [categoryLabel, setCategoryLabel] = useState('热门推荐'); const [categorySortOrder, setCategorySortOrder] = useState('20'); - const [unifiedCreationSpecJson, setUnifiedCreationSpecJson] = useState(''); + const [unifiedCreationSpec, setUnifiedCreationSpec] = + useState(null); + const [unifiedCreationSpecForm, setUnifiedCreationSpecForm] = + useState(null); + const [unifiedCreationSpecFormError, setUnifiedCreationSpecFormError] = + useState(''); const [announcementItems, setAnnouncementItems] = useState< AnnouncementFormItem[] >([]); @@ -101,9 +199,9 @@ export function AdminCreationEntrySwitchPage({ const targetId = selectedId.trim(); setErrorMessage(''); - const unifiedCreationSpecResult = parseUnifiedCreationSpecJson( + const unifiedCreationSpecResult = validateUnifiedCreationSpecForEntry( targetId, - unifiedCreationSpecJson, + unifiedCreationSpec, ); if (!unifiedCreationSpecResult.ok) { setErrorMessage(unifiedCreationSpecResult.message); @@ -193,9 +291,95 @@ export function AdminCreationEntrySwitchPage({ setCategoryId(entry.categoryId); setCategoryLabel(entry.categoryLabel); setCategorySortOrder(String(entry.categorySortOrder)); - setUnifiedCreationSpecJson( - formatUnifiedCreationSpecJson(entry.unifiedCreationSpec), + setUnifiedCreationSpec(entry.unifiedCreationSpec ?? null); + setUnifiedCreationSpecForm(null); + setUnifiedCreationSpecFormError(''); + } + + /** 打开统一创作契约弹窗;缺省时用当前入口 ID 和标题预填。 */ + function openUnifiedCreationSpecForm() { + setUnifiedCreationSpecForm( + buildUnifiedCreationSpecForm( + unifiedCreationSpec, + selectedId.trim(), + title.trim(), + ), ); + setUnifiedCreationSpecFormError(''); + } + + function closeUnifiedCreationSpecForm() { + setUnifiedCreationSpecForm(null); + setUnifiedCreationSpecFormError(''); + } + + function applyUnifiedCreationSpecForm(event: FormEvent) { + event.preventDefault(); + if (!unifiedCreationSpecForm) { + return; + } + + const result = buildUnifiedCreationSpecFromForm(unifiedCreationSpecForm); + if (!result.ok) { + setUnifiedCreationSpecFormError(result.message); + return; + } + if (result.spec.playId !== selectedId.trim()) { + setUnifiedCreationSpecFormError('统一创作契约 playId 必须与入口 ID 一致'); + return; + } + setUnifiedCreationSpec(result.spec); + closeUnifiedCreationSpecForm(); + } + + function updateUnifiedCreationSpecForm( + patch: Partial>, + ) { + setUnifiedCreationSpecForm((currentForm) => + currentForm ? { ...currentForm, ...patch } : currentForm, + ); + } + + function updateUnifiedCreationSpecField( + index: number, + patch: Partial>, + ) { + setUnifiedCreationSpecForm((currentForm) => + currentForm + ? { + ...currentForm, + fields: currentForm.fields.map((field, fieldIndex) => + fieldIndex === index ? { ...field, ...patch } : field, + ), + } + : currentForm, + ); + } + + function addUnifiedCreationSpecField() { + setUnifiedCreationSpecForm((currentForm) => + currentForm + ? { + ...currentForm, + fields: [...currentForm.fields, createUnifiedCreationSpecFieldFormItem()], + } + : currentForm, + ); + } + + function removeUnifiedCreationSpecField(index: number) { + setUnifiedCreationSpecForm((currentForm) => { + if (!currentForm) { + return currentForm; + } + const fields = currentForm.fields.filter( + (_, fieldIndex) => fieldIndex !== index, + ); + return { + ...currentForm, + fields: fields.length > 0 ? fields : [createUnifiedCreationSpecFieldFormItem()], + }; + }); } /** 更新单条公告表单字段,避免后台页面直接暴露 JSON 编辑。 */ @@ -431,24 +615,34 @@ export function AdminCreationEntrySwitchPage({
统一创作契约 - {unifiedCreationSpecJson.trim() ? '已配置' : '未配置'} + {unifiedCreationSpec ? '已配置' : '未配置'}
- {unifiedCreationSpecJson.trim() ? ( - +
+ + {unifiedCreationSpec ? ( + + ) : null} +
+ {unifiedCreationSpec ? ( + ) : (
未配置统一创作页契约
)} -