From 072284b84645858fca6760febefa04f9e0b6c855 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:17:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=B8=B8=E6=88=8F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=AE=A1=E6=A0=B8=E9=A1=B5=E6=8E=92=E7=89=88?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=8C=E5=AE=89=E5=85=A8=E4=B8=8B=E6=9E=B6?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=94=B6=E6=95=9B=E5=88=B0=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 游戏管理页:下架改为独立面板(原因必填、校验提示、仍走二次确认),操作列与作者列排版调整,gameId 省略号加 title - 游戏管理页测试:拆成空原因不提交、面板取消不请求、面板确认后仍需二次确认三条 - 审核页:移除行内游戏级安全下架及其状态与二次确认接线,理由弹窗只服务拒绝,gameId 截断加 title - 审核页测试:删除两条安全下架用例,替换为行内无该动作、microsZ 时间列渲染、窄视口 gameId 截断 - 新增 adminGameDistributionReview.css:审核页专属列宽与动作列 nowrap,含 1366/980 两档断点 - admin-web admin.css:新增游戏管理表格 5 条排版规则 - shared AdminFormat:新增 normalizeAdminTimestamp,兼容后端 seconds.microsZ 时间口径 - shared AdminFormat 测试:新增 5 条覆盖微秒口径、补零、ISO 与 epoch 兼容、非法值回落 - shared admin.css:admin-status 改为 nowrap - 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作 --- .../AdminGameDistributionReviewPage.test.tsx | 98 ++++------- .../pages/AdminGameDistributionReviewPage.tsx | 164 ++++++++---------- .../pages/AdminGameManagementPage.test.tsx | 54 +++++- .../src/pages/AdminGameManagementPage.tsx | 162 ++++++++++++----- .../src/pages/adminGameDistributionReview.css | 93 ++++++++++ apps/admin-web/src/styles/admin.css | 33 ++++ ...】后台游戏审核详情与待审版本试玩-2026-10-02.md | 4 +- .../src/components/admin/AdminFormat.test.ts | 46 +++++ .../src/components/admin/AdminFormat.ts | 33 +++- .../shared/src/components/admin/admin.css | 3 +- 10 files changed, 475 insertions(+), 215 deletions(-) create mode 100644 apps/admin-web/src/pages/adminGameDistributionReview.css create mode 100644 packages/shared/src/components/admin/AdminFormat.test.ts diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 8d312eeb0..4ef5bbf5e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -14,7 +14,6 @@ import { getAdminGameDistributionVersion, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, - suspendAdminGameDistributionGame, } from '../api/adminApiClient'; import type { AdminGameDistributionReviewEntry, @@ -37,8 +36,6 @@ vi.mock('../api/adminApiClient', () => ({ getAdminGameDistributionVersion: vi.fn(), listAdminGameDistributionReviews: vi.fn(), reviewAdminGameDistributionVersion: vi.fn(), - suspendAdminGameDistributionGame: vi.fn(), - })); const entry: AdminGameDistributionReviewEntry = { versionId: 'version-1', @@ -49,8 +46,9 @@ const entry: AdminGameDistributionReviewEntry = { status: 'pending_review', publicationRevision: 4, reviewReason: null, - createdAt: '2026-09-20T08:00:00Z', - updatedAt: '2026-09-20T08:00:00Z', + // 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。 + createdAt: '1791008678.817257Z', + updatedAt: '1791008678.817257Z', }; const detailResponse: AdminGameDistributionVersionDetailResponse = { @@ -84,7 +82,6 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = { beforeEach(() => { vi.mocked(listAdminGameDistributionReviews).mockReset(); vi.mocked(reviewAdminGameDistributionVersion).mockReset(); - vi.mocked(suspendAdminGameDistributionGame).mockReset(); vi.mocked(getAdminGameDistributionVersion).mockReset(); vi.mocked(createAdminGameDistributionPreviewSession).mockReset(); vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ @@ -172,17 +169,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => { }); }); -test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => { - vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ - game: { - id: 'game_1', - title: '测试游戏', - status: 'suspended', - publicationRevision: 5, - }, - replayed: false, - }); - +test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => { render( - expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), +test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => { + render( + , + ); + await screen.findByText('game_1'); + + const row = screen.getByText('game_1').closest('tr'); + expect(row).not.toBeNull(); + const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; + // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 + expect(submittedCell?.textContent).not.toBe('-'); + expect(submittedCell?.textContent).toMatch( + /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u, ); - const [token, gameId, idempotencyKey, payload] = - vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; - expect(token).toBe('admin-token'); - expect(gameId).toBe('game_1'); - expect(String(idempotencyKey)).toContain('game_1'); - expect(payload).toEqual({ - expectedPublicationRevision: 4, - reason: '盗用素材', - }); - expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); }); test('取消理由输入时不做审核操作', async () => { @@ -239,7 +226,7 @@ test('取消理由输入时不做审核操作', async () => { expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); }); -test('取消安全下架确认时不下架', async () => { +test('窄视口下 gameId 截断显示,完整值由 title 暴露', async () => { render( { ); await screen.findByText('game_1'); - fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - const reasonDialog = await screen.findByRole('dialog'); - fireEvent.change( - within(reasonDialog).getByRole('textbox', { name: '下架原因' }), - { - target: { value: '盗用素材' }, - }, - ); - fireEvent.click( - within(reasonDialog).getByRole('button', { name: '继续下架' }), - ); - - await screen.findByText('确认操作'); - const confirmDialog = screen.getByRole('dialog'); - fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + const gameIdCell = screen.getByTitle('game_1'); + expect(gameIdCell.textContent).toBe('game_1'); }); test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => { vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({ - previewUrl: 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', expiresAt: '2026-10-02T12:10:00Z', versionId: 'version-1', }); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 2b245516e..8496f940f 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -1,3 +1,5 @@ +import './adminGameDistributionReview.css'; + import { AdminActionRow, AdminAlert, @@ -19,14 +21,12 @@ import { getAdminGameDistributionVersion, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, - suspendAdminGameDistributionGame, } from '../api/adminApiClient'; import type { AdminGameDistributionGameDetail, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, } from '../api/adminApiTypes'; -import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { handlePageError } from './pageUtils'; interface AdminGameDistributionReviewPageProps { @@ -35,7 +35,6 @@ interface AdminGameDistributionReviewPageProps { } interface ReviewReasonPrompt { - decision: 'reject' | 'suspend'; entry: AdminGameDistributionReviewEntry; } @@ -51,7 +50,10 @@ function applyFrozenGameMetadata( ? item.objectKey : null, ) - .filter((value): value is string => typeof value === 'string' && Boolean(value.trim())) + .filter( + (value): value is string => + typeof value === 'string' && Boolean(value.trim()), + ) : game.screenshots; const deviceSupport = metadata.deviceSupport && typeof metadata.deviceSupport === 'object' @@ -60,12 +62,18 @@ function applyFrozenGameMetadata( return { ...game, title: typeof metadata.title === 'string' ? metadata.title : game.title, - summary: typeof metadata.summary === 'string' ? metadata.summary : game.summary, + summary: + typeof metadata.summary === 'string' ? metadata.summary : game.summary, description: - typeof metadata.description === 'string' ? metadata.description : game.description, - category: typeof metadata.category === 'string' ? metadata.category : game.category, + typeof metadata.description === 'string' + ? metadata.description + : game.description, + category: + typeof metadata.category === 'string' ? metadata.category : game.category, tags: Array.isArray(metadata.tags) - ? metadata.tags.filter((value): value is string => typeof value === 'string') + ? metadata.tags.filter( + (value): value is string => typeof value === 'string', + ) : game.tags, coverObjectKey: typeof metadata.coverObjectKey === 'string' @@ -74,10 +82,14 @@ function applyFrozenGameMetadata( screenshots, deviceSupport, inputModes: Array.isArray(metadata.inputModes) - ? metadata.inputModes.filter((value): value is string => typeof value === 'string') + ? metadata.inputModes.filter( + (value): value is string => typeof value === 'string', + ) : game.inputModes, orientation: - typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, + typeof metadata.orientation === 'string' + ? metadata.orientation + : game.orientation, }; } @@ -91,14 +103,6 @@ function formatBytes(value: number) { return `${value} B`; } -function createSuspendIdempotencyKey(gameId: string) { - const random = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `game-suspend-${gameId}-${random}`.slice(0, 128); -} - function createReviewIdempotencyKey(versionId: string) { const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto @@ -123,8 +127,6 @@ export function AdminGameDistributionReviewPage({ ); const [reasonDraft, setReasonDraft] = useState(''); const [reasonError, setReasonError] = useState(''); - const [busyGameId, setBusyGameId] = useState(''); - const writeConfirm = useAdminWriteConfirm(); const [selectedEntry, setSelectedEntry] = useState(null); const [detail, setDetail] = @@ -172,9 +174,10 @@ export function AdminGameDistributionReviewPage({ ); const reviewDetail = { ...response, game: reviewGame }; setDetail(reviewDetail); - const objectKeys = [reviewGame.coverObjectKey, ...reviewGame.screenshots].filter( - (value): value is string => Boolean(value?.trim()), - ); + const objectKeys = [ + reviewGame.coverObjectKey, + ...reviewGame.screenshots, + ].filter((value): value is string => Boolean(value?.trim())); const resolvedUrls = await Promise.all( objectKeys.map(async (objectKey) => { try { @@ -267,48 +270,13 @@ export function AdminGameDistributionReviewPage({ } /** - * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 - * 由服务端返回冲突,前端只提示刷新,不静默重试。 + * 待审版本行内只做版本级动作(详情 / 通过 / 拒绝)。管理员「安全下架」是游戏级 + * 动作(强制下线线上版本),入口在后台「游戏管理」页,不在审核新版本的语境里。 */ - async function suspendGame( - entry: AdminGameDistributionReviewEntry, - reason: string, - ) { - const trimmedReason = reason.trim(); - const confirmed = await writeConfirm.confirmWrite({ - action: '安全下架游戏', - target: `${entry.gameId}(版本 v${entry.versionNumber})`, - }); - if (!confirmed) return; - setBusyGameId(entry.gameId); - setErrorMessage(''); - setStatusMessage(''); - try { - await suspendAdminGameDistributionGame( - token, - entry.gameId, - createSuspendIdempotencyKey(entry.gameId), - { - expectedPublicationRevision: entry.publicationRevision, - ...(trimmedReason ? { reason: trimmedReason } : {}), - }, - ); - setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); - await loadReviews(); - } catch (error) { - handlePageError(error, onUnauthorized, setErrorMessage); - } finally { - setBusyGameId(''); - } - } - - function openReasonPrompt( - entry: AdminGameDistributionReviewEntry, - decision: ReviewReasonPrompt['decision'], - ) { + function openReasonPrompt(entry: AdminGameDistributionReviewEntry) { setReasonDraft(''); setReasonError(''); - setReasonPrompt({ decision, entry }); + setReasonPrompt({ entry }); } function closeReasonPrompt() { @@ -321,18 +289,14 @@ export function AdminGameDistributionReviewPage({ if (!reasonPrompt) return; const reason = reasonDraft.trim(); - if (reasonPrompt.decision === 'reject' && !reason) { + if (!reason) { setReasonError('拒绝审核必须填写理由'); return; } - const { decision, entry } = reasonPrompt; + const { entry } = reasonPrompt; closeReasonPrompt(); - if (decision === 'reject') { - void submitReview(entry, 'reject', reason); - return; - } - void suspendGame(entry, reason); + void submitReview(entry, 'reject', reason); } if (selectedEntry) { @@ -355,7 +319,7 @@ export function AdminGameDistributionReviewPage({ } : selectedEntry; closeDetails(); - openReasonPrompt(expectedEntry, 'reject'); + openReasonPrompt(expectedEntry); }} onApprove={() => { const expectedEntry = detail @@ -407,6 +371,7 @@ export function AdminGameDistributionReviewPage({ extra={共 {entries.length} 条} rows={entries} size="wide" + tableClassName="admin-game-review-table" columns={[ { key: 'gameId', label: '游戏' }, { key: 'versionNumber', label: '版本' }, @@ -419,7 +384,12 @@ export function AdminGameDistributionReviewPage({ return ( - {entry.gameId} + + {entry.gameId} + v{entry.versionNumber} @@ -435,7 +405,7 @@ export function AdminGameDistributionReviewPage({ {formatAdminDateTime(entry.createdAt)} - + openReasonPrompt(entry, 'reject')} + onClick={() => openReasonPrompt(entry)} > 拒绝 - openReasonPrompt(entry, 'suspend')} - > - {busyGameId === entry.gameId ? '正在下架…' : '安全下架'} - @@ -478,7 +440,7 @@ export function AdminGameDistributionReviewPage({ {reasonPrompt ? ( - {reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'} + 确认拒绝 } @@ -513,7 +473,7 @@ export function AdminGameDistributionReviewPage({ ) : null} - {writeConfirm.confirmDialog} ); } @@ -559,7 +518,9 @@ function AdminGameDistributionReviewDetailView({ {isDetailLoading ? 正在加载审核详情… : null} - {detailError ? {detailError} : null} + {detailError ? ( + {detailError} + ) : null} 返回审核列表 @@ -577,7 +538,9 @@ function AdminGameDistributionReviewDetailView({ } /> - {detailError ? {detailError} : null} + {detailError ? ( + {detailError} + ) : null} {detail.version.packageSha256} }, + { + label: 'SHA-256', + value: {detail.version.packageSha256}, + }, ]} actions={ @@ -615,10 +584,13 @@ function AdminGameDistributionReviewDetailView({

版本审核

- 发布者:{detail.game.author.name} · {detail.game.author.id} + 发布者:{detail.game.author.name} ·{' '} + {detail.game.author.id}

{detail.version.reviewReason ? ( - 审核理由:{detail.version.reviewReason} + + 审核理由:{detail.version.reviewReason} + ) : null} {detail.version.frozenMetadata ? (
@@ -628,7 +600,9 @@ function AdminGameDistributionReviewDetailView({
) : null} - {previewError ? {previewError} : null} + {previewError ? ( + {previewError} + ) : null} {previewUrl ? (