From 620ac52461faf4fd81f2d6c55dcb5d6efcc8a4d9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 16:15:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=B8=B8=E6=88=8F=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E9=A1=B5=E5=B1=95=E7=A4=BA=E6=B8=B8=E6=88=8F=E5=90=8D?= =?UTF-8?q?=E4=B8=8E=E4=BD=9C=E8=80=85=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20gameId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminGameDistributionReviewPage.tsx:待审列表只回版本字段,新增 resolveGameMeta 用既有后台作品列表接口按 gameId 反查标题/作者,逐游戏去重并按会话缓存;单个游戏取不到只回落显示 gameId,不阻断审核列表 - 同文件:loadReviews 在拿到 entries 后并行补齐本页 distinct gameId 的元数据 - 同文件:游戏列由单个 gameId 文本改为「游戏名(主行)+ 作者名与 user_id(副行,截断显示,完整值放 title)」 - adminGameDistributionReview.css:以 .admin-game-review-game 网格布局替换 .admin-game-review-game-id,保留窄视口截断与 title 出口 - AdminGameDistributionReviewPage.test.tsx:补 listAdminGameDistributionGames 的 mock 与游戏列表 fixture,断言由 gameId 改为游戏名 --- .../AdminGameDistributionReviewPage.test.tsx | 61 +++++++++--- .../pages/AdminGameDistributionReviewPage.tsx | 94 +++++++++++++++++-- .../src/pages/adminGameDistributionReview.css | 35 ++++--- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 4ef5bbf5e..3ca7ab6be 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -12,10 +12,12 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { createAdminGameDistributionPreviewSession, getAdminGameDistributionVersion, + listAdminGameDistributionGames, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; import type { + AdminGameDistributionGameListResponse, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, } from '../api/adminApiTypes'; @@ -34,6 +36,7 @@ vi.mock('../api/adminApiClient', () => ({ ), createAdminGameDistributionPreviewSession: vi.fn(), getAdminGameDistributionVersion: vi.fn(), + listAdminGameDistributionGames: vi.fn(), listAdminGameDistributionReviews: vi.fn(), reviewAdminGameDistributionVersion: vi.fn(), })); @@ -51,6 +54,28 @@ const entry: AdminGameDistributionReviewEntry = { updatedAt: '1791008678.817257Z', }; +/** 游戏名与作者由既有后台作品列表接口按 gameId 反查(待审列表本身不含这两个字段)。 */ +const gameTitle = '测试游戏名'; +const gameListResponse: AdminGameDistributionGameListResponse = { + games: [ + { + gameId: 'game_1', + title: gameTitle, + author: { id: 'user-9f1', name: '136****8867', avatarUrl: null }, + status: 'unpublished', + versionCount: 1, + playCount: 0, + activeVersionId: null, + publicationRevision: 4, + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', + deletedAt: null, + versions: [], + }, + ], + nextCursor: null, +}; + const detailResponse: AdminGameDistributionVersionDetailResponse = { game: { id: 'game_1', @@ -81,6 +106,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = { beforeEach(() => { vi.mocked(listAdminGameDistributionReviews).mockReset(); + vi.mocked(listAdminGameDistributionGames).mockReset(); vi.mocked(reviewAdminGameDistributionVersion).mockReset(); vi.mocked(getAdminGameDistributionVersion).mockReset(); vi.mocked(createAdminGameDistributionPreviewSession).mockReset(); @@ -88,6 +114,7 @@ beforeEach(() => { entries: [entry], nextCursor: null, }); + vi.mocked(listAdminGameDistributionGames).mockResolvedValue(gameListResponse); }); test('通过审核只提交当前 publicationRevision 并刷新列表', async () => { @@ -102,7 +129,7 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async () onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); expect(screen.queryByLabelText('发行入口')).toBeNull(); expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull(); @@ -141,7 +168,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => { onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); const dialog = await screen.findByRole('dialog'); @@ -176,7 +203,7 @@ test('待审版本行内只保留版本级审核动作,不提供游戏级安 onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); // 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页; // 审核一个新版本时不该出现该动作。 @@ -197,9 +224,9 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); - const row = screen.getByText('game_1').closest('tr'); + const row = screen.getByText(gameTitle).closest('tr'); expect(row).not.toBeNull(); const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 @@ -216,7 +243,7 @@ test('取消理由输入时不做审核操作', async () => { onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); const dialog = await screen.findByRole('dialog'); @@ -226,17 +253,29 @@ test('取消理由输入时不做审核操作', async () => { expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); }); -test('窄视口下 gameId 截断显示,完整值由 title 暴露', async () => { +test('游戏列显示游戏名与作者,不再把 gameId 当主展示', async () => { render( , ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); - const gameIdCell = screen.getByTitle('game_1'); - expect(gameIdCell.textContent).toBe('game_1'); + // 按 gameId 反查一次,第二次刷新走缓存(本次只加载一次列表)。 + expect(listAdminGameDistributionGames).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ keyword: 'game_1' }), + ); + + const row = screen.getByText(gameTitle).closest('tr'); + expect(row).not.toBeNull(); + const gameCell = within(row as HTMLElement).getAllByRole('cell')[0]; + // 主展示是游戏名 + 打码作者名;gameId 只留在悬浮标题里。 + expect(gameCell?.textContent).toContain('136****8867'); + expect(gameCell?.textContent).not.toContain('game_1'); + expect(screen.getByTitle('user-9f1').textContent).toBe('user-9f1'); + expect(gameCell?.querySelector('[title="game_1"]')).not.toBeNull(); }); test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => { @@ -254,7 +293,7 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); fireEvent.click(screen.getByRole('button', { name: '详情' })); expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 8496f940f..f63a9c79e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -13,12 +13,13 @@ import { TextField, } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createAdminGameDistributionPreviewSession, getAdminAssetReadUrl, getAdminGameDistributionVersion, + listAdminGameDistributionGames, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; @@ -38,6 +39,13 @@ interface ReviewReasonPrompt { entry: AdminGameDistributionReviewEntry; } +/** 待审列表要展示的游戏名与作者:由既有后台作品列表接口按 gameId 反查得到。 */ +interface ReviewGameMeta { + title: string; + authorName: string; + authorId: string; +} + function applyFrozenGameMetadata( game: AdminGameDistributionGameDetail, metadata: Record | null, @@ -137,6 +145,56 @@ export function AdminGameDistributionReviewPage({ const [previewError, setPreviewError] = useState(''); const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [assetUrls, setAssetUrls] = useState>({}); + const [gameMetaByGameId, setGameMetaByGameId] = useState< + Record + >({}); + // 同一 gameId 的标题/作者在会话内不变,缓存避免每次刷新重复反查。 + const gameMetaCacheRef = useRef>({}); + + /** + * 待审列表只回版本字段(无游戏名/作者),这里用既有后台作品列表接口按 gameId 反查, + * 逐游戏一次请求并去重;单个游戏取不到只回落显示 gameId,不阻断审核列表。 + */ + const resolveGameMeta = useCallback( + async (gameIds: string[]) => { + const resolved = await Promise.all( + gameIds.map(async (gameId) => { + const cached = gameMetaCacheRef.current[gameId]; + if (cached) { + return [gameId, cached] as const; + } + try { + const response = await listAdminGameDistributionGames(token, { + limit: 5, + keyword: gameId, + }); + const matched = response.games.find( + (game) => game.gameId === gameId, + ); + if (!matched) return null; + const meta: ReviewGameMeta = { + title: matched.title?.trim() || matched.gameId, + authorName: matched.author?.name?.trim() || '', + authorId: matched.author?.id?.trim() || '', + }; + gameMetaCacheRef.current[gameId] = meta; + return [gameId, meta] as const; + } catch { + return null; + } + }), + ); + const merged = Object.fromEntries( + resolved.filter( + (item): item is readonly [string, ReviewGameMeta] => item !== null, + ), + ); + if (Object.keys(merged).length > 0) { + setGameMetaByGameId((current) => ({ ...current, ...merged })); + } + }, + [token], + ); const loadReviews = useCallback(async () => { setIsLoading(true); @@ -144,12 +202,15 @@ export function AdminGameDistributionReviewPage({ try { const response = await listAdminGameDistributionReviews(token); setEntries(response.entries); + await resolveGameMeta([ + ...new Set(response.entries.map((entry) => entry.gameId)), + ]); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { setIsLoading(false); } - }, [token, onUnauthorized]); + }, [token, onUnauthorized, resolveGameMeta]); useEffect(() => { void loadReviews(); @@ -381,15 +442,32 @@ export function AdminGameDistributionReviewPage({ ]} renderRow={(entry) => { const busy = busyVersionId === entry.versionId; + const gameMeta = gameMetaByGameId[entry.gameId]; return ( - - {entry.gameId} - + {/* gameId 不再作为主展示,只留在悬浮标题里做兜底标识。 */} +
+ + {gameMeta?.title ?? entry.gameId} + + + + {gameMeta?.authorName || '—'} + + {gameMeta?.authorId ? ( + + {gameMeta.authorId} + + ) : null} + +
v{entry.versionNumber} diff --git a/apps/admin-web/src/pages/adminGameDistributionReview.css b/apps/admin-web/src/pages/adminGameDistributionReview.css index f41d65066..2eed66619 100644 --- a/apps/admin-web/src/pages/adminGameDistributionReview.css +++ b/apps/admin-web/src/pages/adminGameDistributionReview.css @@ -56,22 +56,35 @@ table.admin-game-review-table td .admin-muted-text { justify-content: flex-end; } -/* gameId 是 37 字符的定长标识:窄视口截断显示,靠 title 暴露完整值。 */ -.admin-game-review-game-id { - display: block; +/* 游戏列:主行游戏名、副行作者(打码名 + user_id 截断,完整值放 title)。 */ +.admin-game-review-game { + display: grid; + min-width: 0; + gap: 2px; +} + +.admin-game-review-game-author { + display: flex; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +.admin-game-review-game-author-name { + flex: 0 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* - * 窄视口(≤1366)下把 gameId 降到 12px:13px 时它的文本宽 259px 只比列宽多 1px 的余量, - * 字体回退或换行差异都会让它变成省略号;降到 12px 留出约 19px 余量,完整标识仍然可见。 - */ -@media (max-width: 1366px) { - table.admin-game-review-table td:first-child code { - font-size: 12px; - } +.admin-game-review-game-author-id { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.75; } /* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 gameId 的宽度。 */