From 093f832ef9b7f70b01774889ea5514d709e0d417 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 23 Sep 2026 22:50:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=AE=A1=E6=A0=B8=E6=93=8D=E4=BD=9C=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除游戏审核页的发行地址提示 将拒绝理由与下架原因改为点击按钮后输入 补充审核与安全下架交互测试 --- .../AdminGameDistributionReviewPage.test.tsx | 99 +++++++++-- .../pages/AdminGameDistributionReviewPage.tsx | 158 +++++++++++------- 2 files changed, 185 insertions(+), 72 deletions(-) diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 85164f6ad..8291e815e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import { @@ -65,7 +71,9 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async () await screen.findByText('game_1'); expect(screen.queryByLabelText('发行入口')).toBeNull(); - expect(screen.getByText('通过后由系统分配发行地址')).toBeTruthy(); + expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull(); + expect(screen.queryByLabelText('拒绝理由')).toBeNull(); + expect(screen.queryByLabelText('下架原因')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => @@ -87,7 +95,12 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async () ); }); -test('缺少拒绝理由时不调用审核接口', async () => { +test('点击拒绝后填写理由再提交审核接口', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'rejected', reviewReason: '运行时报错' }, + replayed: false, + }); + render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + const reasonInput = within(dialog).getByRole('textbox', { + name: '拒绝理由', + }); - expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy(); expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); + + fireEvent.change(reasonInput, { target: { value: '运行时报错' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [, versionId, , payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(versionId).toBe('version-1'); + expect(payload).toEqual({ + decision: 'reject', + expectedPublicationRevision: 4, + reviewReason: '运行时报错', + }); }); -test('安全下架需要二次确认,并携带公开修订号与原因', async () => { +test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => { vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ game: { id: 'game_1', @@ -121,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('下架原因'), { - target: { value: '盗用素材' }, - }); 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: '继续下架' }), + ); - // 第一次点击只弹出确认面板,不直接调用后端。 - expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); - expect(await screen.findByRole('dialog')).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '确认' })); + await screen.findByText('确认操作'); + const confirmDialog = screen.getByRole('dialog'); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' })); await waitFor(() => expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), @@ -147,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); }); -test('取消确认时不下架', async () => { +test('取消理由输入时不做审核操作', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('取消安全下架确认时不下架', async () => { render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - await screen.findByRole('dialog'); - 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(); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 787bce570..7a3d8890e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -1,3 +1,4 @@ +import { Modal, TextField } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps { onUnauthorized: (message?: string) => void; } +interface ReviewReasonPrompt { + decision: 'reject' | 'suspend'; + entry: AdminGameDistributionReviewEntry; +} + function formatBytes(value: number) { if (value >= 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MiB`; @@ -58,12 +64,11 @@ export function AdminGameDistributionReviewPage({ const [busyVersionId, setBusyVersionId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); - const [reasonByVersion, setReasonByVersion] = useState< - Record - >({}); - const [suspendReasonByGame, setSuspendReasonByGame] = useState< - Record - >({}); + const [reasonPrompt, setReasonPrompt] = useState( + null, + ); + const [reasonDraft, setReasonDraft] = useState(''); + const [reasonError, setReasonError] = useState(''); const [busyGameId, setBusyGameId] = useState(''); const writeConfirm = useAdminWriteConfirm(); @@ -87,9 +92,10 @@ export function AdminGameDistributionReviewPage({ async function submitReview( entry: AdminGameDistributionReviewEntry, decision: 'approve' | 'reject', + reason = '', ) { - const reason = (reasonByVersion[entry.versionId] ?? '').trim(); - if (decision === 'reject' && !reason) { + const trimmedReason = reason.trim(); + if (decision === 'reject' && !trimmedReason) { setErrorMessage('拒绝审核必须填写理由'); return; } @@ -109,7 +115,7 @@ export function AdminGameDistributionReviewPage({ : { decision, expectedPublicationRevision: entry.publicationRevision, - reviewReason: reason, + reviewReason: trimmedReason, }, ); setStatusMessage( @@ -129,8 +135,11 @@ export function AdminGameDistributionReviewPage({ * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 * 由服务端返回冲突,前端只提示刷新,不静默重试。 */ - async function suspendGame(entry: AdminGameDistributionReviewEntry) { - const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + async function suspendGame( + entry: AdminGameDistributionReviewEntry, + reason: string, + ) { + const trimmedReason = reason.trim(); const confirmed = await writeConfirm.confirmWrite({ action: '安全下架游戏', target: `${entry.gameId}(版本 v${entry.versionNumber})`, @@ -146,11 +155,10 @@ export function AdminGameDistributionReviewPage({ createSuspendIdempotencyKey(entry.gameId), { expectedPublicationRevision: entry.publicationRevision, - ...(reason ? { reason } : {}), + ...(trimmedReason ? { reason: trimmedReason } : {}), }, ); setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); - setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); await loadReviews(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -159,6 +167,39 @@ export function AdminGameDistributionReviewPage({ } } + function openReasonPrompt( + entry: AdminGameDistributionReviewEntry, + decision: ReviewReasonPrompt['decision'], + ) { + setReasonDraft(''); + setReasonError(''); + setReasonPrompt({ decision, entry }); + } + + function closeReasonPrompt() { + setReasonPrompt(null); + setReasonDraft(''); + setReasonError(''); + } + + function confirmReasonPrompt() { + if (!reasonPrompt) return; + + const reason = reasonDraft.trim(); + if (reasonPrompt.decision === 'reject' && !reason) { + setReasonError('拒绝审核必须填写理由'); + return; + } + + const { decision, entry } = reasonPrompt; + closeReasonPrompt(); + if (decision === 'reject') { + void submitReview(entry, 'reject', reason); + return; + } + void suspendGame(entry, reason); + } + return (
@@ -237,11 +278,6 @@ export function AdminGameDistributionReviewPage({ {formatTime(entry.createdAt)}
-
- - 通过后由系统分配发行地址 - -
-
- - - setReasonByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setSuspendReasonByGame((current) => ({ - ...current, - [entry.gameId]: event.target.value, - })) - } - disabled={busy} - /> -
) : null}
+ {reasonPrompt ? ( + + + + + } + > + { + setReasonDraft(event.target.value); + if (reasonError) setReasonError(''); + }} + /> + + ) : null} {writeConfirm.confirmDialog}
);