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

772 lines
24 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 { 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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'
);
}