点赞和改造开关加入后台配置
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user