优化后台游戏审核操作弹窗
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 3m48s
Project CI / Frontend tests (push) Successful in 2m4s
Project CI / Native shell tests (push) Successful in 5m57s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m15s
Project CI / Repository checks (push) Successful in 2m10s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m38s

移除游戏审核页的发行地址提示

将拒绝理由与下架原因改为点击按钮后输入

补充审核与安全下架交互测试
This commit is contained in:
2026-09-23 22:50:36 +08:00
parent cc958f3678
commit 093f832ef9
2 changed files with 185 additions and 72 deletions
@@ -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(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -97,12 +110,32 @@ test('缺少拒绝理由时不调用审核接口', async () => {
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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -157,8 +212,20 @@ test('取消确认时不下架', async () => {
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();
@@ -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<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
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 (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -237,11 +278,6 @@ export function AdminGameDistributionReviewPage({
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<span className="admin-muted-text">
</span>
</div>
<button
type="button"
className="admin-primary-button"
@@ -250,55 +286,19 @@ export function AdminGameDistributionReviewPage({
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
onClick={() => openReasonPrompt(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId
? '正在下架…'
@@ -314,6 +314,52 @@ export function AdminGameDistributionReviewPage({
</div>
) : null}
</div>
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
closeLabel="关闭理由输入"
onClose={closeReasonPrompt}
size="sm"
className="genarrative-ui"
footer={
<div className="admin-confirm-actions" style={{ width: '100%' }}>
<button
type="button"
className="admin-secondary-button"
onClick={closeReasonPrompt}
>
</button>
<button
type="button"
className={
reasonPrompt.decision === 'reject'
? 'admin-ghost-button'
: 'admin-danger-button'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</button>
</div>
}
>
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
value={reasonDraft}
error={reasonError}
rows={4}
onChange={(event) => {
setReasonDraft(event.target.value);
if (reasonError) setReasonError('');
}}
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</section>
);