From 562549c9821198bdc11d6027ae252fb1b5fea5bf Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 04:34:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(=E5=90=8E=E5=8F=B0):=20=E5=85=B1?= =?UTF-8?q?=E5=88=9B=E4=B8=BB=E9=A2=98=E6=94=B9=E7=94=A8=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E7=B1=BB=E5=9E=8B=20+=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=88=90=E5=91=98=E5=90=8D=E5=8D=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 类型去重:`api/adminGameThemeTypes.ts` 不再本地定义任何载荷,改成对 `packages/shared/src/contracts/gameDistribution` 的**纯透传**(相对路径导入,不走包名——仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约只在本分支上,走包名会拿到缺类型的 旧副本,实测 TS2724)。页面与 api 客户端改用契约原名(`GameDistributionAdminTheme` 等), 只保留一个后台独有的查询值别名 `AdminThemeStatusFilter = GameDistributionThemeStatus | 'all'` (`all` 是接口查询白名单取值,不是主题状态本身,没有进契约)。删除重复定义后只剩一份真相。 2) 成员面板改用 `GET /admin/api/game-distribution/themes/{themeId}/members?limit=&cursor=`: - 草稿 / 归档主题也能读到成员名单(旧做法借公开投影,对未发布主题 404,看不到名单)——公开投影 那条路(`getPublicGameDistributionTheme`)整体删掉,不留两套读法。 - 每行展示 rootGameId / 标题(游戏行不存在时为「(游戏行不存在)」)/ 排序 / 挂载时间 + 可见性徽标:`visible` 与 `visibility` 按服务端事实**分别展示、互不推导**—— 「公开可见」;「已公开,但无公开版本」(`visible=false` + `visibility=published`,刻意要让人 看出来的异常);「作品未公开」;「作品已下架」;「作品已删除」;「游戏行不存在」。 - 分页:每页 20(上限 50,由 api 客户端按服务端口径夹住),`nextCursor` 驱动「加载更多成员」; 页脚显示「成员行总数 N;已加载 M 条」(`totalMembers` 不受分页影响,不与 `members.length` 互相推导)。 - 增删成员后**重读第一页**而不是本地删行(游标基于排序,本地删会让后续翻页错位); 读取失败如实报错(如非法游标 400),不渲染成「还没有成员」。 测试(10 条,含新增/改写 4 条):草稿主题也能读到名单并逐条移除(移除后重读第一页)、 「已公开但无公开版本」与其它不可见原因分别展示、游标加载更多(末页按钮消失)、 读取失败如实报错不显示空名单。 --- apps/admin-web/src/api/adminApiClient.ts | 67 +++-- apps/admin-web/src/api/adminGameThemeTypes.ts | 84 ++---- .../src/pages/AdminGameThemesPage.test.tsx | 233 ++++++++++++---- .../src/pages/AdminGameThemesPage.tsx | 262 ++++++++++++------ 4 files changed, 420 insertions(+), 226 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9bc5081a0..fbe12d92a 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -99,14 +99,15 @@ import type { ProfileWalletConfigAdminResponse, } from './adminApiTypes'; import type { - AdminCreateThemeRequest, - AdminGameDistributionThemeListResponse, - AdminGameDistributionThemeMemberResponse, - AdminGameDistributionThemeMutationResponse, - AdminPublicThemeDetail, AdminThemeStatusFilter, - AdminUpdateThemeRequest, - AdminUpsertThemeMemberRequest, + GameDistributionAdminThemeListResponse, + GameDistributionAdminThemeMemberListResponse, + GameDistributionAdminThemeMemberRemovalResponse, + GameDistributionAdminThemeMemberResponse, + GameDistributionAdminThemeMutationResponse, + GameDistributionCreateThemeRequest, + GameDistributionUpdateThemeRequest, + GameDistributionUpsertThemeMemberRequest, } from './adminGameThemeTypes'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; @@ -121,6 +122,10 @@ const ADMIN_API_BASE_URL = normalizeBaseUrl( */ const ADMIN_THEME_LIST_LIMIT_MAX = 200; +/** 后台成员名单每页条数:缺省与服务端一致,上限 50(超出服务端截断,这里先夹)。 */ +const ADMIN_THEME_MEMBER_PAGE_SIZE = 20; +const ADMIN_THEME_MEMBER_PAGE_SIZE_MAX = 50; + interface AdminRequestOptions { method?: string; token?: string; @@ -1527,7 +1532,7 @@ export function listAdminGameDistributionThemes( const params = new URLSearchParams({ limit: String(normalizedLimit) }); const status = options.status?.trim(); if (status && status !== 'all') params.set('status', status); - return request( + return request( `/admin/api/game-distribution/themes?${params.toString()}`, { token, signal }, ); @@ -1537,9 +1542,9 @@ export function listAdminGameDistributionThemes( export function createAdminGameDistributionTheme( token: string, idempotencyKey: string, - payload: AdminCreateThemeRequest, + payload: GameDistributionCreateThemeRequest, ) { - return request( + return request( '/admin/api/game-distribution/themes', { token, @@ -1555,9 +1560,9 @@ export function updateAdminGameDistributionTheme( token: string, themeId: string, idempotencyKey: string, - payload: AdminUpdateThemeRequest, + payload: GameDistributionUpdateThemeRequest, ) { - return request( + return request( `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`, { token, @@ -1577,40 +1582,54 @@ export function upsertAdminGameDistributionThemeMember( token: string, themeId: string, rootGameId: string, - payload: AdminUpsertThemeMemberRequest, + payload: GameDistributionUpsertThemeMemberRequest, ) { - return request( + return request( `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`, { token, method: 'PUT', body: payload }, ); } -/** 移除主题成员:不要求幂等键;成员不存在也算成功(200)。 */ +/** 移除主题成员:不要求幂等键;成员不存在也算成功(200,返回同一个形状)。 */ export function removeAdminGameDistributionThemeMember( token: string, themeId: string, rootGameId: string, ) { - return request( + return request( `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members/${encodeURIComponent(normalizeRootGameId(rootGameId))}`, { token, method: 'DELETE' }, ); } /** - * 公开主题详情(匿名单读)。 + * 后台主题成员名单:`GET …/themes/{themeId}/members?limit=&cursor=`。 * - * 后台用它列出**已发布**主题的当前可见成员:服务端目前没有「后台读成员」的接口(只有按根作品 ID - * 的 PUT/DELETE),可见成员能且只能从这条公开路径取得。未发布 / 已归档主题这里会 404,调用方按 - * 「拿不到成员列表」处理,不要把它渲染成空列表。 + * **不套公开可见性过滤**,草稿 / 归档主题照常可读——这正是这条接口存在的理由。每行带 + * `visible`(此刻公开投影是否包含它)与 `visibility`(为什么:`published`/`unpublished`/ + * `suspended`/`deleted`/`missing`)两个独立字段,界面不要互相推导。 + * + * `limit` 缺省 20 / 上限 50(服务端口径),这里按同一口径夹一次;`cursor` 用上一页的 `nextCursor`。 */ -export function getPublicGameDistributionTheme( +export function listAdminGameDistributionThemeMembers( + token: string, themeId: string, + options: { limit?: number; cursor?: string | null } = {}, signal?: AbortSignal, ) { - return request( - `/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}`, - { signal }, + const requestedLimit = options.limit ?? ADMIN_THEME_MEMBER_PAGE_SIZE; + const normalizedLimit = Number.isFinite(requestedLimit) + ? Math.min( + Math.max(Math.trunc(requestedLimit), 1), + ADMIN_THEME_MEMBER_PAGE_SIZE_MAX, + ) + : ADMIN_THEME_MEMBER_PAGE_SIZE; + const params = new URLSearchParams({ limit: String(normalizedLimit) }); + const cursor = options.cursor?.trim(); + if (cursor) params.set('cursor', cursor); + return request( + `/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members?${params.toString()}`, + { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminGameThemeTypes.ts b/apps/admin-web/src/api/adminGameThemeTypes.ts index 0c4b42433..81b4f6281 100644 --- a/apps/admin-web/src/api/adminGameThemeTypes.ts +++ b/apps/admin-web/src/api/adminGameThemeTypes.ts @@ -1,78 +1,34 @@ /** - * 后台「共创主题」接口的请求 / 响应载荷类型。 + * 后台「共创主题」接口用到的契约类型**透传**入口。 * - * 契约来源是 `packages/shared` 的 `contracts/gameDistribution`,这里用**相对路径直接引用**而不是 - * `@genarrative/shared`:仓库根的 `node_modules/@genarrative/shared` 软链指向主工作树,主题契约 - * 只在本分支上,走包名会拿到缺主题类型的旧副本(AGC 侧同样用这种相对引用)。 + * 这里**不再重复定义任何载荷**:主题的后台类型已在 + * `packages/shared/src/contracts/gameDistribution.ts` 登记(并纳入 DTO parity 的 + * `TS_ONLY_TYPES` / `RESPONSE_BUILDERS`),后台 UI 直接用契约名,避免两份定义并存漂移。 * - * 为什么单独一个文件而不是并进 `adminApiTypes.ts`:那边被 - * `scripts/check-game-distribution-dto-parity.mjs` 全量扫描(任何未登记的新类型都会判红),而主题 - * 后台响应是**服务端手拼 JSON**、没有对应 Rust DTO,登记只能改 `scripts/`(本轮范围外,且另一个 - * session 正在改它)。放在这里既不触发那条门禁,也不改共享契约。 + * 为什么用相对路径而不是包名 `@genarrative/shared`:仓库根的 + * `node_modules/@genarrative/shared` 软链指向**主工作树**,主题契约只在本分支上,走包名会拿到 + * 缺这些类型的旧副本(实测 TS2724)。AGC 侧同样用这种相对引用。 */ -import type { +export type { + GameDistributionAdminTheme, + GameDistributionAdminThemeListResponse, + GameDistributionAdminThemeMemberListResponse, + GameDistributionAdminThemeMemberRemovalResponse, + GameDistributionAdminThemeMemberResponse, + GameDistributionAdminThemeMemberRow, + GameDistributionAdminThemeMemberVisibility, + GameDistributionAdminThemeMutationResponse, 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; +import type { GameDistributionThemeStatus } from '../../../../packages/shared/src/contracts/gameDistribution'; /** - * 后台主题条目(`GET /admin/api/game-distribution/themes` 的 `themes[]` 与写接口的 `theme`)。 + * 后台主题列表的 `status` 查询值。 * - * `memberCount` 是**成员行总数**(含当前不可见的成员):后台没有「不该被探测」的顾虑,运营需要 - * 知道这个主题挂了几行;公开投影里的同名键是当前可见成员数,两者口径不同。 + * `all` 是**接口查询参数**的白名单取值(不过滤),不是主题状态本身,所以没有进共享契约。 */ -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; -} +export type AdminThemeStatusFilter = GameDistributionThemeStatus | 'all'; diff --git a/apps/admin-web/src/pages/AdminGameThemesPage.test.tsx b/apps/admin-web/src/pages/AdminGameThemesPage.test.tsx index 40602fb7f..a52386417 100644 --- a/apps/admin-web/src/pages/AdminGameThemesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameThemesPage.test.tsx @@ -12,13 +12,13 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { createAdminGameDistributionTheme, - getPublicGameDistributionTheme, + listAdminGameDistributionThemeMembers, listAdminGameDistributionThemes, removeAdminGameDistributionThemeMember, updateAdminGameDistributionTheme, upsertAdminGameDistributionThemeMember, } from '../api/adminApiClient'; -import type { AdminGameDistributionTheme } from '../api/adminGameThemeTypes'; +import type { GameDistributionAdminTheme } from '../api/adminGameThemeTypes'; import { AdminGameThemesPage } from './AdminGameThemesPage'; vi.mock('../api/adminApiClient', () => ({ @@ -37,10 +37,10 @@ vi.mock('../api/adminApiClient', () => ({ updateAdminGameDistributionTheme: vi.fn(), upsertAdminGameDistributionThemeMember: vi.fn(), removeAdminGameDistributionThemeMember: vi.fn(), - getPublicGameDistributionTheme: vi.fn(), + listAdminGameDistributionThemeMembers: vi.fn(), })); -const publishedTheme: AdminGameDistributionTheme = { +const publishedTheme: GameDistributionAdminTheme = { themeId: 'theme_1', name: '星际防线', summary: '同一母版下的二创合集', @@ -52,7 +52,7 @@ const publishedTheme: AdminGameDistributionTheme = { updatedAt: '2026-10-02T08:00:00Z', }; -const draftTheme: AdminGameDistributionTheme = { +const draftTheme: GameDistributionAdminTheme = { ...publishedTheme, themeId: 'theme_2', name: '草稿主题', @@ -62,7 +62,7 @@ const draftTheme: AdminGameDistributionTheme = { memberCount: 0, }; -function mockList(themes: AdminGameDistributionTheme[] = [publishedTheme]) { +function mockList(themes: GameDistributionAdminTheme[] = [publishedTheme]) { vi.mocked(listAdminGameDistributionThemes).mockResolvedValue({ themes }); } @@ -77,10 +77,13 @@ function renderPage( beforeEach(() => { vi.clearAllMocks(); mockList(); - // 成员面板打开时会读公开投影;默认给一个空 roots,个别用例再覆盖。 - vi.mocked(getPublicGameDistributionTheme).mockResolvedValue({ - roots: [], - } as never); + // 成员面板默认给一份空名单,个别用例再覆盖。 + vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({ + themeId: 'theme_1', + totalMembers: 0, + members: [], + nextCursor: null, + }); }); afterEach(() => { @@ -269,27 +272,106 @@ test('加非根作品:把 409 翻成「只能收录根作品」的可读原因 ); }); -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: '另一母版' }, +test('成员面板:草稿主题也能读到成员名单,逐条可移除', async () => { + mockList([draftTheme]); + vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({ + themeId: 'theme_2', + totalMembers: 2, + members: [ + { + rootGameId: 'game_root_1', + title: '母版作品', + sortOrder: 0, + createdAt: '2026-10-01T08:00:00Z', + visible: true, + visibility: 'published', + }, + { + rootGameId: 'game_root_2', + title: null, + sortOrder: 5, + createdAt: '2026-10-01T09:00:00Z', + visible: false, + visibility: 'missing', + }, ], - } as never); + nextCursor: null, + }); vi.mocked(removeAdminGameDistributionThemeMember).mockResolvedValue({ - themeId: 'theme_1', + themeId: 'theme_2', 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: '成员' })); + + // 草稿主题也走后台名单接口(不套公开可见性过滤,也不再借公开投影)。 + expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledWith( + 'admin-token', + 'theme_2', + { limit: 20 }, + expect.anything(), + ); + + const dialog = await screen.findByRole('dialog'); + const members = await within(dialog).findByRole('list', { + name: '主题成员名单', + }); + expect(within(members).getByText('母版作品')).toBeTruthy(); + // 游戏行不存在时标题为 null:仍要出现在名单里,否则与 totalMembers 对不上。 + expect(within(members).getByText('(游戏行不存在)')).toBeTruthy(); + expect(within(members).getByText('game_root_2')).toBeTruthy(); + expect(within(members).getByText('公开可见')).toBeTruthy(); + expect(within(members).getByText('游戏行不存在')).toBeTruthy(); + expect(within(dialog).getByText(/成员行总数 2;已加载 2 条/)).toBeTruthy(); + + // 只移除点中的那一条。 + fireEvent.click(within(members).getAllByRole('button', { name: '移除' })[0]!); + await waitFor(() => + expect(removeAdminGameDistributionThemeMember).toHaveBeenCalledWith( + 'admin-token', + 'theme_2', + 'game_root_1', + ), + ); + // 移除后重读第一页(不是本地删行:游标基于排序)。 + await waitFor(() => + expect(listAdminGameDistributionThemeMembers).toHaveBeenCalledTimes(2), + ); +}); + +test('成员面板:区分「已公开但无公开版本」与其它不可见原因', async () => { + vi.mocked(listAdminGameDistributionThemeMembers).mockResolvedValue({ + themeId: 'theme_1', + totalMembers: 3, + members: [ + { + rootGameId: 'game_root_a', + title: '已公开无版本', + sortOrder: 0, + createdAt: '2026-10-01T08:00:00Z', + // 关键异常态:游戏行 published 但此刻公开投影不含它。 + visible: false, + visibility: 'published', + }, + { + rootGameId: 'game_root_b', + title: '已下架', + sortOrder: 1, + createdAt: '2026-10-01T08:00:00Z', + visible: false, + visibility: 'suspended', + }, + { + rootGameId: 'game_root_c', + title: '已删除', + sortOrder: 2, + createdAt: '2026-10-01T08:00:00Z', + visible: false, + visibility: 'deleted', + }, + ], + nextCursor: null, }); renderPage(); const row = (await screen.findByText('星际防线')).closest('tr')!; @@ -297,37 +379,84 @@ test('成员面板:已发布主题列出可见成员并支持逐条移除', as const dialog = await screen.findByRole('dialog'); const members = await within(dialog).findByRole('list', { - name: '公开可见成员', + 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(); + // `visible` 与 `visibility` 独立:published + 不可见必须说清是「没有公开版本」。 + expect(within(members).getByText('已公开,但无公开版本')).toBeTruthy(); + expect(within(members).getByText('作品已下架')).toBeTruthy(); + expect(within(members).getByText('作品已删除')).toBeTruthy(); }); -test('成员面板:未发布主题明说读不到成员列表,而不是渲染成空', async () => { - mockList([draftTheme]); +test('成员面板:成员多于首页时可按游标加载更多', async () => { + vi.mocked(listAdminGameDistributionThemeMembers) + .mockResolvedValueOnce({ + themeId: 'theme_1', + totalMembers: 2, + members: [ + { + rootGameId: 'game_root_1', + title: '母版一', + sortOrder: 0, + createdAt: '2026-10-01T08:00:00Z', + visible: true, + visibility: 'published', + }, + ], + nextCursor: '0:theme_1:game_root_1', + }) + .mockResolvedValueOnce({ + themeId: 'theme_1', + totalMembers: 2, + members: [ + { + rootGameId: 'game_root_2', + title: '母版二', + sortOrder: 1, + createdAt: '2026-10-01T09:00:00Z', + visible: true, + visibility: 'published', + }, + ], + nextCursor: null, + }); renderPage(); - const row = (await screen.findByText('草稿主题')).closest('tr')!; + 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(); + await within(dialog).findByRole('list', { name: '主题成员名单' }); + fireEvent.click( + within(dialog).getByRole('button', { name: '加载更多成员' }), + ); + + await waitFor(() => + expect(listAdminGameDistributionThemeMembers).toHaveBeenLastCalledWith( + 'admin-token', + 'theme_1', + { limit: 20, cursor: '0:theme_1:game_root_1' }, + ), + ); + await waitFor(() => + expect(within(dialog).getByText('game_root_2')).toBeTruthy(), + ); + // 末页没有 nextCursor → 按钮消失。 + expect(within(dialog).queryByRole('button', { name: '加载更多成员' })).toBeNull(); +}); + +test('成员面板:读取失败时如实报错,不渲染成空名单', async () => { + vi.mocked(listAdminGameDistributionThemeMembers).mockRejectedValue( + Object.assign(new Error('游标非法'), { + status: 400, + code: 'THEME_INVALID_CURSOR', + }), + ); + 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(within(dialog).queryByText(/还没有成员/)).toBeNull(); }); test('401 时交给上层处理登录失效,不把错误塞进页面', async () => { diff --git a/apps/admin-web/src/pages/AdminGameThemesPage.tsx b/apps/admin-web/src/pages/AdminGameThemesPage.tsx index 07dab1be6..dd0bb2835 100644 --- a/apps/admin-web/src/pages/AdminGameThemesPage.tsx +++ b/apps/admin-web/src/pages/AdminGameThemesPage.tsx @@ -9,23 +9,26 @@ import { AdminPage, AdminPageHeading, AdminStatusPill, + formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Search } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createAdminGameDistributionTheme, - getPublicGameDistributionTheme, isAdminApiError, + listAdminGameDistributionThemeMembers, listAdminGameDistributionThemes, removeAdminGameDistributionThemeMember, updateAdminGameDistributionTheme, upsertAdminGameDistributionThemeMember, } from '../api/adminApiClient'; import type { - AdminGameDistributionTheme, - AdminThemeStatus, AdminThemeStatusFilter, + GameDistributionAdminTheme, + GameDistributionAdminThemeMemberRow, + GameDistributionAdminThemeMemberVisibility, + GameDistributionThemeStatus, } from '../api/adminGameThemeTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { handlePageError } from './pageUtils'; @@ -37,7 +40,7 @@ interface AdminGameThemesPageProps { /** 主题状态与文案:`archived` 后公开侧读不到,这一点必须在界面上说清。 */ const THEME_STATUS_META: Record< - AdminThemeStatus, + GameDistributionThemeStatus, { label: string; tone: AdminStatusTone } > = { draft: { label: '草稿', tone: 'pending' }, @@ -46,7 +49,7 @@ const THEME_STATUS_META: Record< }; const THEME_STATUS_OPTIONS: { - value: AdminThemeStatus; + value: GameDistributionThemeStatus; label: string; }[] = [ { value: 'draft', label: '草稿(公开侧不可读)' }, @@ -68,13 +71,15 @@ const THEME_SUMMARY_MAX_CHARS = 200; const THEME_BADGE_MAX_CHARS = 16; /** 列表 `limit` 上界:与服务端 `MAX_ADMIN_THEME_LIST_LIMIT` 同值。 */ const THEME_LIST_LIMIT = 200; +/** 成员名单每页条数:与服务端缺省值同值(上限 50 由 api 客户端夹住)。 */ +const MEMBER_PAGE_SIZE = 20; interface ThemeFormDraft { name: string; summary: string; badge: string; sortOrder: string; - status: AdminThemeStatus; + status: GameDistributionThemeStatus; } const EMPTY_THEME_FORM: ThemeFormDraft = { @@ -87,14 +92,14 @@ const EMPTY_THEME_FORM: ThemeFormDraft = { function themeStatusMeta(status: string) { return ( - THEME_STATUS_META[status as AdminThemeStatus] ?? { + THEME_STATUS_META[status as GameDistributionThemeStatus] ?? { label: status || '—', tone: 'pending' as AdminStatusTone, } ); } -function draftFromTheme(theme: AdminGameDistributionTheme): ThemeFormDraft { +function draftFromTheme(theme: GameDistributionAdminTheme): ThemeFormDraft { return { name: theme.name, summary: theme.summary, @@ -104,6 +109,34 @@ function draftFromTheme(theme: AdminGameDistributionTheme): ThemeFormDraft { }; } +/** + * 成员行的可见性文案。 + * + * `visible` 与 `visibility` 是**两个独立字段**,这里按服务端给的事实映射,不做互相推导: + * `visible === false` 且 `visibility === 'published'` 表示「作品已公开,但没有当前公开版本」—— + * 这是刻意要让人看出来的异常,不能糊成一句「不可见」。 + */ +function memberVisibilityMeta(member: GameDistributionAdminThemeMemberRow): { + label: string; + tone: AdminStatusTone; +} { + if (member.visible) { + return { label: '公开可见', tone: 'ok' }; + } + const reasons: Record = { + published: '已公开,但无公开版本', + unpublished: '作品未公开', + suspended: '作品已下架', + deleted: '作品已删除', + missing: '游戏行不存在', + }; + const reason = reasons[member.visibility] ?? '不可见'; + return { + label: reason, + tone: member.visibility === 'published' ? 'pending' : 'error', + }; +} + /** * 主题写操作的幂等键:**按请求指纹复用**。 * @@ -165,7 +198,7 @@ export function AdminGameThemesPage({ token, onUnauthorized, }: AdminGameThemesPageProps) { - const [themes, setThemes] = useState([]); + const [themes, setThemes] = useState([]); const [statusFilter, setStatusFilter] = useState('all'); const [draftFilter, setDraftFilter] = @@ -178,28 +211,30 @@ export function AdminGameThemesPage({ const [createDraft, setCreateDraft] = useState(EMPTY_THEME_FORM); const [createError, setCreateError] = useState(''); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState(null); const [editDraft, setEditDraft] = useState(EMPTY_THEME_FORM); const [editError, setEditError] = useState(''); const [memberTheme, setMemberTheme] = - useState(null); + 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` 当不变式。 + * `memberTotal` 是成员行总数、**不受分页影响**;`memberRows` 只是已加载的这几页, + * 两者不要互相推导。 */ - const [visibleMembersTruncated, setVisibleMembersTruncated] = useState(false); - const [visibleMembersState, setVisibleMembersState] = useState< - 'idle' | 'loading' | 'ready' | 'unavailable' + const [memberRows, setMemberRows] = useState< + GameDistributionAdminThemeMemberRow[] + >([]); + const [memberTotal, setMemberTotal] = useState(0); + const [memberNextCursor, setMemberNextCursor] = useState(null); + const [memberListState, setMemberListState] = useState< + 'idle' | 'loading' | 'ready' | 'error' >('idle'); + const [memberListError, setMemberListError] = useState(''); const [memberBusyId, setMemberBusyId] = useState(''); const [busy, setBusy] = useState(false); const writeConfirm = useAdminWriteConfirm(); @@ -244,51 +279,92 @@ export function AdminGameThemesPage({ }, [loadThemes, statusFilter]); /** - * 成员面板打开时取一次「公开可见成员」。 + * 成员面板打开时从**后台成员名单接口**读取首页。 * - * 只有已发布主题能从公开路径读到(其余状态会 404),因此未发布时标记为 unavailable 并如实说明, - * 不渲染成「没有成员」——那会让人以为主题是空的。 + * 这条接口不套公开可见性过滤,草稿 / 归档主题也照常可读——所以不再借公开投影(那条路对未发布 + * 主题会 404,只能看到「可见成员」,两种口径混在一页里更让人困惑)。 */ useEffect(() => { const themeId = memberTheme?.themeId; - const status = memberTheme?.status; if (!themeId) { - setVisibleMembers([]); - setVisibleMembersState('idle'); - return; - } - if (status !== 'published') { - setVisibleMembers([]); - setVisibleMembersState('unavailable'); + setMemberRows([]); + setMemberTotal(0); + setMemberNextCursor(null); + setMemberListState('idle'); + setMemberListError(''); 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'); + setMemberListState('loading'); + setMemberListError(''); + void listAdminGameDistributionThemeMembers( + token, + themeId, + { limit: MEMBER_PAGE_SIZE }, + controller.signal, + ) + .then((response) => { + setMemberRows(response.members ?? []); + setMemberTotal(response.totalMembers); + setMemberNextCursor(response.nextCursor); + setMemberListState('ready'); }) - .catch(() => { - if (!controller.signal.aborted) { - setVisibleMembers([]); - setVisibleMembersTruncated(false); - setVisibleMembersState('unavailable'); + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; } + setMemberRows([]); + setMemberTotal(0); + setMemberNextCursor(null); + setMemberListState('error'); + setMemberListError( + themeWriteErrorMessage(error) || '读取成员名单失败,请稍后重试', + ); }); return () => { controller.abort(); }; - }, [memberTheme?.themeId, memberTheme?.status]); + }, [memberTheme?.themeId, token]); + + /** 追加下一页成员(游标来自上一页的 `nextCursor`;非法游标服务端回 400,这里原样展示)。 */ + async function loadMoreMembers() { + const themeId = memberTheme?.themeId; + const cursor = memberNextCursor; + if (!themeId || !cursor || memberListState === 'loading') { + return; + } + setMemberListState('loading'); + setMemberListError(''); + try { + const response = await listAdminGameDistributionThemeMembers(token, themeId, { + limit: MEMBER_PAGE_SIZE, + cursor, + }); + setMemberRows((current) => [...current, ...(response.members ?? [])]); + setMemberTotal(response.totalMembers); + setMemberNextCursor(response.nextCursor); + setMemberListState('ready'); + } catch (error) { + setMemberListState('ready'); + setMemberListError(themeWriteErrorMessage(error)); + } + } + + /** 成员名单重新读第一页(增删成员后调用,避免游标失效)。 */ + async function reloadMembers(themeId: string) { + try { + const response = await listAdminGameDistributionThemeMembers(token, themeId, { + limit: MEMBER_PAGE_SIZE, + }); + setMemberRows(response.members ?? []); + setMemberTotal(response.totalMembers); + setMemberNextCursor(response.nextCursor); + setMemberListState('ready'); + } catch (error) { + setMemberListState('error'); + setMemberListError(themeWriteErrorMessage(error)); + } + } function themeFingerprint(action: string, payload: object) { return `${action}-${JSON.stringify(payload)}`; @@ -426,6 +502,7 @@ export function AdminGameThemesPage({ `已把 ${rootGameId} 加入主题《${memberTheme.name}》(重复添加只更新排序号)`, ); await loadThemes(statusFilter); + await reloadMembers(memberTheme.themeId); } catch (error) { setMemberError(themeWriteErrorMessage(error)); } finally { @@ -449,9 +526,8 @@ export function AdminGameThemesPage({ ); setMemberNotice(`已从主题《${memberTheme.name}》移除 ${rootGameId}`); await loadThemes(statusFilter); - setVisibleMembers((current) => - current.filter((member) => member.gameId !== rootGameId), - ); + // 重读第一页而不是本地删一行:游标基于排序,本地删会让后续翻页错位。 + await reloadMembers(memberTheme.themeId); } catch (error) { setMemberError(themeWriteErrorMessage(error)); } finally { @@ -758,51 +834,65 @@ export function AdminGameThemesPage({ {memberNotice} ) : null} {/* - 服务端没有「后台读取主题成员」的接口,只有按根作品 ID 的 PUT/DELETE。已发布主题能从 - 公开投影读到**当前可见成员**(可能与成员行总数不同),这里如实区分两种口径;未发布 / - 已归档主题读不到,就明说读不到,而不是渲染成空列表。 + 成员名单来自后台接口:**不套公开可见性过滤**,草稿 / 归档主题也照常可读,且每行都带 + `visible`(此刻公开投影是否包含它)与 `visibility`(为什么)。两者独立,界面不互相推导: + `published` + 不可见 = 「作品已公开、但没有公开版本」这种异常,必须能看出来。 */} - {visibleMembersState === 'loading' ? ( -

正在读取公开可见成员…

+ {memberListState === 'loading' && memberRows.length === 0 ? ( +

正在读取成员名单…

) : null} - {visibleMembersState === 'ready' ? ( - visibleMembers.length > 0 ? ( - <> -
    - {visibleMembers.map((member) => ( -
  • + {memberListError ? ( + + {memberListError} + + ) : null} + {memberRows.length > 0 ? ( + <> +
      + {memberRows.map((member) => { + const visibility = memberVisibilityMeta(member); + return ( +
    • - {member.title?.trim() || '(无标题)'}{' '} - {member.gameId} + {member.title?.trim() || '(游戏行不存在)'}{' '} + {member.rootGameId}{' '} + + 排序 {member.sortOrder} · 挂载于{' '} + {formatAdminDateTime(member.createdAt)} + + + {visibility.label} + void removeMember(member.gameId)} + onClick={() => void removeMember(member.rootGameId)} > - {memberBusyId === member.gameId ? '移除中…' : '移除'} + {memberBusyId === member.rootGameId ? '移除中…' : '移除'}
    • - ))} -
    - {visibleMembersTruncated ? ( -

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

    - ) : null} - - ) : ( + ); + })} +

- 这个主题当前没有对公开侧可见的成员(成员行总数见上方「成员数」)。 + 成员行总数 {memberTotal};已加载 {memberRows.length} 条。

- ) - ) : null} - {visibleMembersState === 'unavailable' ? ( + {memberNextCursor ? ( + void loadMoreMembers()} + > + {memberListState === 'loading' ? '加载中…' : '加载更多成员'} + + ) : null} + + ) : memberListState === 'ready' ? (

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

) : null} @@ -899,7 +989,7 @@ function ThemeFormFields({ onChange={(event) => onChange({ ...draft, - status: event.target.value as AdminThemeStatus, + status: event.target.value as GameDistributionThemeStatus, }) } disabled={disabled}