diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 11bd0e53..3c36b390 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-10 公开作品互动能力进入后台全局配置 + +- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 +- 决策:公开作品点赞 / 改造能力作为 `creation_entry_config.public_work_interactions_json` 的全局矩阵保存,不进入单个 `creation_entry_type_config`。`GET /api/creation-entry/config` 下发 `publicWorkInteractions`;后台通过 `/admin/api/creation-entry/config/interactions` 按 `sourceType` 保存点赞、改造开关和关闭提示;api-server 只对已经接入后端动作的 RPG / custom-world、大鱼吃小鱼和拼图 like / remix 路由做同源熔断,公开列表、详情读取、已发布作品启动和运行态请求不受影响。 +- 影响范围:`CreationEntryConfigResponse`、`AdminCreationEntryConfigResponse`、`module-runtime` 默认矩阵、`spacetime-module` 表字段和 procedure、`spacetime-client` 绑定、后台入口开关页、平台作品详情点赞 / 改造意图解析。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime public_work_interaction_config_defaults_and_overrides --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server public_work_interactions --manifest-path server-rs/Cargo.toml`、后台和前台作品详情互动相关前端测试。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-10 dev Gitea 提供内网 HTTP 入口 - 背景:release / dev 目标 agent 需要从 dev 自托管 Gitea 拉取仓库;继续走 `https://git.genarrative.world/...` 会绕公网链路,`10.2.0.10:3000` 又受云侧端口策略影响不能作为稳定入口。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 2f0d3fd5..cef625e0 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1135,6 +1135,8 @@ - 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。 - 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。 - 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。 +- 追加处理:`/api/auth/refresh` 只有明确返回 `401` / `403` 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 `Failed to fetch` 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。 +- 契约:`/api/auth/refresh` 成功响应按共享契约 `RefreshSessionResponse { token }` 解析;测试 mock 不要额外塞 `{ ok: true, token }` 遮住真实恢复路径。 - 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。 - 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ef176285..0a3a1792 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -20,6 +20,7 @@ import type { AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, ApiErrorEnvelope, ApiMeta, @@ -129,16 +130,16 @@ export async function request( export function loginAdmin(username: string, password: string) { return request('/admin/api/login', { method: 'POST', - body: {username, password}, + body: { username, password }, }); } export function getAdminMe(token: string) { - return request('/admin/api/me', {token}); + return request('/admin/api/me', { token }); } export function getAdminOverview(token: string) { - return request('/admin/api/overview', {token}); + return request('/admin/api/overview', { token }); } export function getAdminDatabaseTables(token: string) { @@ -154,7 +155,7 @@ export function getAdminDatabaseTableRows( ) { return request( `/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`, - {token}, + { token }, ); } @@ -172,15 +173,14 @@ export function listAdminTrackingEvents( ) { return request( `/admin/api/tracking/events${buildQueryString(query)}`, - {token}, + { token }, ); } - export function getAdminCreationEntryConfig(token: string) { return request( '/admin/api/creation-entry/config', - {token}, + { token }, ); } @@ -213,10 +213,25 @@ export function upsertAdminCreationEntryBanners( ); } +/** 保存公开作品详情页点赞 / 改造能力配置。 */ +export function upsertAdminPublicWorkInteractions( + token: string, + payload: AdminUpsertPublicWorkInteractionConfigRequest, +) { + return request( + '/admin/api/creation-entry/config/interactions', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminWorkVisibility(token: string) { return request( '/admin/api/works/visibility', - {token}, + { token }, ); } @@ -237,7 +252,7 @@ export function updateAdminWorkVisibility( export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', - {token}, + { token }, ); } @@ -258,7 +273,7 @@ export function upsertProfileRedeemCode( export function listProfileInviteCodes(token: string) { return request( '/admin/api/profile/invite-codes', - {token}, + { token }, ); } @@ -293,7 +308,7 @@ export function disableProfileRedeemCode( export function listProfileTaskConfigs(token: string) { return request( '/admin/api/profile/tasks', - {token}, + { token }, ); } @@ -325,7 +340,7 @@ export function disableProfileTaskConfig( export function listProfileRechargeProducts(token: string) { return request( '/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 = diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 2eb58477..d3cb8ebe 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx index 4400b5da..0022c263 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -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: '
后台公告
', }, ], + 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( - , + const { container } = render( + , ); 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: '
新的入口公告
'}, + target: { value: '
新的入口公告
' }, }); - 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: '
轮播第二条
'}, + target: { value: '
轮播第二条
' }, }); - 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( + , + ); + + 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(); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index e00c848b..8a674650 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -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 = { + '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( + [], + ); 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 (
@@ -515,191 +600,288 @@ export function AdminCreationEntrySwitchPage({ ) : null} {!isAnnouncementMode ? ( -
-
-
- - - + <> +
+
+

作品互动

+ {`${publicWorkInteractions.length} 类`}
- -
- - -
- - - - - - - -
- - -
- - - -
-
- 统一创作契约 - - {unifiedCreationSpec ? '已配置' : '未配置'} - -
-
- - {unifiedCreationSpec ? ( - - ) : null} -
- {unifiedCreationSpec ? ( - - ) : ( -
未配置统一创作页契约
- )} -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - -
- -
- - -
- - - - - - + + + + + - {entries.map((entry) => ( - + {publicWorkInteractions.map((item, index) => ( + + + + + - - - - - ))}
入口展示开放统一契约分类排序作品类型点赞点赞关闭提示改造改造关闭提示
{formatPublicWorkSourceLabel(item.sourceType)} - + + + + updatePublicWorkInteraction(index, { + likeDisabledMessage: event.target.value, + }) + } + /> + + + + + updatePublicWorkInteraction(index, { + remixDisabledMessage: event.target.value, + }) + } + /> {entry.visible ? '是' : '否'}{entry.open ? '是' : '否'}{entry.unifiedCreationSpec ? '是' : '否'}{entry.categoryLabel || entry.categoryId}{entry.sortOrder}
+ {interactionErrorMessage ? ( +
+ {interactionErrorMessage} +
+ ) : null} +
+ +
-
+ +
+
+
+ + + +
+ +
+ + +
+ + + + + + + +
+ + +
+ + + +
+
+ 统一创作契约 + {unifiedCreationSpec ? '已配置' : '未配置'} +
+
+ + {unifiedCreationSpec ? ( + + ) : null} +
+ {unifiedCreationSpec ? ( + + ) : ( +
未配置统一创作页契约
+ )} +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ +
+
+ +
+
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
入口展示开放统一契约分类排序
+ + {entry.visible ? '是' : '否'}{entry.open ? '是' : '否'}{entry.unifiedCreationSpec ? '是' : '否'}{entry.categoryLabel || entry.categoryId}{entry.sortOrder}
+
+
+
+ ) : null} {confirmDialog} @@ -776,7 +958,10 @@ export function AdminCreationEntrySwitchPage({
{unifiedCreationSpecForm.fields.map((field, index) => ( -
+
{`字段 ${index + 1}`}