收口创作入口契约后台表单
将统一创作契约泥点消耗改为数字字段并由前端格式化展示 将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单 隐藏玩法阶段等内部标识并按玩法默认映射自动带出 更新创作入口文档、团队记忆和回归测试
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 创作首屏开放态卡片不要再显示左上状态标签
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('"', '"');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
if (parsed.spec.playId !== entryId) {
|
||||
|
||||
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 (
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>玩法</dt>
|
||||
<dd>{parsed.spec.playId}</dd>
|
||||
<div className="admin-contract-card">
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>玩法</dt>
|
||||
<dd>{spec.playId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>表头</dt>
|
||||
<dd>{spec.title}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<dt>表头</dt>
|
||||
<dd>{parsed.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>
|
||||
</div>
|
||||
</dl>
|
||||
</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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
|
||||
|
||||
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `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,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `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 出现失效图片。
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
|
||||
|
||||
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单和表头由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,读取和保存时不再用入口名称自动覆盖;需要改表头时应直接修改后台契约 JSON 的 `title` 字段。`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*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||
统一创作入口覆盖当前可进入创作链路的已有模板:`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*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||
|
||||
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
入口配置中的 `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`。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user