Files
Genarrative/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
T

1417 lines
46 KiB
TypeScript

import { Pencil, Plus, RefreshCcw, Save, Trash2, X } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
upsertAdminPublicWorkInteractions,
} from '../api/adminApiClient';
import type {
AdminCreationEntryEventBannerPayload,
AdminCreationEntryTypeConfigPayload,
PublicWorkInteractionConfigPayload,
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;
const PUBLIC_WORK_SOURCE_LABELS: Record<string, string> = {
'custom-world': 'RPG',
'big-fish': '摸鱼',
puzzle: '拼图',
'puzzle-clear': '拼消消',
'jump-hop': '跳一跳',
'wooden-fish': '敲木鱼',
match3d: '抓大鹅',
'square-hole': '方洞挑战',
'visual-novel': '视觉小说',
'bark-battle': '汪汪声浪',
edutainment: '宝贝识物',
};
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 [publicWorkInteractions, setPublicWorkInteractions] = useState<
PublicWorkInteractionConfigPayload[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSavingBanners, setIsSavingBanners] = useState(false);
const [isSavingInteractions, setIsSavingInteractions] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [bannerErrorMessage, setBannerErrorMessage] = useState('');
const [interactionErrorMessage, setInteractionErrorMessage] = 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));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
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));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
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));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setBannerErrorMessage);
} finally {
setIsSavingBanners(false);
}
}
/** 保存公开作品详情页点赞 / 改造能力开关。 */
async function handleSavePublicWorkInteractions() {
if (isSavingInteractions) {
return;
}
setInteractionErrorMessage('');
const confirmed = await confirmWrite({
action: '保存作品互动配置',
target: 'public-work-interactions',
});
if (!confirmed) {
return;
}
setIsSavingInteractions(true);
try {
const response = await upsertAdminPublicWorkInteractions(token, {
publicWorkInteractions: publicWorkInteractions.map((item) => ({
...item,
sourceType: item.sourceType.trim(),
likeDisabledMessage: item.likeDisabledMessage.trim(),
remixDisabledMessage: item.remixDisabledMessage.trim(),
})),
});
setEntries(sortEntries(response.entries));
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setInteractionErrorMessage);
} finally {
setIsSavingInteractions(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('', '')];
});
}
/** 更新单条公开作品互动配置。 */
function updatePublicWorkInteraction(
index: number,
patch: Partial<
Pick<
PublicWorkInteractionConfigPayload,
| 'likeEnabled'
| 'remixEnabled'
| 'likeDisabledMessage'
| 'remixDisabledMessage'
>
>,
) {
setPublicWorkInteractions((currentItems) =>
currentItems.map((item, itemIndex) =>
itemIndex === index ? { ...item, ...patch } : item,
),
);
}
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 ? (
<>
<section className="admin-panel admin-form">
<div className="admin-subsection-heading">
<h3>作品互动</h3>
<span>{`${publicWorkInteractions.length} 类`}</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>作品类型</th>
<th>点赞</th>
<th>点赞关闭提示</th>
<th>改造</th>
<th>改造关闭提示</th>
</tr>
</thead>
<tbody>
{publicWorkInteractions.map((item, index) => (
<tr key={item.sourceType}>
<td>{formatPublicWorkSourceLabel(item.sourceType)}</td>
<td>
<label className="admin-switch-field">
<input
checked={item.likeEnabled}
type="checkbox"
onChange={(event) =>
updatePublicWorkInteraction(index, {
likeEnabled: event.target.checked,
})
}
/>
<span>{item.likeEnabled ? '开' : '关'}</span>
</label>
</td>
<td>
<input
aria-label={`${formatPublicWorkSourceLabel(
item.sourceType,
)} 点赞关闭提示`}
value={item.likeDisabledMessage}
onChange={(event) =>
updatePublicWorkInteraction(index, {
likeDisabledMessage: event.target.value,
})
}
/>
</td>
<td>
<label className="admin-switch-field">
<input
checked={item.remixEnabled}
type="checkbox"
onChange={(event) =>
updatePublicWorkInteraction(index, {
remixEnabled: event.target.checked,
})
}
/>
<span>{item.remixEnabled ? '开' : '关'}</span>
</label>
</td>
<td>
<input
aria-label={`${formatPublicWorkSourceLabel(
item.sourceType,
)} 改造关闭提示`}
value={item.remixDisabledMessage}
onChange={(event) =>
updatePublicWorkInteraction(index, {
remixDisabledMessage: event.target.value,
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{interactionErrorMessage ? (
<div className="admin-alert" role="status">
{interactionErrorMessage}
</div>
) : null}
<div className="admin-form-actions">
<button
className="admin-secondary-button"
disabled={isSavingInteractions}
type="button"
onClick={handleSavePublicWorkInteractions}
>
<Save size={17} aria-hidden="true" />
<span>{isSavingInteractions ? '保存中' : '保存作品互动'}</span>
</button>
</div>
</section>
<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 formatPublicWorkSourceLabel(sourceType: string) {
const label = PUBLIC_WORK_SOURCE_LABELS[sourceType];
return label ? `${label} / ${sourceType}` : sourceType;
}
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'
);
}