772 lines
24 KiB
TypeScript
772 lines
24 KiB
TypeScript
import { Plus, RefreshCcw, Save, Trash2 } 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 };
|
|
|
|
let announcementFormItemSequence = 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 [unifiedCreationSpecJson, setUnifiedCreationSpecJson] = 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 = parseUnifiedCreationSpecJson(
|
|
targetId,
|
|
unifiedCreationSpecJson,
|
|
);
|
|
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));
|
|
setUnifiedCreationSpecJson(
|
|
formatUnifiedCreationSpecJson(entry.unifiedCreationSpec),
|
|
);
|
|
}
|
|
|
|
/** 更新单条公告表单字段,避免后台页面直接暴露 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>
|
|
{unifiedCreationSpecJson.trim() ? '已配置' : '未配置'}
|
|
</span>
|
|
</div>
|
|
{unifiedCreationSpecJson.trim() ? (
|
|
<UnifiedCreationSpecSummary specJson={unifiedCreationSpecJson} />
|
|
) : (
|
|
<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 ? (
|
|
<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}
|
|
</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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
function formatUnifiedCreationSpecJson(
|
|
spec: UnifiedCreationSpecPayload | null | undefined,
|
|
) {
|
|
return spec ? JSON.stringify(spec, null, 2) : '';
|
|
}
|
|
|
|
function parseUnifiedCreationSpecJson(entryId: string, value: string) {
|
|
const parsed = parseUnifiedCreationSpecSummaryJson(value);
|
|
if (!parsed.ok || !parsed.spec) {
|
|
return parsed;
|
|
}
|
|
if (parsed.spec.playId !== entryId) {
|
|
return {ok: false as const, message: '统一创作契约 playId 必须与入口 ID 一致'};
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
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 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 (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,
|
|
workspaceStage,
|
|
generationStage,
|
|
resultStage,
|
|
fields,
|
|
},
|
|
};
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<dl className="admin-info-list">
|
|
<div>
|
|
<dt>玩法</dt>
|
|
<dd>{parsed.spec.playId}</dd>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 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'
|
|
);
|
|
}
|