refactor(后台): 共创主题改用共享契约类型 + 后台成员名单接口
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

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 条):草稿主题也能读到名单并逐条移除(移除后重读第一页)、
「已公开但无公开版本」与其它不可见原因分别展示、游标加载更多(末页按钮消失)、
读取失败如实报错不显示空名单。
This commit is contained in:
2026-10-06 04:34:29 +08:00
parent d6d3913320
commit 562549c982
4 changed files with 420 additions and 226 deletions
+43 -24
View File
@@ -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<AdminGameDistributionThemeListResponse>(
return request<GameDistributionAdminThemeListResponse>(
`/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<AdminGameDistributionThemeMutationResponse>(
return request<GameDistributionAdminThemeMutationResponse>(
'/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<AdminGameDistributionThemeMutationResponse>(
return request<GameDistributionAdminThemeMutationResponse>(
`/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<AdminGameDistributionThemeMemberResponse>(
return request<GameDistributionAdminThemeMemberResponse>(
`/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<AdminGameDistributionThemeMemberResponse>(
return request<GameDistributionAdminThemeMemberRemovalResponse>(
`/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<AdminPublicThemeDetail>(
`/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<GameDistributionAdminThemeMemberListResponse>(
`/admin/api/game-distribution/themes/${encodeURIComponent(normalizeThemeId(themeId))}/members?${params.toString()}`,
{ token, signal },
);
}
+20 -64
View File
@@ -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';
@@ -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 () => {
+176 -86
View File
@@ -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<GameDistributionAdminThemeMemberVisibility, string> = {
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<AdminGameDistributionTheme[]>([]);
const [themes, setThemes] = useState<GameDistributionAdminTheme[]>([]);
const [statusFilter, setStatusFilter] =
useState<AdminThemeStatusFilter>('all');
const [draftFilter, setDraftFilter] =
@@ -178,28 +211,30 @@ export function AdminGameThemesPage({
const [createDraft, setCreateDraft] =
useState<ThemeFormDraft>(EMPTY_THEME_FORM);
const [createError, setCreateError] = useState('');
const [editing, setEditing] = useState<AdminGameDistributionTheme | null>(null);
const [editing, setEditing] = useState<GameDistributionAdminTheme | null>(null);
const [editDraft, setEditDraft] = useState<ThemeFormDraft>(EMPTY_THEME_FORM);
const [editError, setEditError] = useState('');
const [memberTheme, setMemberTheme] =
useState<AdminGameDistributionTheme | null>(null);
useState<GameDistributionAdminTheme | null>(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<string | null>(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({
<AdminAlert tone="success">{memberNotice}</AdminAlert>
) : null}
{/*
服务端没有「后台读取主题成员」的接口,只有按根作品 ID 的 PUT/DELETE。已发布主题能从
公开投影读到**当前可见成员**(可能与成员行总数不同),这里如实区分两种口径;未发布 /
已归档主题读不到,就明说读不到,而不是渲染成空列表。
成员名单来自后台接口:**不套公开可见性过滤**,草稿 / 归档主题也照常可读,且每行都带
`visible`(此刻公开投影是否包含它)与 `visibility`(为什么)。两者独立,界面不互相推导:
`published` + 不可见 = 「作品已公开、但没有公开版本」这种异常,必须能看出来。
*/}
{visibleMembersState === 'loading' ? (
<p className="admin-muted-text">正在读取公开可见成员…</p>
{memberListState === 'loading' && memberRows.length === 0 ? (
<p className="admin-muted-text">正在读取成员名单…</p>
) : null}
{visibleMembersState === 'ready' ? (
visibleMembers.length > 0 ? (
<>
<ul className="admin-plain-list" aria-label="公开可见成员">
{visibleMembers.map((member) => (
<li key={member.gameId}>
{memberListError ? (
<AdminAlert tone="warning" role="alert">
{memberListError}
</AdminAlert>
) : null}
{memberRows.length > 0 ? (
<>
<ul className="admin-plain-list" aria-label="主题成员名单">
{memberRows.map((member) => {
const visibility = memberVisibilityMeta(member);
return (
<li key={member.rootGameId}>
<span>
{member.title?.trim() || '(无标题)'}{' '}
<code>{member.gameId}</code>
{member.title?.trim() || '(游戏行不存在)'}{' '}
<code>{member.rootGameId}</code>{' '}
<small className="admin-muted-text">
排序 {member.sortOrder} · 挂载于{' '}
{formatAdminDateTime(member.createdAt)}
</small>
</span>
<AdminStatusPill tone={visibility.tone}>
{visibility.label}
</AdminStatusPill>
<AdminButton
variant="secondary"
type="button"
disabled={busy}
onClick={() => void removeMember(member.gameId)}
onClick={() => void removeMember(member.rootGameId)}
>
{memberBusyId === member.gameId ? '移除中…' : '移除'}
{memberBusyId === member.rootGameId ? '移除中…' : '移除'}
</AdminButton>
</li>
))}
</ul>
{visibleMembersTruncated ? (
<p className="admin-muted-text">
公开可见成员较多,这里只显示服务端返回的前 {visibleMembers.length}{' '}
个(总数见上方「成员数」);其余成员请按根作品 ID 移除。
</p>
) : null}
</>
) : (
);
})}
</ul>
<p className="admin-muted-text">
这个主题当前没有对公开侧可见的成员(成员行总数见上方「成员数」)。
成员行总数 {memberTotal};已加载 {memberRows.length} 条。
</p>
)
) : null}
{visibleMembersState === 'unavailable' ? (
{memberNextCursor ? (
<AdminButton
variant="secondary"
type="button"
disabled={busy || memberListState === 'loading'}
onClick={() => void loadMoreMembers()}
>
{memberListState === 'loading' ? '加载中…' : '加载更多成员'}
</AdminButton>
) : null}
</>
) : memberListState === 'ready' ? (
<p className="admin-muted-text">
该主题不是「已发布」状态,公开侧读不到成员列表(后台暂无成员读取接口),
只能按下面的根作品 ID 逐条移除。
这个主题还没有成员;用上面的「根作品 ID」添加。
</p>
) : null}
<AdminActionRow>
@@ -899,7 +989,7 @@ function ThemeFormFields({
onChange={(event) =>
onChange({
...draft,
status: event.target.value as AdminThemeStatus,
status: event.target.value as GameDistributionThemeStatus,
})
}
disabled={disabled}