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
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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user