再次合并 master

合入 origin/master 最新创作入口契约与后台编辑调整。

保留本枝平台入口架构收口约束并合并玩法链路文档。

通过 typecheck、编码检查、冲突扫描与相关创作入口测试。
This commit is contained in:
2026-06-08 00:00:36 +08:00
13 changed files with 831 additions and 107 deletions

View File

@@ -1671,6 +1671,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 切片会显得像错误素材。
@@ -1678,3 +1687,11 @@
- 影响范围:拼消消工作台 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`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-06 统一创作页表头按契约 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`

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`
## 创作首屏开放态卡片不要再显示左上状态标签

View File

@@ -205,6 +205,7 @@ export interface AdminUpsertCreationEntryEventBannersRequest {
export interface UnifiedCreationSpecPayload {
playId: string;
title: string;
mudPointCost: number;
workspaceStage: string;
generationStage: string;
resultStage: string;

View File

@@ -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();

View File

@@ -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<UnifiedCreationSpecPayload | null>(null);
const [unifiedCreationSpecForm, setUnifiedCreationSpecForm] =
useState<UnifiedCreationSpecFormState | null>(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<HTMLFormElement>) {
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<Omit<UnifiedCreationSpecFormState, 'fields'>>,
) {
setUnifiedCreationSpecForm((currentForm) =>
currentForm ? { ...currentForm, ...patch } : currentForm,
);
}
function updateUnifiedCreationSpecField(
index: number,
patch: Partial<Omit<UnifiedCreationSpecFieldFormItem, 'id'>>,
) {
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({
<div className="admin-subsection-heading">
<span></span>
<span>
{unifiedCreationSpecJson.trim() ? '已配置' : '未配置'}
{unifiedCreationSpec ? '已配置' : '未配置'}
</span>
</div>
{unifiedCreationSpecJson.trim() ? (
<UnifiedCreationSpecSummary specJson={unifiedCreationSpecJson} />
<div className="admin-form-actions">
<button
className="admin-secondary-button"
type="button"
onClick={openUnifiedCreationSpecForm}
>
<Pencil size={17} aria-hidden="true" />
<span>{unifiedCreationSpec ? '修改契约' : '新增契约'}</span>
</button>
{unifiedCreationSpec ? (
<button
className="admin-secondary-button"
type="button"
onClick={() => setUnifiedCreationSpec(null)}
>
<Trash2 size={17} aria-hidden="true" />
<span></span>
</button>
) : null}
</div>
{unifiedCreationSpec ? (
<UnifiedCreationSpecCard spec={unifiedCreationSpec} />
) : (
<div className="admin-muted-text"></div>
)}
<label className="admin-field">
<span> JSON</span>
<textarea
rows={12}
value={unifiedCreationSpecJson}
onChange={(event) =>
setUnifiedCreationSpecJson(event.target.value)
}
/>
</label>
</section>
{errorMessage ? (
@@ -509,6 +703,171 @@ export function AdminCreationEntrySwitchPage({
) : null}
{confirmDialog}
{unifiedCreationSpecForm ? (
<div
className="admin-confirm-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeUnifiedCreationSpecForm();
}
}}
>
<form
aria-labelledby="admin-unified-creation-spec-editor-title"
aria-modal="true"
className="admin-detail-panel admin-form admin-contract-dialog"
role="dialog"
onSubmit={applyUnifiedCreationSpecForm}
>
<div className="admin-panel-heading">
<h3 id="admin-unified-creation-spec-editor-title">
</h3>
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={closeUnifiedCreationSpecForm}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<label className="admin-field">
<span></span>
<input
value={unifiedCreationSpecForm.title}
onChange={(event) =>
updateUnifiedCreationSpecForm({
title: event.target.value,
})
}
/>
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
min={1}
step={1}
type="number"
value={unifiedCreationSpecForm.mudPointCost}
onChange={(event) =>
updateUnifiedCreationSpecForm({
mudPointCost: event.target.value,
})
}
/>
</label>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span></span>
<button
className="admin-link-button"
type="button"
onClick={addUnifiedCreationSpecField}
>
<Plus size={15} aria-hidden="true" />
</button>
</div>
<div className="admin-contract-field-editor-list">
{unifiedCreationSpecForm.fields.map((field, index) => (
<section className="admin-contract-field-editor" key={field.id}>
<div className="admin-subsection-heading">
<span>{`字段 ${index + 1}`}</span>
<button
className="admin-link-button"
type="button"
aria-label={`删除字段 ${index + 1}`}
onClick={() => removeUnifiedCreationSpecField(index)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
<div className="admin-contract-field-editor-grid">
<label className="admin-field">
<span>{`字段 ${index + 1} ID`}</span>
<input
value={field.fieldId}
onChange={(event) =>
updateUnifiedCreationSpecField(index, {
fieldId: event.target.value,
})
}
/>
</label>
<label className="admin-field">
<span>{`字段 ${index + 1} 类型`}</span>
<select
value={field.kind}
onChange={(event) =>
updateUnifiedCreationSpecField(index, {
kind: event.target
.value as UnifiedCreationFieldPayload['kind'],
})
}
>
<option value="text">text</option>
<option value="select">select</option>
<option value="image">image</option>
<option value="audio">audio</option>
</select>
</label>
<label className="admin-field">
<span>{`字段 ${index + 1} 标签`}</span>
<input
value={field.label}
onChange={(event) =>
updateUnifiedCreationSpecField(index, {
label: event.target.value,
})
}
/>
</label>
<label className="admin-switch-field admin-contract-required-toggle">
<input
checked={field.required}
type="checkbox"
onChange={(event) =>
updateUnifiedCreationSpecField(index, {
required: event.target.checked,
})
}
/>
<span></span>
</label>
</div>
</section>
))}
</div>
</section>
{unifiedCreationSpecFormError ? (
<div className="admin-alert" role="status">
{unifiedCreationSpecFormError}
</div>
) : null}
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
type="button"
onClick={closeUnifiedCreationSpecForm}
>
</button>
<button className="admin-primary-button" type="submit">
</button>
</div>
</form>
</div>
) : null}
</section>
);
}
@@ -607,21 +966,100 @@ function escapeAnnouncementHtmlText(value: string): string {
.replaceAll('"', '&quot;');
}
function formatUnifiedCreationSpecJson(
spec: UnifiedCreationSpecPayload | null | undefined,
) {
return spec ? JSON.stringify(spec, null, 2) : '';
function nextUnifiedCreationSpecFieldFormItemId() {
unifiedCreationSpecFieldSequence += 1;
return `unified-field-${unifiedCreationSpecFieldSequence}`;
}
function parseUnifiedCreationSpecJson(entryId: string, value: string) {
const parsed = parseUnifiedCreationSpecSummaryJson(value);
if (!parsed.ok || !parsed.spec) {
return parsed;
function createUnifiedCreationSpecFieldFormItem(
field?: UnifiedCreationFieldPayload,
): UnifiedCreationSpecFieldFormItem {
return {
id: nextUnifiedCreationSpecFieldFormItemId(),
fieldId: field?.id ?? '',
kind: field?.kind ?? 'text',
label: field?.label ?? '',
required: field?.required ?? true,
};
}
if (parsed.spec.playId !== entryId) {
function buildUnifiedCreationSpecForm(
spec: UnifiedCreationSpecPayload | null,
entryId: string,
entryTitle: string,
): UnifiedCreationSpecFormState {
const playId = spec?.playId ?? entryId;
const stages = resolveUnifiedCreationSpecStages(spec, playId);
return {
playId,
title: spec?.title ?? entryTitle,
mudPointCost: String(normalizeMudPointCost(spec?.mudPointCost)),
...stages,
fields:
spec?.fields.map((field) => createUnifiedCreationSpecFieldFormItem(field)) ??
[createUnifiedCreationSpecFieldFormItem()],
};
}
function resolveUnifiedCreationSpecStages(
spec: UnifiedCreationSpecPayload | null,
playId: string,
): UnifiedCreationSpecStageState {
if (spec?.workspaceStage && spec.generationStage && spec.resultStage) {
return {
workspaceStage: spec.workspaceStage,
generationStage: spec.generationStage,
resultStage: spec.resultStage,
};
}
return (
DEFAULT_UNIFIED_CREATION_STAGE_MAP[playId] ?? {
workspaceStage: '',
generationStage: '',
resultStage: '',
}
);
}
function buildUnifiedCreationSpecFromForm(form: UnifiedCreationSpecFormState) {
return validateUnifiedCreationSpec({
playId: form.playId,
title: form.title,
mudPointCost: form.mudPointCost,
workspaceStage: form.workspaceStage,
generationStage: form.generationStage,
resultStage: form.resultStage,
fields: form.fields.map((field) => ({
id: field.fieldId,
kind: field.kind,
label: field.label,
required: field.required,
})),
});
}
function validateUnifiedCreationSpecForEntry(
entryId: string,
spec: UnifiedCreationSpecPayload | null,
) {
if (!spec) {
return {ok: true as const, spec: null};
}
if (spec.playId !== entryId) {
return {ok: false as const, message: '统一创作契约 playId 必须与入口 ID 一致'};
}
return parsed;
return validateUnifiedCreationSpec(spec);
}
function normalizeMudPointCost(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.trunc(value)
: 10;
}
function formatMudPointCostText(value: number | null | undefined) {
return `${normalizeMudPointCost(value)}泥点数`;
}
function validateUnifiedCreationSpec(value: unknown) {
@@ -635,11 +1073,18 @@ function validateUnifiedCreationSpec(value: unknown) {
}
const title = readRequiredString(value, 'title');
const mudPointCost = readPositiveInteger(value, 'mudPointCost');
const workspaceStage = readRequiredString(value, 'workspaceStage');
const generationStage = readRequiredString(value, 'generationStage');
const resultStage = readRequiredString(value, 'resultStage');
if (!title || !workspaceStage || !generationStage || !resultStage) {
return {ok: false as const, message: '统一创作契约标题和阶段不能为空'};
if (!title) {
return {ok: false as const, message: '统一创作契约标题不能为空'};
}
if (!workspaceStage || !generationStage || !resultStage) {
return {ok: false as const, message: '该玩法缺少默认阶段配置,请先由开发接入'};
}
if (mudPointCost === null) {
return {ok: false as const, message: '统一创作契约泥点消耗数量必须是大于 0 的整数'};
}
if (new Set([workspaceStage, generationStage, resultStage]).size !== 3) {
return {ok: false as const, message: '统一创作契约阶段不能重复'};
@@ -683,6 +1128,7 @@ function validateUnifiedCreationSpec(value: unknown) {
spec: {
playId,
title,
mudPointCost,
workspaceStage,
generationStage,
resultStage,
@@ -691,70 +1137,57 @@ function validateUnifiedCreationSpec(value: unknown) {
};
}
function UnifiedCreationSpecSummary({specJson}: {specJson: string}) {
const parsed = parseUnifiedCreationSpecSummaryJson(specJson);
if (!parsed.ok || !parsed.spec) {
return (
<div className="admin-alert" role="status">
{'message' in parsed ? parsed.message : '未配置统一创作页契约'}
</div>
);
}
function UnifiedCreationSpecCard({spec}: {spec: UnifiedCreationSpecPayload}) {
return (
<div className="admin-contract-card">
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{parsed.spec.playId}</dd>
<dd>{spec.playId}</dd>
</div>
<div>
<dt></dt>
<dd>{parsed.spec.title}</dd>
<dd>{spec.title}</dd>
</div>
<div>
<dt></dt>
<dd>
{parsed.spec.workspaceStage} / {parsed.spec.generationStage} /{' '}
{parsed.spec.resultStage}
</dd>
</div>
<div>
<dt></dt>
<dd>{parsed.spec.fields.map((field) => field.id).join('、')}</dd>
<dt></dt>
<dd>{formatMudPointCostText(spec.mudPointCost)}</dd>
</div>
</dl>
<div className="admin-contract-field-list">
{spec.fields.map((field) => (
<div className="admin-contract-field-card" key={field.id}>
<strong>{field.id}</strong>
<span>{field.label}</span>
<span>
{field.kind} / {field.required ? '必填' : '选填'}
</span>
</div>
))}
</div>
</div>
);
}
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<string, unknown>, key: string) {
const raw = value[key];
return typeof raw === 'string' ? raw.trim() : '';
}
function readPositiveInteger(value: Record<string, unknown>, 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

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;
}

View File

@@ -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`

View File

@@ -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]

View File

@@ -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', () => {

View File

@@ -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>

View File

@@ -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泥点数',
}),
]);
});

View File

@@ -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),

View File

@@ -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;