后台游戏管理与审核页排版修复,安全下架入口收敛到游戏管理页

- 游戏管理页:下架改为独立面板(原因必填、校验提示、仍走二次确认),操作列与作者列排版调整,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
- 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作
This commit is contained in:
2026-10-04 00:17:21 +08:00
parent 6c2518b763
commit 072284b846
10 changed files with 475 additions and 215 deletions
@@ -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(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -191,35 +178,35 @@ test('安全下架需要先填写原因,再二次确认并携带公开修订
);
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: '继续下架' }),
);
// 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页;
// 审核一个新版本时不该出现该动作。
expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull();
expect(screen.queryByText('继续下架')).toBeNull();
expect(screen.queryByLabelText('下架原因')).toBeNull();
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull();
expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy();
});
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -248,30 +235,15 @@ test('取消安全下架确认时不下架', async () => {
);
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',
});
@@ -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<AdminGameDistributionReviewEntry | null>(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={<span className="admin-muted-text">共 {entries.length} 条</span>}
rows={entries}
size="wide"
tableClassName="admin-game-review-table"
columns={[
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
@@ -419,7 +384,12 @@ export function AdminGameDistributionReviewPage({
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
<code
className="admin-game-review-game-id"
title={entry.gameId}
>
{entry.gameId}
</code>
</td>
<td>
v{entry.versionNumber}
@@ -435,7 +405,7 @@ export function AdminGameDistributionReviewPage({
</div>
</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
<td>
<td className="admin-game-review-cell--actions">
<AdminActionRow className="admin-review-actions">
<AdminButton
variant="secondary"
@@ -457,18 +427,10 @@ export function AdminGameDistributionReviewPage({
variant="ghost"
type="button"
disabled={busy}
onClick={() => openReasonPrompt(entry, 'reject')}
onClick={() => openReasonPrompt(entry)}
>
拒绝
</AdminButton>
<AdminButton
variant="danger"
type="button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId ? '正在下架…' : '安全下架'}
</AdminButton>
</AdminActionRow>
</td>
</tr>
@@ -478,7 +440,7 @@ export function AdminGameDistributionReviewPage({
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
title="拒绝审核"
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
closeLabel="关闭理由输入"
onClose={closeReasonPrompt}
@@ -500,12 +462,10 @@ export function AdminGameDistributionReviewPage({
</AdminButton>
<AdminButton
type="button"
variant={
reasonPrompt.decision === 'reject' ? 'ghost' : 'danger'
}
variant="ghost"
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
确认拒绝
</AdminButton>
</div>
}
@@ -513,7 +473,7 @@ export function AdminGameDistributionReviewPage({
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
label="拒绝理由"
value={reasonDraft}
error={reasonError}
rows={4}
@@ -524,7 +484,6 @@ export function AdminGameDistributionReviewPage({
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</AdminPage>
);
}
@@ -559,7 +518,9 @@ function AdminGameDistributionReviewDetailView({
<AdminPage wide>
<AdminPageHeading title="审核详情" />
{isDetailLoading ? <AdminAlert>正在加载审核详情…</AdminAlert> : null}
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
{detailError ? (
<AdminAlert tone="warning">{detailError}</AdminAlert>
) : null}
<AdminButton variant="secondary" type="button" onClick={onBack}>
返回审核列表
</AdminButton>
@@ -577,7 +538,9 @@ function AdminGameDistributionReviewDetailView({
</AdminButton>
}
/>
{detailError ? <AdminAlert tone="warning">{detailError}</AdminAlert> : null}
{detailError ? (
<AdminAlert tone="warning">{detailError}</AdminAlert>
) : null}
<GameDetailDisplay
className="platform-theme platform-theme--light admin-game-review-detail"
game={detail.game}
@@ -588,10 +551,16 @@ function AdminGameDistributionReviewDetailView({
versionLabel={`v${detail.version.versionNumber}`}
infoCards={[
{ label: '状态', value: detail.version.status },
{ label: '提交时间', value: formatAdminDateTime(detail.version.createdAt) },
{
label: '提交时间',
value: formatAdminDateTime(detail.version.createdAt),
},
{ label: '包大小', value: formatBytes(detail.version.packageBytes) },
{ label: '文件数', value: detail.version.packageFileCount },
{ label: 'SHA-256', value: <code>{detail.version.packageSha256}</code> },
{
label: 'SHA-256',
value: <code>{detail.version.packageSha256}</code>,
},
]}
actions={
<AdminActionRow className="admin-review-actions">
@@ -615,10 +584,13 @@ function AdminGameDistributionReviewDetailView({
<section>
<h2>版本审核</h2>
<p>
发布者:{detail.game.author.name} · <code>{detail.game.author.id}</code>
发布者:{detail.game.author.name} ·{' '}
<code>{detail.game.author.id}</code>
</p>
{detail.version.reviewReason ? (
<AdminAlert tone="warning">审核理由:{detail.version.reviewReason}</AdminAlert>
<AdminAlert tone="warning">
审核理由:{detail.version.reviewReason}
</AdminAlert>
) : null}
{detail.version.frozenMetadata ? (
<details>
@@ -628,7 +600,9 @@ function AdminGameDistributionReviewDetailView({
</pre>
</details>
) : null}
{previewError ? <AdminAlert tone="warning">{previewError}</AdminAlert> : null}
{previewError ? (
<AdminAlert tone="warning">{previewError}</AdminAlert>
) : null}
{previewUrl ? (
<iframe
title={`${detail.game.title} 待审版本试玩`}
@@ -218,20 +218,62 @@ test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
// 操作列不再内嵌输入框,点按钮才打开面板。
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
const reasonInput = within(dialog).getByLabelText('下架原因');
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
});
test('下架面板取消不触发请求', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
@@ -5,7 +5,6 @@ import {
AdminButton,
AdminDialog,
AdminField,
AdminFieldGroup,
AdminListPanel,
AdminPage,
AdminPageHeading,
@@ -60,6 +59,9 @@ const STATUS_FILTER_OPTIONS: {
const DEFAULT_PAGE_SIZE = 50;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
/** 下架原因必填;面板校验与提交前兜底共用同一句提示。 */
const SUSPEND_REASON_REQUIRED_MESSAGE = '请填写下架原因';
interface GameFilters {
keyword: string;
owner: string;
@@ -128,9 +130,11 @@ export function AdminGameManagementPage({
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
// 下架原因在下架面板内填写;面板打开状态、草稿与校验错误都由页面持有。
const [suspendTarget, setSuspendTarget] =
useState<AdminGameDistributionGameEntry | null>(null);
const [suspendReasonDraft, setSuspendReasonDraft] = useState('');
const [suspendReasonError, setSuspendReasonError] = useState('');
const [versionGame, setVersionGame] =
useState<AdminGameDistributionGameEntry | null>(null);
const writeConfirm = useAdminWriteConfirm();
@@ -237,10 +241,40 @@ export function AdminGameManagementPage({
filters.keyword || filters.owner || filters.status,
);
async function suspendGame(entry: AdminGameDistributionGameEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
function openSuspendPrompt(entry: AdminGameDistributionGameEntry) {
setSuspendReasonDraft('');
setSuspendReasonError('');
setSuspendTarget(entry);
}
function closeSuspendPrompt() {
setSuspendTarget(null);
setSuspendReasonDraft('');
setSuspendReasonError('');
}
/** 面板确认:先做必填校验,再走原有的二次确认与下架请求。 */
function confirmSuspendPrompt() {
if (!suspendTarget) return;
const reason = suspendReasonDraft.trim();
if (!reason) {
setErrorMessage('下架原因不能为空');
setSuspendReasonError(SUSPEND_REASON_REQUIRED_MESSAGE);
return;
}
const entry = suspendTarget;
closeSuspendPrompt();
void suspendGame(entry, reason);
}
async function suspendGame(
entry: AdminGameDistributionGameEntry,
reason: string,
) {
const trimmedReason = reason.trim();
if (!trimmedReason) {
setErrorMessage(SUSPEND_REASON_REQUIRED_MESSAGE);
setStatusMessage('');
return;
}
@@ -261,14 +295,10 @@ export function AdminGameManagementPage({
createGameActionIdempotencyKey('suspend', entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
reason,
reason: trimmedReason,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: '',
}));
refreshCurrentPage();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
@@ -418,6 +448,7 @@ export function AdminGameManagementPage({
}
rows={games}
size="wide"
tableClassName="admin-game-management-table"
columns={[
{ key: 'title', label: '标题' },
{ key: 'author', label: '作者' },
@@ -459,7 +490,7 @@ export function AdminGameManagementPage({
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td>
<td className="admin-game-author-cell">
<div
className="admin-database-user-cell"
style={{
@@ -484,14 +515,23 @@ export function AdminGameManagementPage({
authorInitial(entry)
)}
</div>
<div>
<span>{authorName(entry)}</span>
<small>{entry.author?.id?.trim() || '—'}</small>
<div className="admin-inline-identity">
<div>
<span>{authorName(entry)}</span>
<small title={entry.author?.id?.trim() || undefined}>
{entry.author?.id?.trim() || '—'}
</small>
</div>
</div>
</div>
</td>
<td>
<code>{entry.gameId}</code>
<code
className="admin-table-cell-ellipsis"
title={entry.gameId}
>
{entry.gameId}
</code>
</td>
<td>
<AdminStatusPill tone={status.tone}>
@@ -505,7 +545,7 @@ export function AdminGameManagementPage({
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<td className="admin-game-actions-cell">
<AdminActionRow>
{isDeleted ? null : isSuspended ? (
<AdminButton
@@ -517,32 +557,14 @@ export function AdminGameManagementPage({
{busy ? '处理中…' : '恢复'}
</AdminButton>
) : (
<>
<AdminFieldGroup
label="下架原因"
htmlFor={`game-suspend-reason-${entry.gameId}`}
>
<input
id={`game-suspend-reason-${entry.gameId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</AdminFieldGroup>
<AdminButton
variant="danger"
type="button"
disabled={busy}
onClick={() => void suspendGame(entry)}
>
{busy ? '处理中…' : '下架'}
</AdminButton>
</>
<AdminButton
variant="danger"
type="button"
disabled={busy}
onClick={() => openSuspendPrompt(entry)}
>
{busy ? '处理中…' : '下架'}
</AdminButton>
)}
<AdminButton
variant="secondary"
@@ -570,6 +592,58 @@ export function AdminGameManagementPage({
}}
/>
{suspendTarget ? (
<AdminDialog
as="form"
closeLabel="关闭下架面板"
description={
<>
{suspendTarget.title?.trim() || '—'}({suspendTarget.gameId})
</>
}
panelClassName="admin-confirm-panel"
title="下架游戏"
titleProps={{ id: 'admin-game-suspend-title' }}
onClose={closeSuspendPrompt}
onSubmit={(event) => {
event.preventDefault();
confirmSuspendPrompt();
}}
>
<AdminField label="下架原因(必填)">
<textarea
autoFocus
aria-label="下架原因"
aria-invalid={suspendReasonError ? true : undefined}
placeholder="填写下架原因,提交后记录在游戏状态变更里"
rows={3}
value={suspendReasonDraft}
onChange={(event) => {
setSuspendReasonDraft(event.target.value);
if (suspendReasonError) setSuspendReasonError('');
}}
/>
</AdminField>
{suspendReasonError ? (
<AdminAlert role="alert" tone="warning">
{suspendReasonError}
</AdminAlert>
) : null}
<div className="admin-confirm-actions">
<AdminButton
variant="secondary"
type="button"
onClick={closeSuspendPrompt}
>
取消
</AdminButton>
<AdminButton variant="danger" type="submit">
确认下架
</AdminButton>
</div>
</AdminDialog>
) : null}
{versionGame ? (
<AdminDialog
closeLabel="关闭版本历史"
@@ -0,0 +1,93 @@
/*
* 「游戏审核」待审版本表的页面专属样式。
*
* 目标:1280 / 1440 / 1920 三档视口下,「详情 / 通过 / 拒绝」三个版本级动作
* 都在面板可视带内、单行不换行,不依赖面板内横向滚动。
*
* 做法:只在本页表格上加自己的类,覆盖 `.admin-table-wide` 的 min-width 并固定列宽,
* 把动作列按内容取宽;gameId 最长(37 字符)用省略号截断并保留 title 作为完整值出口。
* 不改共享 `.admin-table-wide` 的全局语义,也不影响其它后台页面。
*/
/* `.admin-table-wide` 是共享类,用元素+类提升优先级覆盖它,只作用于本页表格。 */
table.admin-game-review-table {
width: 100%;
min-width: 0;
table-layout: fixed;
}
table.admin-game-review-table th:nth-child(2),
table.admin-game-review-table td:nth-child(2) {
/* `pending_review` 是最长状态值(约 120px + 单元格内边距),必须给足,否则会压到「发行包」列。 */
width: 148px;
}
table.admin-game-review-table th:nth-child(3),
table.admin-game-review-table td:nth-child(3) {
width: 124px;
}
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4) {
width: 172px;
white-space: nowrap;
}
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
width: 240px;
}
/* 固定列宽下,任何溢出都应该在本列内被裁掉,而不是压到相邻列上。 */
table.admin-game-review-table td {
overflow: hidden;
}
table.admin-game-review-table td .admin-muted-text {
overflow-wrap: anywhere;
}
.admin-game-review-cell--actions {
white-space: nowrap;
}
.admin-game-review-cell--actions .admin-review-actions {
flex-wrap: nowrap;
justify-content: flex-end;
}
/* gameId 是 37 字符的定长标识:窄视口截断显示,靠 title 暴露完整值。 */
.admin-game-review-game-id {
display: block;
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;
}
}
/* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 gameId 的宽度。 */
@media (max-width: 980px) {
table.admin-game-review-table {
table-layout: auto;
}
table.admin-game-review-table th:nth-child(2),
table.admin-game-review-table td:nth-child(2),
table.admin-game-review-table th:nth-child(3),
table.admin-game-review-table td:nth-child(3),
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4),
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
width: auto;
}
}
+33
View File
@@ -3526,3 +3526,36 @@ button:disabled {
border-radius: 1rem;
background: #fff;
}
/*
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
* 不影响其它后台页面:
* - 操作列按内容取宽且按钮组不换行,避免「查看评价」被挤到第二行;
* - 单元格垂直居中,行高不再随最长单元格跳动;
* - 标题列给最小宽度,避免被长 gameId 挤压成一行一个字。
* 这里用 `.admin-table.admin-game-management-table` 复合选择器:共享样式表在 dev 下
* 由组件模块后注入,单类选择器会被 `.admin-table th, .admin-table td` 反超。
*/
.admin-table.admin-game-management-table th,
.admin-table.admin-game-management-table td {
vertical-align: middle;
}
.admin-game-management-table td:first-child {
min-width: 200px;
}
.admin-game-management-table th:last-child,
.admin-game-management-table td.admin-game-actions-cell {
width: 1%;
white-space: nowrap;
}
.admin-game-actions-cell .admin-action-row {
flex-wrap: nowrap;
justify-content: flex-end;
}
.admin-game-author-cell {
min-width: 190px;
}
@@ -17,7 +17,7 @@
- 发布者、游戏资料、版本包摘要和冻结资料展示。
- 单个待审版本的短期隔离试玩会话。
- 试玩资源、Cookie、sandbox、CSP 和版本绑定边界。
- 通过、拒绝、安全下架动作与现有 CAS/幂等规则对齐。
- 版本级审核动作(通过、拒绝)与现有 CAS/幂等规则对齐;游戏级安全下架入口留在「游戏管理」页。
- admin-web、api-server、shared-contracts 和相关测试的契约同步。
## 不在范围内
@@ -41,7 +41,7 @@
- [ ] 审核员可以打开当前 `versionId` 试玩,且 JS/CSS/图片/音频等同包资源可加载。
- [ ] 预览 Token 绑定版本、短期有效、过期和越权读取均失败。
- [ ] 预览不读取平台 Cookie,不授予 `allow-same-origin`,保留发行 sandbox/CSP/storage 约束。
- [ ] 通过、拒绝、安全下架继续执行理由、CAS、幂等和刷新规则。
- [ ] 审核页的通过/拒绝继续执行理由、CAS、幂等和刷新规则;行内不出现游戏级安全下架。
- [ ] 无 SpacetimeDB schema、migration、公开契约破坏性变化。
## 证据要求
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'vitest';
import { formatAdminDate, formatAdminDateTime } from './AdminFormat';
describe('formatAdminDateTime', () => {
test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => {
// 游戏分发后台接口下发的是 shared-kernel `format_timestamp_micros` 的
// `seconds.microsZ` 文本(非 ISO 8601),必须与等价 ISO 时刻渲染一致。
const microsText = '1791008678.817257Z';
const equivalentIso = new Date(1791008678 * 1000 + 817).toISOString();
expect(formatAdminDateTime(microsText)).toBe(
formatAdminDateTime(equivalentIso),
);
expect(formatAdminDateTime(microsText)).not.toBe('-');
});
test('微秒位数不足时按毫秒补零,而不是丢位', () => {
expect(formatAdminDateTime('1791008678.8Z')).toBe(
formatAdminDateTime(new Date(1791008678 * 1000 + 800).toISOString()),
);
});
test('ISO 字符串、epoch 毫秒与 Date 仍按原语义渲染', () => {
const iso = '2026-09-20T08:00:00Z';
expect(formatAdminDateTime(iso)).toBe(formatAdminDateTime(new Date(iso)));
expect(formatAdminDateTime(Date.parse(iso))).toBe(
formatAdminDateTime(new Date(iso)),
);
});
test('无法解析的值回落 fallback', () => {
expect(formatAdminDateTime('not-a-time')).toBe('-');
expect(formatAdminDateTime('')).toBe('-');
expect(formatAdminDateTime(null)).toBe('-');
});
});
describe('formatAdminDate', () => {
test('同样接受 seconds.microsZ 口径', () => {
expect(formatAdminDate('1791008678.817257Z')).toBe(
formatAdminDate(new Date(1791008678 * 1000 + 817).toISOString()),
);
expect(formatAdminDate('1791008678.817257Z')).not.toBe('-');
});
});
@@ -1,5 +1,28 @@
/**
* 后台统一的时间展示:ISO 字符串 / epoch 毫秒 / Date → `zh-CN` 本地时间。
* 部分后台接口下发的微秒时间戳是 `seconds.microsZ` 文本口径(见 shared-kernel 的
* `format_timestamp_micros`,例如 `1791008678.817257Z`)。它不是 ISO 8601,
* JS `new Date()` 会直接判成 `Invalid Date`,因此先归一成 ISO 字符串再解析。
* 其它输入原样返回,交给 `new Date()` 处理。
*/
function normalizeAdminTimestamp(value: string | number): string | number {
if (typeof value !== 'string') {
return value;
}
const micros = /^(\d+)\.(\d{1,9})Z$/.exec(value.trim());
if (!micros) {
return value;
}
const seconds = Number(micros[1]);
const fraction = micros[2] ?? '';
if (!Number.isSafeInteger(seconds)) {
return value;
}
const millis = Number(fraction.padEnd(9, '0').slice(0, 3));
return new Date(seconds * 1000 + millis).toISOString();
}
/**
* 后台统一的时间展示:ISO 字符串 / `seconds.microsZ` / epoch 毫秒 / Date → `zh-CN` 本地时间。
* 解析不出来时回落到 `fallback`,避免页面上出现 `Invalid Date` 或裸 ISO 串。
*/
export function formatAdminDateTime(
@@ -10,7 +33,8 @@ export function formatAdminDateTime(
return fallback;
}
const date = value instanceof Date ? value : new Date(value);
const date =
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
if (!Number.isFinite(date.getTime())) {
return fallback;
}
@@ -30,7 +54,7 @@ export function formatAdminDateTime(
}
}
/** 后台统一的日期展示(不含时分秒)。 */
/** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */
export function formatAdminDate(
value: string | number | Date | null | undefined,
fallback = '-',
@@ -39,7 +63,8 @@ export function formatAdminDate(
return fallback;
}
const date = value instanceof Date ? value : new Date(value);
const date =
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
if (!Number.isFinite(date.getTime())) {
return fallback;
}
@@ -431,7 +431,8 @@
border-radius: 999px;
padding: 3px 8px;
font-size: 12px;
overflow-wrap: anywhere;
/* 状态标签是短徽标:必须单行,避免在窄列里被拆成每行一两个字。 */
white-space: nowrap;
}
.admin-status-ok {