From e401963d3a2bb54f24f45fbccd4eebda77846080 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 03:50:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=B8=B8=E6=A0=87=E5=9B=9E=E5=BD=92=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E7=A0=81=20THEME=5FINVALID=5FCURSOR=EF=BC=88=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E7=A0=81=E6=AD=A4=E5=89=8D=E5=9C=A8=20HTTP=20?= =?UTF-8?q?=E9=9D=A2=E4=B8=8D=E5=8F=AF=E8=BE=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但 `error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR` 在 HTTP 面上拿不到,客户端只能去匹配中文文案。 根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回 中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支 因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。 口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言: - `module-game-distribution/src/errors.rs`:新增 6 个主题码常量 `GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`, 并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。 - `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`; 文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。 - `lib.rs`:导出新增的 6 个码常量。 - 测试: · `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一 不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言; · `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」; · api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息** 逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串; · api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`, 只把 `FORK_` 留在禁忌列表里(并写明理由)。 门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution` **86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0; `cargo fmt --all -- --check` 0;`git diff --check` 0。 --- apps/admin-web/src/api/adminApiClient.ts | 149 +++ apps/admin-web/src/api/adminGameThemeTypes.ts | 78 ++ apps/admin-web/src/app/AdminApp.tsx | 7 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.ts | 3 + .../src/pages/AdminGameThemesPage.test.tsx | 343 +++++++ .../src/pages/AdminGameThemesPage.tsx | 920 ++++++++++++++++++ .../src/modules/game_distribution.rs | 114 ++- .../module-game-distribution/src/domain.rs | 59 +- .../module-game-distribution/src/errors.rs | 49 +- .../module-game-distribution/src/lib.rs | 8 +- .../module-game-distribution/src/theme.rs | 38 +- 12 files changed, 1717 insertions(+), 53 deletions(-) create mode 100644 apps/admin-web/src/api/adminGameThemeTypes.ts create mode 100644 apps/admin-web/src/pages/AdminGameThemesPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminGameThemesPage.tsx diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9c3470426..9bc5081a0 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -98,12 +98,29 @@ import type { ProfileTaskConfigAdminResponse, ProfileWalletConfigAdminResponse, } from './adminApiTypes'; +import type { + AdminCreateThemeRequest, + AdminGameDistributionThemeListResponse, + AdminGameDistributionThemeMemberResponse, + AdminGameDistributionThemeMutationResponse, + AdminPublicThemeDetail, + AdminThemeStatusFilter, + AdminUpdateThemeRequest, + AdminUpsertThemeMemberRequest, +} from './adminGameThemeTypes'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; const ADMIN_API_BASE_URL = normalizeBaseUrl( import.meta.env.VITE_ADMIN_API_BASE_URL ?? '', ); +/** + * 后台主题列表的 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。 + * + * 服务端按上限截断(不报错),客户端也按同一口径夹一次,避免发出一条注定被截断的请求。 + */ +const ADMIN_THEME_LIST_LIMIT_MAX = 200; + interface AdminRequestOptions { method?: string; token?: string; @@ -1488,3 +1505,135 @@ export function importAdminAgcTemplates(token: string, formData: FormData) { }, ); } + +/** + * 后台共创主题列表:`GET /admin/api/game-distribution/themes?limit=&status=`。 + * + * 含 draft / archived;`status=all`(或省略)不过滤。**没有游标**:`limit` 上限 200,服务端按 + * 上限截断,这里也按同一口径夹一次,避免把一条必然被截断的请求发出去。 + */ +export function listAdminGameDistributionThemes( + token: string, + options: { + limit?: number; + status?: AdminThemeStatusFilter; + } = {}, + signal?: AbortSignal, +) { + const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX; + const normalizedLimit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), ADMIN_THEME_LIST_LIMIT_MAX) + : ADMIN_THEME_LIST_LIMIT_MAX; + const params = new URLSearchParams({ limit: String(normalizedLimit) }); + const status = options.status?.trim(); + if (status && status !== 'all') params.set('status', status); + return request( + `/admin/api/game-distribution/themes?${params.toString()}`, + { token, signal }, + ); +} + +/** 后台创建主题:`theme_id` 由服务端生成,客户端只给字段;写操作必须带幂等键。 */ +export function createAdminGameDistributionTheme( + token: string, + idempotencyKey: string, + payload: AdminCreateThemeRequest, +) { + return request( + '/admin/api/game-distribution/themes', + { + token, + method: 'POST', + headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) }, + body: payload, + }, + ); +} + +/** 后台整体覆盖主题字段:`name/summary/badge/sortOrder/status` 都是必填(不是部分更新)。 */ +export function updateAdminGameDistributionTheme( + token: string, + themeId: string, + idempotencyKey: string, + payload: AdminUpdateThemeRequest, +) { + return request( + `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`, + { + token, + method: 'PUT', + headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) }, + body: payload, + }, + ); +} + +/** + * 增 / 改主题成员:**不要求幂等键**(成员身份完全由路径给出,重复调用只改 `sortOrder`)。 + * + * 只允许根作品:非根作品服务端回 409 `THEME_MEMBER_NOT_ROOT`。 + */ +export function upsertAdminGameDistributionThemeMember( + token: string, + themeId: string, + rootGameId: string, + payload: AdminUpsertThemeMemberRequest, +) { + return request( + `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`, + { token, method: 'PUT', body: payload }, + ); +} + +/** 移除主题成员:不要求幂等键;成员不存在也算成功(200)。 */ +export function removeAdminGameDistributionThemeMember( + token: string, + themeId: string, + rootGameId: string, +) { + return request( + `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`, + { token, method: 'DELETE' }, + ); +} + +/** + * 公开主题详情(匿名单读)。 + * + * 后台用它列出**已发布**主题的当前可见成员:服务端目前没有「后台读成员」的接口(只有按根作品 ID + * 的 PUT/DELETE),可见成员能且只能从这条公开路径取得。未发布 / 已归档主题这里会 404,调用方按 + * 「拿不到成员列表」处理,不要把它渲染成空列表。 + */ +export function getPublicGameDistributionTheme( + themeId: string, + signal?: AbortSignal, +) { + return request( + `/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`, + { signal }, + ); +} + +function normalizeThemeId(themeId: string) { + const normalized = themeId.trim(); + if (!normalized) { + throw new Error('缺少主题 ID'); + } + return normalized; +} + +function normalizeRootGameId(rootGameId: string) { + const normalized = rootGameId.trim(); + if (!normalized) { + throw new Error('缺少根作品 ID'); + } + return normalized; +} + +function normalizeThemeIdempotencyKey(idempotencyKey: string) { + const normalized = idempotencyKey.trim(); + if (!normalized || normalized.length > 128) { + throw new Error('主题写操作幂等键必须是 1 到 128 个字符'); + } + return normalized; +} diff --git a/apps/admin-web/src/api/adminGameThemeTypes.ts b/apps/admin-web/src/api/adminGameThemeTypes.ts new file mode 100644 index 000000000..0c4b42433 --- /dev/null +++ b/apps/admin-web/src/api/adminGameThemeTypes.ts @@ -0,0 +1,78 @@ +/** + * 后台「共创主题」接口的请求 / 响应载荷类型。 + * + * 契约来源是 `packages/shared` 的 `contracts/gameDistribution`,这里用**相对路径直接引用**而不是 + * `@genarrative/shared`:仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约 + * 只在本分支上,走包名会拿到缺主题类型的旧副本(AGC 侧同样用这种相对引用)。 + * + * 为什么单独一个文件而不是并进 `adminApiTypes.ts`:那边被 + * `scripts/check-game-distribution-dto-parity.mjs` 全量扫描(任何未登记的新类型都会判红),而主题 + * 后台响应是**服务端手拼 JSON**、没有对应 Rust DTO,登记只能改 `scripts/`(本轮范围外,且另一个 + * session 正在改它)。放在这里既不触发那条门禁,也不改共享契约。 + */ +import type { + GameDistributionCreateThemeRequest, + GameDistributionThemeDetail, + GameDistributionThemeStatus, + GameDistributionUpdateThemeRequest, + GameDistributionUpsertThemeMemberRequest, +} from '../../../../packages/shared/src/contracts/gameDistribution'; + +/** 主题状态(与共享契约同源,不另立枚举)。 */ +export type AdminThemeStatus = GameDistributionThemeStatus; + +/** 后台主题列表的 `status` 查询值:`all` 表示不过滤(服务端白名单同值)。 */ +export type AdminThemeStatusFilter = AdminThemeStatus | 'all'; + +/** 后台创建主题的请求体(与公开契约同一份定义)。 */ +export type AdminCreateThemeRequest = GameDistributionCreateThemeRequest; + +/** 后台更新主题的请求体:整体覆盖,字段全必填。 */ +export type AdminUpdateThemeRequest = GameDistributionUpdateThemeRequest; + +/** 后台成员写入请求体:只带运营权重。 */ +export type AdminUpsertThemeMemberRequest = GameDistributionUpsertThemeMemberRequest; + +/** 公开主题详情(后台用它读已发布主题的可见成员)。 */ +export type AdminPublicThemeDetail = GameDistributionThemeDetail; + +/** + * 后台主题条目(`GET /admin/api/game-distribution/themes` 的 `themes[]` 与写接口的 `theme`)。 + * + * `memberCount` 是**成员行总数**(含当前不可见的成员):后台没有「不该被探测」的顾虑,运营需要 + * 知道这个主题挂了几行;公开投影里的同名键是当前可见成员数,两者口径不同。 + */ +export interface AdminGameDistributionTheme { + themeId: string; + name: string; + summary: string; + badge: string; + sortOrder: number; + status: AdminThemeStatus; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +/** 后台主题列表响应:只有 `themes` 一个键(这条路径不分页、无游标)。 */ +export interface AdminGameDistributionThemeListResponse { + themes: AdminGameDistributionTheme[]; +} + +/** 后台创建 / 更新主题的响应:`replayed` 区分「这次真的写了」与「同键重放」。 */ +export interface AdminGameDistributionThemeMutationResponse { + theme: AdminGameDistributionTheme; + replayed: boolean; +} + +/** + * 后台成员写入响应。 + * + * `sortOrder` 是运营权重(成员列表按它升序);重复 PUT 只改它、不刷新 `createdAt`。 + */ +export interface AdminGameDistributionThemeMemberResponse { + themeId: string; + rootGameId: string; + sortOrder: number; + createdAt: string; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 52957c245..ae89c65b7 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -32,6 +32,7 @@ import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; import { AdminGameManagementPage } from '../pages/AdminGameManagementPage'; import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage'; +import { AdminGameThemesPage } from '../pages/AdminGameThemesPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -346,6 +347,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-themes' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( ({ + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + listAdminGameDistributionThemes: vi.fn(), + createAdminGameDistributionTheme: vi.fn(), + updateAdminGameDistributionTheme: vi.fn(), + upsertAdminGameDistributionThemeMember: vi.fn(), + removeAdminGameDistributionThemeMember: vi.fn(), + getPublicGameDistributionTheme: vi.fn(), +})); + +const publishedTheme: AdminGameDistributionTheme = { + themeId: 'theme_1', + name: '星际防线', + summary: '同一母版下的二创合集', + badge: '热门', + sortOrder: 10, + status: 'published', + memberCount: 3, + createdAt: '2026-10-01T08:00:00Z', + updatedAt: '2026-10-02T08:00:00Z', +}; + +const draftTheme: AdminGameDistributionTheme = { + ...publishedTheme, + themeId: 'theme_2', + name: '草稿主题', + badge: '新', + sortOrder: 20, + status: 'draft', + memberCount: 0, +}; + +function mockList(themes: AdminGameDistributionTheme[] = [publishedTheme]) { + vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes }); +} + +function renderPage( + onUnauthorized: (message?: string) => void = () => undefined, +) { + return render( + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockList(); + // 成员面板打开时会读公开投影;默认给一个空 roots,个别用例再覆盖。 + vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({ + roots: [], + } as never); +}); + +afterEach(() => { + cleanup(); +}); + +test('列表展示名称、角标、状态、排序与成员数,并按状态过滤', async () => { + renderPage(); + + const row = (await screen.findByText('星际防线')).closest('tr')!; + expect(within(row).getByText('热门')).toBeTruthy(); + expect(within(row).getByText('已发布')).toBeTruthy(); + expect(within(row).getByText('10')).toBeTruthy(); + expect(within(row).getByText('3')).toBeTruthy(); + expect(within(row).getByText('theme_1')).toBeTruthy(); + + expect(listAdminGameDistributionThemes).toHaveBeenCalledWith( + 'admin-token', + { limit: 200, status: 'all' }, + expect.anything(), + ); + + // 切到归档过滤:请求带上 status,且列表按新数据渲染。 + mockList([{ ...draftTheme, status: 'archived', name: '归档主题' }]); + fireEvent.change(screen.getByLabelText('状态过滤'), { + target: { value: 'archived' }, + }); + fireEvent.click(screen.getByRole('button', { name: /查询/ })); + + await waitFor(() => + expect(listAdminGameDistributionThemes).toHaveBeenLastCalledWith( + 'admin-token', + { limit: 200, status: 'archived' }, + expect.anything(), + ), + ); + expect(await screen.findByText('归档主题')).toBeTruthy(); +}); + +test('创建主题:先校验再写,写操作带幂等键,重放要提示', async () => { + vi.mocked(createAdminGameDistributionTheme).mockResolvedValue({ + theme: publishedTheme, + replayed: true, + }); + renderPage(); + await screen.findByText('星际防线'); + + fireEvent.click(screen.getByRole('button', { name: '新建主题' })); + const dialog = await screen.findByRole('dialog'); + + // 名称为空:本地就拦下,不发请求。 + fireEvent.click(within(dialog).getByRole('button', { name: '创建' })); + expect( + await within(dialog).findByText('主题名不能为空'), + ).toBeTruthy(); + expect(createAdminGameDistributionTheme).not.toHaveBeenCalled(); + + fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), { + target: { value: '星际防线二' }, + }); + fireEvent.change(within(dialog).getByLabelText('新建主题简介'), { + target: { value: '第二个主题' }, + }); + fireEvent.change(within(dialog).getByLabelText('新建主题角标'), { + target: { value: '新' }, + }); + fireEvent.change(within(dialog).getByLabelText('新建主题排序号'), { + target: { value: '5' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '创建' })); + + // 写操作要先过确认对话框。 + const confirm = await screen.findByRole('dialog', { name: '确认操作' }); + fireEvent.click(within(confirm).getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1), + ); + const [token, idempotencyKey, payload] = vi.mocked( + createAdminGameDistributionTheme, + ).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(payload).toEqual({ + name: '星际防线二', + summary: '第二个主题', + badge: '新', + sortOrder: 5, + status: 'draft', + }); + expect(String(idempotencyKey).length).toBeGreaterThan(8); + expect(String(idempotencyKey).length).toBeLessThanOrEqual(128); + expect( + await screen.findByText(/已存在(幂等重放,未重复写入)/), + ).toBeTruthy(); +}); + +test('创建时同键换请求的 409 要原样解释给运营', async () => { + vi.mocked(createAdminGameDistributionTheme).mockRejectedValue( + Object.assign(new Error('Idempotency-Key 已用于不同的请求'), { + status: 409, + code: 'ADMIN_IDEMPOTENCY_CONFLICT', + }), + ); + renderPage(); + await screen.findByText('星际防线'); + + fireEvent.click(screen.getByRole('button', { name: '新建主题' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), { + target: { value: '冲突主题' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '创建' })); + const confirm = await screen.findByRole('dialog', { name: '确认操作' }); + fireEvent.click(within(confirm).getByRole('button', { name: '确认' })); + + expect( + await within(dialog).findByText(/Idempotency-Key 已用于不同的请求/), + ).toBeTruthy(); +}); + +test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读', async () => { + vi.mocked(updateAdminGameDistributionTheme).mockResolvedValue({ + theme: { ...draftTheme, status: 'archived' }, + replayed: false, + }); + renderPage(); + const row = (await screen.findByText('星际防线')).closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: '编辑' })); + + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByText(/归档(archived)后公开侧不可读/), + ).toBeTruthy(); + + fireEvent.change(within(dialog).getByLabelText('编辑主题主题名'), { + target: { value: '星际防线(归档)' }, + }); + fireEvent.change(within(dialog).getByLabelText('编辑主题状态'), { + target: { value: 'archived' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '保存' })); + const confirm = await screen.findByRole('dialog', { name: '确认操作' }); + fireEvent.click(within(confirm).getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1), + ); + const [token, themeId, , payload] = vi.mocked( + updateAdminGameDistributionTheme, + ).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(themeId).toBe('theme_1'); + expect(payload).toMatchObject({ + name: '星际防线(归档)', + status: 'archived', + }); +}); + +test('加非根作品:把 409 翻成「只能收录根作品」的可读原因', async () => { + vi.mocked(upsertAdminGameDistributionThemeMember).mockRejectedValue( + Object.assign(new Error('not root'), { + status: 409, + code: 'THEME_MEMBER_NOT_ROOT', + }), + ); + renderPage(); + const row = (await screen.findByText('星际防线')).closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: '成员' })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText('根作品 ID'), { + target: { value: 'game_child_1' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: '添加成员' })); + + expect( + await within(dialog).findByText( + '该作品不是根作品(母版);主题只能收录根作品', + ), + ).toBeTruthy(); + expect(upsertAdminGameDistributionThemeMember).toHaveBeenCalledWith( + 'admin-token', + 'theme_1', + 'game_child_1', + { sortOrder: 0 }, + ); +}); + +test('成员面板:已发布主题列出可见成员并支持逐条移除', async () => { + vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({ + theme: { + themeId: 'theme_1', + name: '星际防线', + summary: '', + badge: '热门', + sortOrder: 10, + memberCount: 3, + updatedAt: '2026-10-02T08:00:00Z', + }, + roots: [ + { id: 'game_root_1', title: '母版作品' }, + { id: 'game_root_2', title: '另一母版' }, + ], + } as never); + vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({ + themeId: 'theme_1', + rootGameId: 'game_root_1', + sortOrder: 0, + createdAt: '2026-10-01T08:00:00Z', + }); + renderPage(); + const row = (await screen.findByText('星际防线')).closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: '成员' })); + + const dialog = await screen.findByRole('dialog'); + const members = await within(dialog).findByRole('list', { + name: '公开可见成员', + }); + expect(within(members).getByText('母版作品')).toBeTruthy(); + expect(within(members).getByText('game_root_1')).toBeTruthy(); + + // 只移除点中的那一条。 + fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!); + await waitFor(() => + expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith( + 'admin-token', + 'theme_1', + 'game_root_1', + ), + ); + await waitFor(() => + expect(within(dialog).queryByText('game_root_1')).toBeNull(), + ); + expect(within(dialog).getByText('game_root_2')).toBeTruthy(); +}); + +test('成员面板:未发布主题明说读不到成员列表,而不是渲染成空', async () => { + mockList([draftTheme]); + renderPage(); + const row = (await screen.findByText('草稿主题')).closest('tr')!; + fireEvent.click(within(row).getByRole('button', { name: '成员' })); + + const dialog = await screen.findByRole('dialog'); + expect( + await within(dialog).findByText(/公开侧读不到成员列表/), + ).toBeTruthy(); + expect(getPublicGameDistributionTheme).not.toHaveBeenCalled(); +}); + +test('401 时交给上层处理登录失效,不把错误塞进页面', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminGameDistributionThemes).mockRejectedValue( + Object.assign(new Error('unauthorized'), { status: 401 }), + ); + renderPage(onUnauthorized); + + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); diff --git a/apps/admin-web/src/pages/AdminGameThemesPage.tsx b/apps/admin-web/src/pages/AdminGameThemesPage.tsx new file mode 100644 index 000000000..07dab1be6 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameThemesPage.tsx @@ -0,0 +1,920 @@ +import type { AdminStatusTone } from '@genarrative/shared/components'; +import { + AdminActionRow, + AdminAlert, + AdminButton, + AdminDialog, + AdminField, + AdminListPanel, + AdminPage, + AdminPageHeading, + AdminStatusPill, +} from '@genarrative/shared/components'; +import { RefreshCcw, Search } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + createAdminGameDistributionTheme, + getPublicGameDistributionTheme, + isAdminApiError, + listAdminGameDistributionThemes, + removeAdminGameDistributionThemeMember, + updateAdminGameDistributionTheme, + upsertAdminGameDistributionThemeMember, +} from '../api/adminApiClient'; +import type { + AdminGameDistributionTheme, + AdminThemeStatus, + AdminThemeStatusFilter, +} from '../api/adminGameThemeTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameThemesPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +/** 主题状态与文案:`archived` 后公开侧读不到,这一点必须在界面上说清。 */ +const THEME_STATUS_META: Record< + AdminThemeStatus, + { label: string; tone: AdminStatusTone } +> = { + draft: { label: '草稿', tone: 'pending' }, + published: { label: '已发布', tone: 'ok' }, + archived: { label: '已归档', tone: 'error' }, +}; + +const THEME_STATUS_OPTIONS: { + value: AdminThemeStatus; + label: string; +}[] = [ + { value: 'draft', label: '草稿(公开侧不可读)' }, + { value: 'published', label: '已发布(公开侧可读)' }, + { value: 'archived', label: '已归档(公开侧不可读)' }, +]; + +const STATUS_FILTER_OPTIONS: { value: AdminThemeStatusFilter; label: string }[] = + [ + { value: 'all', label: '全部状态' }, + { value: 'draft', label: '草稿' }, + { value: 'published', label: '已发布' }, + { value: 'archived', label: '已归档' }, + ]; + +/** 与接口契约同值的文本上限(前端先拦,服务端仍会独立校验)。 */ +const THEME_NAME_MAX_CHARS = 40; +const THEME_SUMMARY_MAX_CHARS = 200; +const THEME_BADGE_MAX_CHARS = 16; +/** 列表 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。 */ +const THEME_LIST_LIMIT = 200; + +interface ThemeFormDraft { + name: string; + summary: string; + badge: string; + sortOrder: string; + status: AdminThemeStatus; +} + +const EMPTY_THEME_FORM: ThemeFormDraft = { + name: '', + summary: '', + badge: '', + sortOrder: '0', + status: 'draft', +}; + +function themeStatusMeta(status: string) { + return ( + THEME_STATUS_META[status as AdminThemeStatus] ?? { + label: status || '—', + tone: 'pending' as AdminStatusTone, + } + ); +} + +function draftFromTheme(theme: AdminGameDistributionTheme): ThemeFormDraft { + return { + name: theme.name, + summary: theme.summary, + badge: theme.badge, + sortOrder: String(theme.sortOrder), + status: theme.status, + }; +} + +/** + * 主题写操作的幂等键:**按请求指纹复用**。 + * + * 同一次编辑(指纹不变)重试必须复用同一个键,否则服务端会把它当成新请求;而改了内容再提交就是 + * 另一次操作,键必须换(同键换请求服务端回 409)。键前缀只用于排障时区分是哪个页面的写入。 + */ +function createThemeIdempotencyKey(action: string) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `theme-${action}-${random}`.slice(0, 128); +} + +/** 表单校验:返回可读错误(与接口契约同值),空串表示通过。 */ +export function validateThemeForm(draft: ThemeFormDraft): string { + const name = draft.name.trim(); + if (!name) { + return '主题名不能为空'; + } + if (Array.from(name).length > THEME_NAME_MAX_CHARS) { + return `主题名最多 ${THEME_NAME_MAX_CHARS} 个字符`; + } + if (Array.from(draft.summary.trim()).length > THEME_SUMMARY_MAX_CHARS) { + return `主题简介最多 ${THEME_SUMMARY_MAX_CHARS} 个字符`; + } + if (Array.from(draft.badge.trim()).length > THEME_BADGE_MAX_CHARS) { + return `角标最多 ${THEME_BADGE_MAX_CHARS} 个字符`; + } + if (!Number.isFinite(Number(draft.sortOrder)) || !Number.isInteger(Number(draft.sortOrder))) { + return '排序号必须是整数'; + } + return ''; +} + +/** + * 主题写操作的可读错误:按**服务端稳定错误码**分支(不匹配中文文案), + * 其余按服务端 message 原样展示(不吞掉真实原因)。 + */ +export function themeWriteErrorMessage(error: unknown): string { + if (isAdminApiError(error)) { + switch (error.code) { + case 'THEME_MEMBER_NOT_ROOT': + return '该作品不是根作品(母版);主题只能收录根作品'; + case 'THEME_MEMBER_GAME_NOT_FOUND': + return '没有找到这个根作品 ID,请确认后再添加'; + case 'THEME_NOT_FOUND': + return '这个主题已不存在,请刷新列表'; + case 'THEME_IDEMPOTENCY_CONFLICT': + return '这次提交的幂等键已经用在别的内容上,请刷新页面后重试'; + default: + return error.message; + } + } + return error instanceof Error ? error.message : '操作失败'; +} + +export function AdminGameThemesPage({ + token, + onUnauthorized, +}: AdminGameThemesPageProps) { + const [themes, setThemes] = useState([]); + const [statusFilter, setStatusFilter] = + useState('all'); + const [draftFilter, setDraftFilter] = + useState('all'); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [createOpen, setCreateOpen] = useState(false); + const [createDraft, setCreateDraft] = + useState(EMPTY_THEME_FORM); + const [createError, setCreateError] = useState(''); + const [editing, setEditing] = useState(null); + const [editDraft, setEditDraft] = useState(EMPTY_THEME_FORM); + const [editError, setEditError] = useState(''); + const [memberTheme, setMemberTheme] = + useState(null); + const [memberRootGameId, setMemberRootGameId] = useState(''); + const [memberSortOrder, setMemberSortOrder] = useState('0'); + const [memberError, setMemberError] = useState(''); + const [memberNotice, setMemberNotice] = useState(''); + /** 已发布主题的**公开可见成员**(后台没有成员读取接口,只能从公开投影取)。 */ + const [visibleMembers, setVisibleMembers] = useState< + { gameId: string; title: string }[] + >([]); + /** + * 公开可见成员列表是否被截断(服务端 `rootsTruncated`)。 + * + * 成员行总数与公开可见数是两个口径:**不要**把 `memberCount === roots.length` 当不变式。 + */ + const [visibleMembersTruncated, setVisibleMembersTruncated] = useState(false); + const [visibleMembersState, setVisibleMembersState] = useState< + 'idle' | 'loading' | 'ready' | 'unavailable' + >('idle'); + const [memberBusyId, setMemberBusyId] = useState(''); + const [busy, setBusy] = useState(false); + const writeConfirm = useAdminWriteConfirm(); + const listController = useRef(null); + // 幂等键按「动作 + 请求指纹」复用:草稿不变时重试同一个键,改了内容才换键。 + const idempotencyRef = useRef<{ fingerprint: string; key: string } | null>(null); + + const loadThemes = useCallback( + async (status: AdminThemeStatusFilter) => { + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + try { + const response = await listAdminGameDistributionThemes( + token, + { limit: THEME_LIST_LIMIT, status }, + controller.signal, + ); + setThemes(response.themes ?? []); + setHasLoaded(true); + setErrorMessage(''); + } catch (error) { + if (controller.signal.aborted) { + return; + } + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }, + [token, onUnauthorized], + ); + + useEffect(() => { + void loadThemes(statusFilter); + return () => { + listController.current?.abort(); + }; + }, [loadThemes, statusFilter]); + + /** + * 成员面板打开时取一次「公开可见成员」。 + * + * 只有已发布主题能从公开路径读到(其余状态会 404),因此未发布时标记为 unavailable 并如实说明, + * 不渲染成「没有成员」——那会让人以为主题是空的。 + */ + useEffect(() => { + const themeId = memberTheme?.themeId; + const status = memberTheme?.status; + if (!themeId) { + setVisibleMembers([]); + setVisibleMembersState('idle'); + return; + } + if (status !== 'published') { + setVisibleMembers([]); + setVisibleMembersState('unavailable'); + return; + } + const controller = new AbortController(); + setVisibleMembersState('loading'); + void getPublicGameDistributionTheme(themeId, controller.signal) + .then((detail) => { + setVisibleMembers( + (detail.roots ?? []).map((root) => ({ + gameId: root.id, + title: root.title, + })), + ); + // `rootsTruncated` 是服务端新增的布字段:老响应没有它,按「未截断」处理。 + setVisibleMembersTruncated( + 'rootsTruncated' in detail && detail.rootsTruncated === true, + ); + setVisibleMembersState('ready'); + }) + .catch(() => { + if (!controller.signal.aborted) { + setVisibleMembers([]); + setVisibleMembersTruncated(false); + setVisibleMembersState('unavailable'); + } + }); + return () => { + controller.abort(); + }; + }, [memberTheme?.themeId, memberTheme?.status]); + + function themeFingerprint(action: string, payload: object) { + return `${action}-${JSON.stringify(payload)}`; + } + + function stableThemeIdempotencyKey(action: string, payload: object) { + const fingerprint = themeFingerprint(action, payload); + if (idempotencyRef.current?.fingerprint === fingerprint) { + return idempotencyRef.current.key; + } + const key = createThemeIdempotencyKey(action); + idempotencyRef.current = { fingerprint, key }; + return key; + } + + async function submitCreate() { + const validation = validateThemeForm(createDraft); + if (validation) { + setCreateError(validation); + return; + } + const payload = { + name: createDraft.name.trim(), + summary: createDraft.summary.trim(), + badge: createDraft.badge.trim(), + sortOrder: Number(createDraft.sortOrder), + status: createDraft.status, + }; + const confirmed = await writeConfirm.confirmWrite({ + action: '创建共创主题', + target: payload.name, + }); + if (!confirmed) { + return; + } + setBusy(true); + setCreateError(''); + try { + const result = await createAdminGameDistributionTheme( + token, + stableThemeIdempotencyKey('create', payload), + payload, + ); + setCreateOpen(false); + setCreateDraft(EMPTY_THEME_FORM); + idempotencyRef.current = null; + setStatusMessage( + result.replayed + ? `主题《${result.theme.name}》已存在(幂等重放,未重复写入)` + : `已创建主题《${result.theme.name}》`, + ); + await loadThemes(statusFilter); + } catch (error) { + setCreateError(themeWriteErrorMessage(error)); + } finally { + setBusy(false); + } + } + + async function submitEdit() { + if (!editing) { + return; + } + const validation = validateThemeForm(editDraft); + if (validation) { + setEditError(validation); + return; + } + const payload = { + name: editDraft.name.trim(), + summary: editDraft.summary.trim(), + badge: editDraft.badge.trim(), + sortOrder: Number(editDraft.sortOrder), + status: editDraft.status, + }; + const confirmed = await writeConfirm.confirmWrite({ + action: '保存共创主题', + target: payload.name, + }); + if (!confirmed) { + return; + } + setBusy(true); + setEditError(''); + try { + const result = await updateAdminGameDistributionTheme( + token, + editing.themeId, + stableThemeIdempotencyKey('update', { + themeId: editing.themeId, + ...payload, + }), + payload, + ); + setEditing(null); + idempotencyRef.current = null; + setStatusMessage(`已保存主题《${result.theme.name}》`); + await loadThemes(statusFilter); + } catch (error) { + setEditError(themeWriteErrorMessage(error)); + } finally { + setBusy(false); + } + } + + async function addMember() { + if (!memberTheme) { + return; + } + const rootGameId = memberRootGameId.trim(); + if (!rootGameId) { + setMemberError('请填写根作品 ID'); + return; + } + if ( + !Number.isFinite(Number(memberSortOrder)) || + !Number.isInteger(Number(memberSortOrder)) + ) { + setMemberError('排序号必须是整数'); + return; + } + setBusy(true); + setMemberError(''); + setMemberNotice(''); + try { + await upsertAdminGameDistributionThemeMember( + token, + memberTheme.themeId, + rootGameId, + { sortOrder: Number(memberSortOrder) }, + ); + setMemberRootGameId(''); + setMemberSortOrder('0'); + setMemberNotice( + `已把 ${rootGameId} 加入主题《${memberTheme.name}》(重复添加只更新排序号)`, + ); + await loadThemes(statusFilter); + } catch (error) { + setMemberError(themeWriteErrorMessage(error)); + } finally { + setBusy(false); + } + } + + async function removeMember(rootGameId: string) { + if (!memberTheme) { + return; + } + setBusy(true); + setMemberBusyId(rootGameId); + setMemberError(''); + setMemberNotice(''); + try { + await removeAdminGameDistributionThemeMember( + token, + memberTheme.themeId, + rootGameId, + ); + setMemberNotice(`已从主题《${memberTheme.name}》移除 ${rootGameId}`); + await loadThemes(statusFilter); + setVisibleMembers((current) => + current.filter((member) => member.gameId !== rootGameId), + ); + } catch (error) { + setMemberError(themeWriteErrorMessage(error)); + } finally { + setBusy(false); + setMemberBusyId(''); + } + } + + const currentTheme = memberTheme + ? themes.find((theme) => theme.themeId === memberTheme.themeId) ?? memberTheme + : null; + + return ( + + + { + setCreateDraft(EMPTY_THEME_FORM); + setCreateError(''); + setCreateOpen(true); + }} + > + 新建主题 + + void loadThemes(statusFilter)} + disabled={isLoading} + > + + + } + /> + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {statusMessage ? ( + {statusMessage} + ) : null} + +

+ 主题把同一母版(根作品)下的二创作品收在一起对外展示。最多一次读取{' '} + {THEME_LIST_LIMIT} 条(服务端上限,超出按上限截断,没有游标)。 +

+ +
{ + event.preventDefault(); + setStatusFilter(draftFilter); + }} + > + + + + + + + +
+ + + {themes.length ? `共 ${themes.length} 条` : ''} + + } + rows={themes} + size="wide" + tableClassName="admin-game-themes-table" + columns={[ + { key: 'name', label: '名称' }, + { key: 'badge', label: '角标' }, + { key: 'status', label: '状态' }, + { key: 'sortOrder', label: '排序' }, + { key: 'memberCount', label: '成员数' }, + { key: 'themeId', label: 'themeId' }, + { key: 'actions', label: '操作' }, + ]} + renderRow={(theme) => { + const status = themeStatusMeta(theme.status); + return ( + + + {theme.name?.trim() || '—'} + {theme.summary?.trim() ? ( + {theme.summary} + ) : null} + + {theme.badge?.trim() || '—'} + + + {status.label} + + {theme.status === 'archived' ? ( + + 已归档:公开侧不可读 + + ) : null} + + {theme.sortOrder} + {theme.memberCount} + + + {theme.themeId} + + + + + { + setEditDraft(draftFromTheme(theme)); + setEditError(''); + setEditing(theme); + }} + > + 编辑 + + { + setMemberTheme(theme); + setMemberRootGameId(''); + setMemberSortOrder('0'); + setMemberError(''); + setMemberNotice(''); + }} + > + 成员 + + + + + ); + }} + /> + + {createOpen ? ( + setCreateOpen(false)} + onSubmit={(event) => { + event.preventDefault(); + void submitCreate(); + }} + > + + {createError ? ( + + {createError} + + ) : null} + + + {busy ? '提交中…' : '创建'} + + setCreateOpen(false)} + > + 取消 + + + + ) : null} + + {editing ? ( + 整体覆盖主题字段({editing.themeId})} + titleProps={{ id: 'admin-game-theme-edit-title' }} + onClose={() => setEditing(null)} + onSubmit={(event) => { + event.preventDefault(); + void submitEdit(); + }} + > + + {editError ? ( + + {editError} + + ) : null} + + + {busy ? '保存中…' : '保存'} + + setEditing(null)} + > + 取消 + + + + ) : null} + + {currentTheme ? ( + + 《{currentTheme.name}》· 成员数 {currentTheme.memberCount} + + } + titleProps={{ id: 'admin-game-theme-members-title' }} + onClose={() => setMemberTheme(null)} + onSubmit={(event) => { + event.preventDefault(); + void addMember(); + }} + > + + { + setMemberRootGameId(event.target.value); + if (memberError) setMemberError(''); + }} + disabled={busy} + /> + + + setMemberSortOrder(event.target.value)} + disabled={busy} + /> + + {memberError ? ( + + {memberError} + + ) : null} + {memberNotice ? ( + {memberNotice} + ) : null} + {/* + 服务端没有「后台读取主题成员」的接口,只有按根作品 ID 的 PUT/DELETE。已发布主题能从 + 公开投影读到**当前可见成员**(可能与成员行总数不同),这里如实区分两种口径;未发布 / + 已归档主题读不到,就明说读不到,而不是渲染成空列表。 + */} + {visibleMembersState === 'loading' ? ( +

正在读取公开可见成员…

+ ) : null} + {visibleMembersState === 'ready' ? ( + visibleMembers.length > 0 ? ( + <> +
    + {visibleMembers.map((member) => ( +
  • + + {member.title?.trim() || '(无标题)'}{' '} + {member.gameId} + + void removeMember(member.gameId)} + > + {memberBusyId === member.gameId ? '移除中…' : '移除'} + +
  • + ))} +
+ {visibleMembersTruncated ? ( +

+ 公开可见成员较多,这里只显示服务端返回的前 {visibleMembers.length}{' '} + 个(总数见上方「成员数」);其余成员请按根作品 ID 移除。 +

+ ) : null} + + ) : ( +

+ 这个主题当前没有对公开侧可见的成员(成员行总数见上方「成员数」)。 +

+ ) + ) : null} + {visibleMembersState === 'unavailable' ? ( +

+ 该主题不是「已发布」状态,公开侧读不到成员列表(后台暂无成员读取接口), + 只能按下面的根作品 ID 逐条移除。 +

+ ) : null} + + + {busy ? '提交中…' : '添加成员'} + + setMemberTheme(null)} + > + 关闭 + + + {/* + 服务端没有「后台读取主题成员」的接口(只有 PUT/DELETE 按根作品 ID 操作),因此这里 + 不假装给出一份成员名单:只能按 ID 移除。列表里的成员数是成员行总数。 + */} +

+ 目前只能按根作品 ID 移除成员(后台暂无成员读取接口);成员总数见上方「成员数」。 +

+
+ ) : null} + + {writeConfirm.confirmDialog} +
+ ); +} + +function ThemeFormFields({ + draft, + disabled, + namePrefix, + onChange, +}: { + draft: ThemeFormDraft; + disabled: boolean; + namePrefix: string; + onChange: (draft: ThemeFormDraft) => void; +}) { + return ( + <> + + + onChange({ ...draft, name: event.target.value }) + } + disabled={disabled} + /> + + +