feat: 支持创作入口公告配置
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDebugHttpRequest,
|
||||
@@ -197,6 +198,21 @@ export function upsertAdminCreationEntryConfig(
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存创作入口公告表单序列化后的后端传输字段。 */
|
||||
export function upsertAdminCreationEntryBanners(
|
||||
token: string,
|
||||
payload: AdminUpsertCreationEntryEventBannersRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config/banners',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminWorkVisibility(token: string) {
|
||||
return request<AdminWorkVisibilityListResponse>(
|
||||
'/admin/api/works/visibility',
|
||||
|
||||
@@ -144,10 +144,25 @@ export interface AdminTrackingEventListQuery {
|
||||
}
|
||||
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
eventBanners: AdminCreationEntryEventBannerPayload[];
|
||||
}
|
||||
|
||||
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
|
||||
export interface AdminCreationEntryEventBannerPayload {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
renderMode: 'structured' | 'html';
|
||||
htmlCode?: string | null;
|
||||
}
|
||||
|
||||
/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */
|
||||
export interface AdminCreationEntryTypeConfigPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -164,6 +179,7 @@ export interface AdminCreationEntryTypeConfigPayload {
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作模板入口开关与统一创作契约的请求体。 */
|
||||
export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -179,6 +195,13 @@ export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
|
||||
}
|
||||
|
||||
/** 后台保存创作入口公告表单序列化结果的请求体。 */
|
||||
export interface AdminUpsertCreationEntryEventBannersRequest {
|
||||
/** 传输字段沿用后端契约,内容由后台表单生成。 */
|
||||
eventBannersJson: string;
|
||||
}
|
||||
|
||||
/** 后台统一创作工作台契约表单的传输结构。 */
|
||||
export interface UnifiedCreationSpecPayload {
|
||||
playId: string;
|
||||
title: string;
|
||||
@@ -188,6 +211,7 @@ export interface UnifiedCreationSpecPayload {
|
||||
fields: UnifiedCreationFieldPayload[];
|
||||
}
|
||||
|
||||
/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */
|
||||
export interface UnifiedCreationFieldPayload {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
|
||||
@@ -200,6 +200,13 @@ export function AdminApp() {
|
||||
onResultChange={setInviteResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'creation-announcement' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'creation-entry' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
token={token}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BadgeDollarSign,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
@@ -35,6 +36,7 @@ const routeIcons = {
|
||||
invite: TicketCheck,
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'creation-announcement': Megaphone,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
16
apps/admin-web/src/app/adminRoutes.test.ts
Normal file
16
apps/admin-web/src/app/adminRoutes.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {expect, test} from 'vitest';
|
||||
|
||||
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
|
||||
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
|
||||
test('后台入口公告路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'creation-announcement',
|
||||
label: '入口公告',
|
||||
hash: '#creation-announcement',
|
||||
});
|
||||
expect(resolveAdminRoute('#creation-announcement')).toBe(
|
||||
'creation-announcement',
|
||||
);
|
||||
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
|
||||
export type AdminRouteId =
|
||||
| 'overview'
|
||||
| 'tables'
|
||||
@@ -7,9 +8,11 @@ export type AdminRouteId =
|
||||
| 'invite'
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'creation-announcement'
|
||||
| 'creation-entry'
|
||||
| 'work-visibility';
|
||||
|
||||
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
label: string;
|
||||
@@ -25,10 +28,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
];
|
||||
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
|
||||
return (
|
||||
@@ -37,6 +42,7 @@ export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据后台路由标识反查 hash,供导航点击时同步地址栏。 */
|
||||
export function routeHash(routeId: AdminRouteId) {
|
||||
return (
|
||||
adminRoutes.find((route) => route.id === routeId)?.hash ??
|
||||
|
||||
@@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryBanners,
|
||||
upsertAdminCreationEntryConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminCreationEntryBanners: vi.fn(),
|
||||
upsertAdminCreationEntryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -40,6 +42,18 @@ const puzzleSpec: UnifiedCreationSpecPayload = {
|
||||
};
|
||||
|
||||
const configResponse: AdminCreationEntryConfigResponse = {
|
||||
eventBanners: [
|
||||
{
|
||||
title: '创作公告',
|
||||
description: '',
|
||||
coverImageSrc: '',
|
||||
prizePoolMudPoints: 0,
|
||||
startsAtText: '',
|
||||
endsAtText: '',
|
||||
renderMode: 'html',
|
||||
htmlCode: '<section>后台公告</section>',
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
@@ -50,9 +64,9 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: puzzleSpec,
|
||||
},
|
||||
@@ -62,6 +76,7 @@ const configResponse: AdminCreationEntryConfigResponse = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
});
|
||||
|
||||
@@ -93,7 +108,10 @@ test('创作入口后台展示并保存统一创作契约', async () => {
|
||||
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = await screen.findByLabelText('契约 JSON');
|
||||
@@ -110,3 +128,109 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
|
||||
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('创作入口后台用表单保存公告配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
|
||||
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: '周末创作赛'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: '<section>新的入口公告</section>'},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '新增公告'}));
|
||||
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
||||
target: {value: '第二条公告'},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
||||
target: {value: '<section>轮播第二条</section>'},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
||||
});
|
||||
const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!;
|
||||
expect(JSON.parse(payload.eventBannersJson)).toEqual([
|
||||
{
|
||||
title: '周末创作赛',
|
||||
htmlCode: '<section>新的入口公告</section>',
|
||||
},
|
||||
{
|
||||
title: '第二条公告',
|
||||
htmlCode: '<section>轮播第二条</section>',
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'description',
|
||||
);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'coverImageSrc',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||
...configResponse,
|
||||
eventBanners: [
|
||||
{
|
||||
title: '旧公告 <标题>',
|
||||
description: '旧描述 & 需要转义',
|
||||
coverImageSrc: '/legacy.png',
|
||||
prizePoolMudPoints: 120,
|
||||
startsAtText: '2026-06-01',
|
||||
endsAtText: '2026-06-30',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty(
|
||||
'value',
|
||||
'旧公告 <标题>',
|
||||
);
|
||||
expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty(
|
||||
'value',
|
||||
'<section><h1>旧公告 <标题></h1><p>旧描述 & 需要转义</p></section>',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台拒绝空公告表单', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: {value: ''},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: {value: ''},
|
||||
});
|
||||
await user.click(screen.getByRole('button', {name: '保存公告'}));
|
||||
|
||||
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
import {RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useState} from 'react';
|
||||
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';
|
||||
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 [entries, setEntries] = useState<
|
||||
AdminCreationEntryTypeConfigPayload[]
|
||||
>([]);
|
||||
const [selectedId, setSelectedId] = useState('puzzle');
|
||||
const [title, setTitle] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
@@ -31,15 +52,21 @@ export function AdminCreationEntrySwitchPage({
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [sortOrder, setSortOrder] = useState('30');
|
||||
const [categoryId, setCategoryId] = useState('recent');
|
||||
const [categoryLabel, setCategoryLabel] = useState('最近创作');
|
||||
const [categorySortOrder, setCategorySortOrder] = useState('10');
|
||||
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 {confirmWrite, confirmDialog} = useAdminWriteConfirm();
|
||||
const [bannerErrorMessage, setBannerErrorMessage] = useState('');
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
const isAnnouncementMode = mode === 'announcements';
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEntries();
|
||||
@@ -53,8 +80,11 @@ export function AdminCreationEntrySwitchPage({
|
||||
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,
|
||||
nextEntries.find((entry) => entry.id === selectedId) ??
|
||||
nextEntries[0] ??
|
||||
null,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setListErrorMessage);
|
||||
@@ -105,6 +135,7 @@ export function AdminCreationEntrySwitchPage({
|
||||
});
|
||||
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);
|
||||
@@ -113,6 +144,40 @@ export function AdminCreationEntrySwitchPage({
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存底部加号创作入口页的多公告表单配置。 */
|
||||
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;
|
||||
@@ -128,15 +193,53 @@ export function AdminCreationEntrySwitchPage({
|
||||
setCategoryId(entry.categoryId);
|
||||
setCategoryLabel(entry.categoryLabel);
|
||||
setCategorySortOrder(String(entry.categorySortOrder));
|
||||
setUnifiedCreationSpecJson(formatUnifiedCreationSpecJson(entry.unifiedCreationSpec));
|
||||
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>创作入口开关</h2>
|
||||
<p>控制创作中心入口展示与运行态路由可用性</p>
|
||||
<h2>{isAnnouncementMode ? '创作入口公告' : '创作入口开关'}</h2>
|
||||
<p>
|
||||
{isAnnouncementMode
|
||||
? '配置底部加号创作入口页的公告轮播'
|
||||
: '控制创作中心入口展示与运行态路由可用性'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
@@ -155,161 +258,255 @@ export function AdminCreationEntrySwitchPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
{isAnnouncementMode ? (
|
||||
<section className="admin-panel admin-form">
|
||||
<div className="admin-subsection-heading">
|
||||
<h3>创作入口公告</h3>
|
||||
<span>{announcementItems.length > 0 ? '已配置' : '未配置'}</span>
|
||||
</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
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={addAnnouncementItem}
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
<span>新增公告</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>
|
||||
{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>
|
||||
</div>
|
||||
) : 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>
|
||||
@@ -333,6 +530,83 @@ function parseInteger(value: string) {
|
||||
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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user