Files
Genarrative/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
kdletters 17662916cd 收口创作入口契约后台表单
将统一创作契约泥点消耗改为数字字段并由前端格式化展示
将后台契约编辑从 JSON 文本改为结构化卡片与弹窗表单
隐藏玩法阶段等内部标识并按玩法默认映射自动带出
更新创作入口文档、团队记忆和回归测试
2026-06-07 23:53:31 +08:00

1205 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Pencil, Plus, RefreshCcw, Save, Trash2, X } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
} from '../api/adminApiClient';
import type {
AdminCreationEntryEventBannerPayload,
AdminCreationEntryTypeConfigPayload,
UnifiedCreationFieldPayload,
UnifiedCreationSpecPayload,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
/** 创作入口后台页面参数;公告模式只展示底部加号入口公告表单。 */
interface AdminCreationEntrySwitchPageProps {
token: string;
onUnauthorized: (message?: string) => void;
mode?: 'switches' | 'announcements';
}
/** 后台公告表单的一行编辑态,保存时会统一序列化为后端传输字段。 */
type AnnouncementFormItem = {
id: string;
title: string;
htmlCode: string;
};
/** 公告表单保存前的校验与序列化结果。 */
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,
onUnauthorized,
mode = 'switches',
}: AdminCreationEntrySwitchPageProps) {
const [entries, setEntries] = useState<
AdminCreationEntryTypeConfigPayload[]
>([]);
const [selectedId, setSelectedId] = useState('puzzle');
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
const [badge, setBadge] = useState('');
const [imageSrc, setImageSrc] = useState('');
const [visible, setVisible] = useState(true);
const [open, setOpen] = useState(true);
const [sortOrder, setSortOrder] = useState('30');
const [categoryId, setCategoryId] = useState('recommended');
const [categoryLabel, setCategoryLabel] = useState('热门推荐');
const [categorySortOrder, setCategorySortOrder] = useState('20');
const [unifiedCreationSpec, setUnifiedCreationSpec] =
useState<UnifiedCreationSpecPayload | null>(null);
const [unifiedCreationSpecForm, setUnifiedCreationSpecForm] =
useState<UnifiedCreationSpecFormState | null>(null);
const [unifiedCreationSpecFormError, setUnifiedCreationSpecFormError] =
useState('');
const [announcementItems, setAnnouncementItems] = useState<
AnnouncementFormItem[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSavingBanners, setIsSavingBanners] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [bannerErrorMessage, setBannerErrorMessage] = useState('');
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
const isAnnouncementMode = mode === 'announcements';
useEffect(() => {
void refreshEntries();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshEntries() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await getAdminCreationEntryConfig(token);
const nextEntries = sortEntries(response.entries);
setEntries(nextEntries);
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
fillForm(
nextEntries.find((entry) => entry.id === selectedId) ??
nextEntries[0] ??
null,
);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const targetId = selectedId.trim();
setErrorMessage('');
const unifiedCreationSpecResult = validateUnifiedCreationSpecForEntry(
targetId,
unifiedCreationSpec,
);
if (!unifiedCreationSpecResult.ok) {
setErrorMessage(unifiedCreationSpecResult.message);
return;
}
const confirmed = await confirmWrite({
action: '保存创作入口开关',
target: targetId,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertAdminCreationEntryConfig(token, {
id: targetId,
title: title.trim(),
subtitle: subtitle.trim(),
badge: badge.trim(),
imageSrc: imageSrc.trim(),
visible,
open,
sortOrder: parseInteger(sortOrder),
categoryId: categoryId.trim(),
categoryLabel: categoryLabel.trim(),
categorySortOrder: parseInteger(categorySortOrder),
unifiedCreationSpec: unifiedCreationSpecResult.spec,
});
const nextEntries = sortEntries(response.entries);
setEntries(nextEntries);
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
/** 保存底部加号创作入口页的多公告表单配置。 */
async function handleSaveBanners() {
if (isSavingBanners) {
return;
}
setBannerErrorMessage('');
const bannerJsonResult = buildEventBannersJsonFromForm(announcementItems);
if (!bannerJsonResult.ok) {
setBannerErrorMessage(bannerJsonResult.message);
return;
}
const confirmed = await confirmWrite({
action: '保存创作入口公告',
target: 'creation-entry-announcements',
});
if (!confirmed) {
return;
}
setIsSavingBanners(true);
try {
const response = await upsertAdminCreationEntryBanners(token, {
eventBannersJson: bannerJsonResult.json,
});
setEntries(sortEntries(response.entries));
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setBannerErrorMessage);
} finally {
setIsSavingBanners(false);
}
}
function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) {
if (!entry) {
return;
}
setSelectedId(entry.id);
setTitle(entry.title);
setSubtitle(entry.subtitle);
setBadge(entry.badge);
setImageSrc(entry.imageSrc);
setVisible(entry.visible);
setOpen(entry.open);
setSortOrder(String(entry.sortOrder));
setCategoryId(entry.categoryId);
setCategoryLabel(entry.categoryLabel);
setCategorySortOrder(String(entry.categorySortOrder));
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 编辑。 */
function updateAnnouncementItem(
index: number,
patch: Partial<Pick<AnnouncementFormItem, 'title' | 'htmlCode'>>,
) {
setAnnouncementItems((currentItems) =>
currentItems.map((item, itemIndex) =>
itemIndex === index ? { ...item, ...patch } : item,
),
);
}
/** 新增一条空公告表单行。 */
function addAnnouncementItem() {
setAnnouncementItems((currentItems) => [
...currentItems,
createAnnouncementFormItem('', ''),
]);
}
/** 删除指定公告表单行,至少保留一条空行供继续编辑。 */
function removeAnnouncementItem(index: number) {
setAnnouncementItems((currentItems) => {
const nextItems = currentItems.filter(
(_, itemIndex) => itemIndex !== index,
);
return nextItems.length > 0
? nextItems
: [createAnnouncementFormItem('', '')];
});
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>{isAnnouncementMode ? '创作入口公告' : '创作入口开关'}</h2>
<p>
{isAnnouncementMode
? '配置底部加号创作入口页的公告轮播'
: '控制创作中心入口展示与运行态路由可用性'}
</p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshEntries}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
{isAnnouncementMode ? (
<section className="admin-panel admin-form">
<div className="admin-subsection-heading">
<h3></h3>
<span>{announcementItems.length > 0 ? '已配置' : '未配置'}</span>
</div>
<div className="admin-form-actions">
<button
className="admin-secondary-button"
type="button"
onClick={addAnnouncementItem}
>
<Plus size={17} aria-hidden="true" />
<span></span>
</button>
</div>
{announcementItems.map((item, index) => (
<section className="admin-subsection" key={item.id}>
<div className="admin-subsection-heading">
<span>{`公告 ${index + 1}`}</span>
<button
className="admin-link-button"
type="button"
aria-label={`删除公告 ${index + 1}`}
onClick={() => removeAnnouncementItem(index)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
<label className="admin-field">
<span>{`公告 ${index + 1} 标题`}</span>
<input
value={item.title}
onChange={(event) =>
updateAnnouncementItem(index, { title: event.target.value })
}
/>
</label>
<label className="admin-field">
<span>{`公告 ${index + 1} HTML`}</span>
<textarea
rows={6}
value={item.htmlCode}
onChange={(event) =>
updateAnnouncementItem(index, {
htmlCode: event.target.value,
})
}
/>
</label>
</section>
))}
{bannerErrorMessage ? (
<div className="admin-alert" role="status">
{bannerErrorMessage}
</div>
) : null}
<div className="admin-form-actions">
<button
className="admin-secondary-button"
disabled={isSavingBanners}
type="button"
onClick={handleSaveBanners}
>
<Save size={17} aria-hidden="true" />
<span>{isSavingBanners ? '保存中' : '保存公告'}</span>
</button>
</div>
</section>
) : null}
{!isAnnouncementMode ? (
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span> ID</span>
<input
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={visible}
type="checkbox"
onChange={(event) => setVisible(event.target.checked)}
/>
<span></span>
</label>
<label className="admin-switch-field">
<input
checked={open}
type="checkbox"
onChange={(event) => setOpen(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={badge}
onChange={(event) => setBadge(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span></span>
<input
value={subtitle}
onChange={(event) => setSubtitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={imageSrc}
onChange={(event) => setImageSrc(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span> ID</span>
<input
value={categoryId}
onChange={(event) => setCategoryId(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={categoryLabel}
onChange={(event) => setCategoryLabel(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={categorySortOrder}
onChange={(event) => setCategorySortOrder(event.target.value)}
/>
</label>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span></span>
<span>
{unifiedCreationSpec ? '已配置' : '未配置'}
</span>
</div>
<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>
)}
</section>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-form-actions">
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存入库'}</span>
</button>
</div>
</form>
<section className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
<td>
<button
className="admin-link-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.id}
</button>
</td>
<td>{entry.visible ? '是' : '否'}</td>
<td>{entry.open ? '是' : '否'}</td>
<td>{entry.unifiedCreationSpec ? '是' : '否'}</td>
<td>{entry.categoryLabel || entry.categoryId}</td>
<td>{entry.sortOrder}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
) : 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>
);
}
function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) {
return [...entries].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.id.localeCompare(right.id);
});
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return 0;
}
return parsed;
}
/** 为公告表单行生成稳定 key避免编辑时 React 重建输入框。 */
function nextAnnouncementFormItemId() {
announcementFormItemSequence += 1;
return `announcement-${announcementFormItemSequence}`;
}
/** 创建公告表单行,后台返回为空时也提供一条可编辑空行。 */
function createAnnouncementFormItem(
title: string,
htmlCode: string,
): AnnouncementFormItem {
return {
id: nextAnnouncementFormItemId(),
title,
htmlCode,
};
}
/** 把后台公告快照转换成表单行;旧结构化 banner 会降级成 HTML 公告片段。 */
function formatEventBannersFormItems(
banners: AdminCreationEntryEventBannerPayload[] | null | undefined,
): AnnouncementFormItem[] {
const formItems = (banners ?? []).map((banner) =>
createAnnouncementFormItem(
banner.title,
banner.renderMode === 'html' && banner.htmlCode
? banner.htmlCode
: buildStructuredAnnouncementHtml(banner),
),
);
return formItems.length > 0
? formItems
: [createAnnouncementFormItem('', '')];
}
/** 保存前把公告表单序列化为后端传输字段,并先做必填校验。 */
function buildEventBannersJsonFromForm(
items: AnnouncementFormItem[],
): AnnouncementFormBuildResult {
const banners = items.map((item) => ({
title: item.title.trim(),
htmlCode: item.htmlCode.trim(),
}));
if (banners.length === 0) {
return {ok: false as const, message: '至少需要一条公告'};
}
const emptyIndex = banners.findIndex(
(banner) => !banner.title || !banner.htmlCode,
);
if (emptyIndex >= 0) {
return {
ok: false as const,
message: `公告 ${emptyIndex + 1} 标题和 HTML 都不能为空`,
};
}
return {ok: true as const, json: JSON.stringify(banners, null, 2)};
}
/** 将旧结构化公告字段转成可编辑 HTML避免后台表单丢失历史公告内容。 */
function buildStructuredAnnouncementHtml(
banner: AdminCreationEntryEventBannerPayload,
): string {
return `<section><h1>${escapeAnnouncementHtmlText(
banner.title,
)}</h1><p>${escapeAnnouncementHtmlText(banner.description)}</p></section>`;
}
/** 转义旧结构化文案,确保迁移到 HTML 表单时不会意外变成标签。 */
function escapeAnnouncementHtmlText(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function nextUnifiedCreationSpecFieldFormItemId() {
unifiedCreationSpecFieldSequence += 1;
return `unified-field-${unifiedCreationSpecFieldSequence}`;
}
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,
};
}
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 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) {
if (!isRecord(value)) {
return {ok: false as const, message: '统一创作契约必须是对象'};
}
const playId = readRequiredString(value, 'playId');
if (!playId) {
return {ok: false as const, message: '统一创作契约 playId 不能为空'};
}
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) {
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: '统一创作契约阶段不能重复'};
}
if (!Array.isArray(value.fields) || value.fields.length === 0) {
return {ok: false as const, message: '统一创作契约 fields 不能为空'};
}
const fieldIds = new Set<string>();
const fields: UnifiedCreationFieldPayload[] = [];
for (const item of value.fields) {
if (!isRecord(item)) {
return {ok: false as const, message: '统一创作契约字段必须是对象'};
}
const id = readRequiredString(item, 'id');
const label = readRequiredString(item, 'label');
if (!id || !label) {
return {ok: false as const, message: '统一创作契约字段 id 和 label 不能为空'};
}
if (fieldIds.has(id)) {
return {ok: false as const, message: `统一创作契约字段 id 重复:${id}`};
}
fieldIds.add(id);
if (!isUnifiedCreationFieldKind(item.kind)) {
return {ok: false as const, message: `统一创作契约字段 kind 非法:${id}`};
}
if (typeof item.required !== 'boolean') {
return {ok: false as const, message: `统一创作契约字段 required 非法:${id}`};
}
fields.push({
id,
kind: item.kind,
label,
required: item.required,
});
}
return {
ok: true as const,
spec: {
playId,
title,
mudPointCost,
workspaceStage,
generationStage,
resultStage,
fields,
},
};
}
function UnifiedCreationSpecCard({spec}: {spec: UnifiedCreationSpecPayload}) {
return (
<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>
);
}
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);
}
function isUnifiedCreationFieldKind(
value: unknown,
): value is UnifiedCreationFieldPayload['kind'] {
return (
value === 'text' ||
value === 'select' ||
value === 'image' ||
value === 'audio'
);
}