From e29992cf0158686777d089da7c8da68607b4a6f4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 10 Jun 2026 14:36:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=82=B9=E8=B5=9E=E5=92=8C=E6=94=B9=E9=80=A0?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=8A=A0=E5=85=A5=E5=90=8E=E5=8F=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 2 + apps/admin-web/src/api/adminApiClient.ts | 47 +- apps/admin-web/src/api/adminApiTypes.ts | 23 +- .../AdminCreationEntrySwitchPage.test.tsx | 117 +++- .../pages/AdminCreationEntrySwitchPage.tsx | 602 ++++++++++++------ ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 14 +- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 2 + ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 6 +- packages/shared/src/contracts/auth.ts | 3 +- server-rs/crates/api-server/src/admin.rs | 82 ++- server-rs/crates/api-server/src/app.rs | 95 ++- .../api-server/src/creation_entry_config.rs | 102 +++ .../crates/api-server/src/modules/admin.rs | 8 +- server-rs/crates/api-server/src/state.rs | 85 +++ .../crates/module-runtime/src/application.rs | 223 ++++++- server-rs/crates/module-runtime/src/domain.rs | 23 + server-rs/crates/module-runtime/src/lib.rs | 32 + .../crates/shared-contracts/src/admin.rs | 14 +- .../src/creation_entry_config.rs | 23 + .../spacetime-client/src/mapper/runtime.rs | 15 + .../spacetime-client/src/module_bindings.rs | 4 + .../creation_entry_config_snapshot_type.rs | 1 + .../creation_entry_config_type.rs | 7 + .../crates/spacetime-client/src/runtime.rs | 28 + .../crates/spacetime-module/src/migration.rs | 3 + .../src/runtime/creation_entry_config.rs | 54 ++ .../PlatformEntryFlowShellImpl.tsx | 178 +++--- .../platformPublicWorkDetailFlow.test.ts | 50 +- .../platformPublicWorkDetailFlow.ts | 69 +- src/services/apiClient.test.ts | 67 +- src/services/apiClient.ts | 26 +- src/services/creationEntryConfigService.ts | 11 + 33 files changed, 1644 insertions(+), 380 deletions(-) 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}`}