点赞和改造开关加入后台配置

This commit is contained in:
2026-06-10 14:36:56 +08:00
parent 9db467d23f
commit e29992cf01
33 changed files with 1644 additions and 380 deletions

View File

@@ -20,6 +20,7 @@ import type {
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
ApiErrorEnvelope,
ApiMeta,
@@ -129,16 +130,16 @@ export async function request<T>(
export function loginAdmin(username: string, password: string) {
return request<AdminLoginResponse>('/admin/api/login', {
method: 'POST',
body: {username, password},
body: { username, password },
});
}
export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', {token});
return request<AdminMeResponse>('/admin/api/me', { token });
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', {token});
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
export function getAdminDatabaseTables(token: string) {
@@ -154,7 +155,7 @@ export function getAdminDatabaseTableRows(
) {
return request<AdminDatabaseTableRowsResponse>(
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
{token},
{ token },
);
}
@@ -172,15 +173,14 @@ export function listAdminTrackingEvents(
) {
return request<AdminTrackingEventListResponse>(
`/admin/api/tracking/events${buildQueryString(query)}`,
{token},
{ token },
);
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
{token},
{ token },
);
}
@@ -213,10 +213,25 @@ export function upsertAdminCreationEntryBanners(
);
}
/** 保存公开作品详情页点赞 / 改造能力配置。 */
export function upsertAdminPublicWorkInteractions(
token: string,
payload: AdminUpsertPublicWorkInteractionConfigRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config/interactions',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listAdminWorkVisibility(token: string) {
return request<AdminWorkVisibilityListResponse>(
'/admin/api/works/visibility',
{token},
{ token },
);
}
@@ -237,7 +252,7 @@ export function updateAdminWorkVisibility(
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
{token},
{ token },
);
}
@@ -258,7 +273,7 @@ export function upsertProfileRedeemCode(
export function listProfileInviteCodes(token: string) {
return request<ProfileInviteCodeAdminListResponse>(
'/admin/api/profile/invite-codes',
{token},
{ token },
);
}
@@ -293,7 +308,7 @@ export function disableProfileRedeemCode(
export function listProfileTaskConfigs(token: string) {
return request<ProfileTaskConfigAdminListResponse>(
'/admin/api/profile/tasks',
{token},
{ token },
);
}
@@ -325,7 +340,7 @@ export function disableProfileTaskConfig(
export function listProfileRechargeProducts(token: string) {
return request<ProfileRechargeProductConfigAdminListResponse>(
'/admin/api/profile/recharge-products',
{token},
{ token },
);
}
@@ -414,13 +429,13 @@ function buildAdminApiError(
) {
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
const errorPayload = envelope?.error;
const details = isRecord(errorPayload?.details)
? errorPayload.details
: null;
const details = isRecord(errorPayload?.details) ? errorPayload.details : null;
const detailsMessage =
typeof details?.message === 'string' ? details.message.trim() : '';
const payloadMessage =
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
typeof errorPayload?.message === 'string'
? errorPayload.message.trim()
: '';
const topLevelMessage =
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
const message =

View File

@@ -107,12 +107,7 @@ export interface AdminDebugHeaderInput {
value: string;
}
export type AdminDebugHttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE';
export type AdminDebugHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface AdminDebugHttpRequest {
method: AdminDebugHttpMethod;
@@ -143,11 +138,11 @@ export interface AdminTrackingEventListQuery {
limit?: number;
}
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
export interface AdminCreationEntryConfigResponse {
entries: AdminCreationEntryTypeConfigPayload[];
eventBanners: AdminCreationEntryEventBannerPayload[];
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
}
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
@@ -201,6 +196,20 @@ export interface AdminUpsertCreationEntryEventBannersRequest {
eventBannersJson: string;
}
/** 后台公开作品详情页互动能力配置项。 */
export interface PublicWorkInteractionConfigPayload {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
}
/** 后台保存公开作品点赞 / 改造能力配置请求体。 */
export interface AdminUpsertPublicWorkInteractionConfigRequest {
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
}
/** 后台统一创作工作台契约表单的传输结构。 */
export interface UnifiedCreationSpecPayload {
playId: string;

View File

@@ -1,19 +1,26 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
upsertAdminPublicWorkInteractions,
} from '../api/adminApiClient';
import type {
AdminCreationEntryConfigResponse,
UnifiedCreationSpecPayload,
} from '../api/adminApiTypes';
import {AdminCreationEntrySwitchPage} from './AdminCreationEntrySwitchPage';
import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -23,6 +30,7 @@ vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(() => false),
upsertAdminCreationEntryBanners: vi.fn(),
upsertAdminCreationEntryConfig: vi.fn(),
upsertAdminPublicWorkInteractions: vi.fn(),
}));
const puzzleSpec: UnifiedCreationSpecPayload = {
@@ -55,6 +63,15 @@ const configResponse: AdminCreationEntryConfigResponse = {
htmlCode: '<section>后台公告</section>',
},
],
publicWorkInteractions: [
{
sourceType: 'puzzle',
likeEnabled: true,
remixEnabled: true,
likeDisabledMessage: '拼图点赞暂不可用。',
remixDisabledMessage: '拼图作品改造暂不可用。',
},
],
entries: [
{
id: 'puzzle',
@@ -79,16 +96,24 @@ beforeEach(() => {
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue(
configResponse,
);
});
test('创作入口后台展示并保存统一创作契约', async () => {
const user = userEvent.setup();
const {container} = render(
<AdminCreationEntrySwitchPage token="admin-token" onUnauthorized={vi.fn()} />,
const { container } = render(
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('pictureDescription');
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list'),
).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
).toContain('拼图');
@@ -97,22 +122,22 @@ test('创作入口后台展示并保存统一创作契约', async () => {
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
expect(screen.queryByText('puzzle-generating')).toBeNull();
await user.click(screen.getByRole('button', {name: '修改契约'}));
const dialog = screen.getByRole('dialog', {name: '统一创作契约'});
await user.click(screen.getByRole('button', { name: '修改契约' }));
const dialog = screen.getByRole('dialog', { name: '统一创作契约' });
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
target: {value: '12'},
target: { value: '12' },
});
await user.click(within(dialog).getByRole('button', {name: '应用修改'}));
await user.click(within(dialog).getByRole('button', { name: '应用修改' }));
expect(screen.queryByRole('dialog', {name: '统一创作契约'})).toBeNull();
expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull();
expect(screen.getByText('12泥点数')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '保存入库'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await user.click(screen.getByRole('button', { name: '保存入库' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
@@ -150,9 +175,11 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async ()
);
await screen.findByText('pictureDescription');
await user.click(screen.getByRole('button', {name: '保存入库'}));
await user.click(screen.getByRole('button', { name: '保存入库' }));
expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy();
expect(
await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'),
).toBeTruthy();
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
});
@@ -166,23 +193,25 @@ test('创作入口后台用表单保存公告配置', async () => {
/>,
);
expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2);
expect(
await screen.findAllByRole('heading', { name: '创作入口公告' }),
).toHaveLength(2);
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: {value: '周末创作赛'},
target: { value: '周末创作赛' },
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: {value: '<section>新的入口公告</section>'},
target: { value: '<section>新的入口公告</section>' },
});
await user.click(screen.getByRole('button', {name: '新增公告'}));
await user.click(screen.getByRole('button', { name: '新增公告' }));
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
target: {value: '第二条公告'},
target: { value: '第二条公告' },
});
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
target: {value: '<section>轮播第二条</section>'},
target: { value: '<section>轮播第二条</section>' },
});
await user.click(screen.getByRole('button', {name: '保存公告'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await user.click(screen.getByRole('button', { name: '保存公告' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
@@ -206,6 +235,42 @@ test('创作入口后台用表单保存公告配置', async () => {
);
});
test('创作入口后台用表单保存作品互动配置', async () => {
const user = userEvent.setup();
render(
<AdminCreationEntrySwitchPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('作品互动');
const likeToggle = screen.getAllByRole('checkbox')[0]!;
await user.click(likeToggle);
fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), {
target: { value: '拼图点赞维护中。' },
});
await user.click(screen.getByRole('button', { name: '保存作品互动' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith(
'admin-token',
{
publicWorkInteractions: [
{
sourceType: 'puzzle',
likeEnabled: false,
remixEnabled: true,
likeDisabledMessage: '拼图点赞维护中。',
remixDisabledMessage: '拼图作品改造暂不可用。',
},
],
},
);
});
});
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
...configResponse,
@@ -251,12 +316,12 @@ test('创作入口后台拒绝空公告表单', async () => {
);
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
target: {value: ''},
target: { value: '' },
});
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
target: {value: ''},
target: { value: '' },
});
await user.click(screen.getByRole('button', {name: '保存公告'}));
await user.click(screen.getByRole('button', { name: '保存公告' }));
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();

View File

@@ -5,10 +5,12 @@ import {
getAdminCreationEntryConfig,
upsertAdminCreationEntryBanners,
upsertAdminCreationEntryConfig,
upsertAdminPublicWorkInteractions,
} from '../api/adminApiClient';
import type {
AdminCreationEntryEventBannerPayload,
AdminCreationEntryTypeConfigPayload,
PublicWorkInteractionConfigPayload,
UnifiedCreationFieldPayload,
UnifiedCreationSpecPayload,
} from '../api/adminApiTypes';
@@ -129,14 +131,28 @@ const DEFAULT_UNIFIED_CREATION_STAGE_MAP: Record<
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 [entries, setEntries] = useState<AdminCreationEntryTypeConfigPayload[]>(
[],
);
const [selectedId, setSelectedId] = useState('puzzle');
const [title, setTitle] = useState('');
const [subtitle, setSubtitle] = useState('');
@@ -157,12 +173,17 @@ export function AdminCreationEntrySwitchPage({
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';
@@ -179,6 +200,7 @@ export function AdminCreationEntrySwitchPage({
const nextEntries = sortEntries(response.entries);
setEntries(nextEntries);
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
fillForm(
nextEntries.find((entry) => entry.id === selectedId) ??
nextEntries[0] ??
@@ -234,6 +256,7 @@ export function AdminCreationEntrySwitchPage({
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);
@@ -269,6 +292,7 @@ export function AdminCreationEntrySwitchPage({
});
setEntries(sortEntries(response.entries));
setAnnouncementItems(formatEventBannersFormItems(response.eventBanners));
setPublicWorkInteractions(response.publicWorkInteractions ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setBannerErrorMessage);
} finally {
@@ -276,6 +300,41 @@ export function AdminCreationEntrySwitchPage({
}
}
/** 保存公开作品详情页点赞 / 改造能力开关。 */
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;
@@ -361,7 +420,10 @@ export function AdminCreationEntrySwitchPage({
currentForm
? {
...currentForm,
fields: [...currentForm.fields, createUnifiedCreationSpecFieldFormItem()],
fields: [
...currentForm.fields,
createUnifiedCreationSpecFieldFormItem(),
],
}
: currentForm,
);
@@ -377,7 +439,10 @@ export function AdminCreationEntrySwitchPage({
);
return {
...currentForm,
fields: fields.length > 0 ? fields : [createUnifiedCreationSpecFieldFormItem()],
fields:
fields.length > 0
? fields
: [createUnifiedCreationSpecFieldFormItem()],
};
});
}
@@ -414,6 +479,26 @@ export function AdminCreationEntrySwitchPage({
});
}
/** 更新单条公开作品互动配置。 */
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">
@@ -515,191 +600,288 @@ export function AdminCreationEntrySwitchPage({
) : 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>
<>
<section className="admin-panel admin-form">
<div className="admin-subsection-heading">
<h3></h3>
<span>{`${publicWorkInteractions.length}`}</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>
{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>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id}>
{publicWorkInteractions.map((item, index) => (
<tr key={item.sourceType}>
<td>{formatPublicWorkSourceLabel(item.sourceType)}</td>
<td>
<button
className="admin-link-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.id}
</button>
<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>
<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>
{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>
<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}
@@ -776,7 +958,10 @@ export function AdminCreationEntrySwitchPage({
</div>
<div className="admin-contract-field-editor-list">
{unifiedCreationSpecForm.fields.map((field, index) => (
<section className="admin-contract-field-editor" key={field.id}>
<section
className="admin-contract-field-editor"
key={field.id}
>
<div className="admin-subsection-heading">
<span>{`字段 ${index + 1}`}</span>
<button
@@ -881,6 +1066,11 @@ function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) {
});
}
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)) {
@@ -933,7 +1123,7 @@ function buildEventBannersJsonFromForm(
htmlCode: item.htmlCode.trim(),
}));
if (banners.length === 0) {
return {ok: false as const, message: '至少需要一条公告'};
return { ok: false as const, message: '至少需要一条公告' };
}
const emptyIndex = banners.findIndex(
(banner) => !banner.title || !banner.htmlCode,
@@ -945,7 +1135,7 @@ function buildEventBannersJsonFromForm(
};
}
return {ok: true as const, json: JSON.stringify(banners, null, 2)};
return { ok: true as const, json: JSON.stringify(banners, null, 2) };
}
/** 将旧结构化公告字段转成可编辑 HTML避免后台表单丢失历史公告内容。 */
@@ -995,9 +1185,9 @@ function buildUnifiedCreationSpecForm(
title: spec?.title ?? entryTitle,
mudPointCost: String(normalizeMudPointCost(spec?.mudPointCost)),
...stages,
fields:
spec?.fields.map((field) => createUnifiedCreationSpecFieldFormItem(field)) ??
[createUnifiedCreationSpecFieldFormItem()],
fields: spec?.fields.map((field) =>
createUnifiedCreationSpecFieldFormItem(field),
) ?? [createUnifiedCreationSpecFieldFormItem()],
};
}
@@ -1044,10 +1234,13 @@ function validateUnifiedCreationSpecForEntry(
spec: UnifiedCreationSpecPayload | null,
) {
if (!spec) {
return {ok: true as const, spec: null};
return { ok: true as const, spec: null };
}
if (spec.playId !== entryId) {
return {ok: false as const, message: '统一创作契约 playId 必须与入口 ID 一致'};
return {
ok: false as const,
message: '统一创作契约 playId 必须与入口 ID 一致',
};
}
return validateUnifiedCreationSpec(spec);
}
@@ -1064,12 +1257,12 @@ function formatMudPointCostText(value: number | null | undefined) {
function validateUnifiedCreationSpec(value: unknown) {
if (!isRecord(value)) {
return {ok: false as const, message: '统一创作契约必须是对象'};
return { ok: false as const, message: '统一创作契约必须是对象' };
}
const playId = readRequiredString(value, 'playId');
if (!playId) {
return {ok: false as const, message: '统一创作契约 playId 不能为空'};
return { ok: false as const, message: '统一创作契约 playId 不能为空' };
}
const title = readRequiredString(value, 'title');
@@ -1078,42 +1271,57 @@ function validateUnifiedCreationSpec(value: unknown) {
const generationStage = readRequiredString(value, 'generationStage');
const resultStage = readRequiredString(value, 'resultStage');
if (!title) {
return {ok: false as const, message: '统一创作契约标题不能为空'};
return { ok: false as const, message: '统一创作契约标题不能为空' };
}
if (!workspaceStage || !generationStage || !resultStage) {
return {ok: false as const, message: '该玩法缺少默认阶段配置,请先由开发接入'};
return {
ok: false as const,
message: '该玩法缺少默认阶段配置,请先由开发接入',
};
}
if (mudPointCost === null) {
return {ok: false as const, message: '统一创作契约泥点消耗数量必须是大于 0 的整数'};
return {
ok: false as const,
message: '统一创作契约泥点消耗数量必须是大于 0 的整数',
};
}
if (new Set([workspaceStage, generationStage, resultStage]).size !== 3) {
return {ok: false as const, message: '统一创作契约阶段不能重复'};
return { ok: false as const, message: '统一创作契约阶段不能重复' };
}
if (!Array.isArray(value.fields) || value.fields.length === 0) {
return {ok: false as const, message: '统一创作契约 fields 不能为空'};
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: '统一创作契约字段必须是对象'};
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 不能为空'};
return {
ok: false as const,
message: '统一创作契约字段 id 和 label 不能为空',
};
}
if (fieldIds.has(id)) {
return {ok: false as const, message: `统一创作契约字段 id 重复:${id}`};
return { ok: false as const, message: `统一创作契约字段 id 重复:${id}` };
}
fieldIds.add(id);
if (!isUnifiedCreationFieldKind(item.kind)) {
return {ok: false as const, message: `统一创作契约字段 kind 非法:${id}`};
return {
ok: false as const,
message: `统一创作契约字段 kind 非法:${id}`,
};
}
if (typeof item.required !== 'boolean') {
return {ok: false as const, message: `统一创作契约字段 required 非法:${id}`};
return {
ok: false as const,
message: `统一创作契约字段 required 非法:${id}`,
};
}
fields.push({
id,
@@ -1137,7 +1345,11 @@ function validateUnifiedCreationSpec(value: unknown) {
};
}
function UnifiedCreationSpecCard({spec}: {spec: UnifiedCreationSpecPayload}) {
function UnifiedCreationSpecCard({
spec,
}: {
spec: UnifiedCreationSpecPayload;
}) {
return (
<div className="admin-contract-card">
<dl className="admin-info-list">