Files
Genarrative/apps/admin-web/src/pages/AdminGameManagementPage.tsx
T
suzmii 072284b846 后台游戏管理与审核页排版修复,安全下架入口收敛到游戏管理页
- 游戏管理页:下架改为独立面板(原因必填、校验提示、仍走二次确认),操作列与作者列排版调整,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
- 文档:后台游戏审核详情计划加取代标注,说明审核页只承载版本级动作
2026-10-04 00:17:21 +08:00

730 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AdminStatusTone } from '@genarrative/shared/components';
import {
AdminActionRow,
AdminAlert,
AdminButton,
AdminDialog,
AdminField,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPagination,
AdminStatusPill,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw, Search, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameStatus,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameManagementPageProps {
token: string;
canManageReviews?: boolean;
onUnauthorized: (message?: string) => void;
}
const GAME_STATUS_META: Record<
string,
{ label: string; tone: AdminStatusTone }
> = {
published: { label: '已公开', tone: 'ok' },
suspended: { label: '已下架', tone: 'error' },
unpublished: { label: '未公开', tone: 'pending' },
// 作者软删除:保留在后台可见(默认视图之外),但不再公开可玩。
deleted: { label: '已删除', tone: 'error' },
};
const STATUS_FILTER_OPTIONS: {
value: AdminGameDistributionGameStatus | '';
label: string;
}[] = [
{ value: '', label: '全部状态' },
{ value: 'published', label: '已公开' },
{ value: 'unpublished', label: '未公开' },
{ value: 'suspended', label: '已下架' },
{ value: 'deleted', label: '已删除' },
];
const DEFAULT_PAGE_SIZE = 50;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
/** 下架原因必填;面板校验与提交前兜底共用同一句提示。 */
const SUSPEND_REASON_REQUIRED_MESSAGE = '请填写下架原因';
interface GameFilters {
keyword: string;
owner: string;
status: AdminGameDistributionGameStatus | '';
}
const EMPTY_FILTERS: GameFilters = { keyword: '', owner: '', status: '' };
function gameStatusMeta(status: string) {
return (
GAME_STATUS_META[status] ?? {
label: status || '—',
tone: 'pending',
}
);
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatOptionalTime(value: string | null) {
return value ? formatAdminDateTime(value) : '—';
}
function authorName(entry: AdminGameDistributionGameEntry) {
return entry.author?.name?.trim() || '—';
}
function authorInitial(entry: AdminGameDistributionGameEntry) {
const name = authorName(entry);
return name === '—' ? '—' : (Array.from(name)[0] ?? '—');
}
function createGameActionIdempotencyKey(
action: 'suspend' | 'restore',
gameId: string,
) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore';
return `${prefix}-${gameId}-${random}`.slice(0, 128);
}
export function AdminGameManagementPage({
token,
canManageReviews = false,
onUnauthorized,
}: AdminGameManagementPageProps) {
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [draftFilters, setDraftFilters] = useState<GameFilters>(EMPTY_FILTERS);
const [filters, setFilters] = useState<GameFilters>(EMPTY_FILTERS);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
// 下架原因在下架面板内填写;面板打开状态、草稿与校验错误都由页面持有。
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();
const listController = useRef<AbortController | null>(null);
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
const loadGames = useCallback(
async (cursor: string | null, page: number) => {
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionGames(
token,
{
limit: pageSize,
cursor,
keyword: filters.keyword,
owner: filters.owner,
status: filters.status,
},
controller.signal,
);
if (controller.signal.aborted) return;
setGames(response.games);
setNextCursor(response.nextCursor ?? null);
setPageIndex(page);
setHasLoaded(true);
} catch (error: unknown) {
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
if (!controller.signal.aborted)
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
if (listController.current === controller) {
listController.current = null;
setIsLoading(false);
}
}
},
[
token,
onUnauthorized,
pageSize,
filters.keyword,
filters.owner,
filters.status,
],
);
// 条件或每页条数变化时回到第一页;这里是唯一的"重置游标"入口。
useEffect(() => {
pageCursors.current = [null];
setPageIndex(1);
void loadGames(null, 1);
}, [loadGames]);
useEffect(
() => () => {
listController.current?.abort();
},
[],
);
function applyFilters() {
setFilters({
keyword: draftFilters.keyword.trim(),
owner: draftFilters.owner.trim(),
status: draftFilters.status,
});
}
function resetFilters() {
setDraftFilters(EMPTY_FILTERS);
setFilters(EMPTY_FILTERS);
}
function refreshCurrentPage() {
void loadGames(pageCursors.current[pageIndex - 1] ?? null, pageIndex);
}
function goToNextPage() {
if (!nextCursor) return;
const nextPage = pageIndex + 1;
pageCursors.current[nextPage - 1] = nextCursor;
void loadGames(nextCursor, nextPage);
}
function goToPreviousPage() {
if (pageIndex <= 1) return;
const previousPage = pageIndex - 1;
void loadGames(pageCursors.current[previousPage - 1] ?? null, previousPage);
}
function changePageSize(size: number) {
pageCursors.current = [null];
setPageSize(size);
}
const filtersActive = Boolean(
filters.keyword || filters.owner || filters.status,
);
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) {
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;
}
const confirmed = await writeConfirm.confirmWrite({
action: '下架游戏',
target: `${entry.title || entry.gameId}(${entry.gameId})`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('suspend', entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
reason: trimmedReason,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
refreshCurrentPage();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
async function restoreGame(entry: AdminGameDistributionGameEntry) {
const confirmed = await writeConfirm.confirmWrite({
action: '恢复游戏',
target: `${entry.title || entry.gameId}(${entry.gameId})`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await restoreAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('restore', entry.gameId),
{ expectedPublicationRevision: entry.publicationRevision },
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
refreshCurrentPage();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<AdminPage wide>
<AdminPageHeading
title="游戏管理"
actions={
<AdminButton
variant="secondary"
type="button"
onClick={refreshCurrentPage}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
刷新
</AdminButton>
}
/>
{errorMessage ? (
<AdminAlert tone="warning" role="alert">
{errorMessage}
</AdminAlert>
) : null}
{statusMessage ? (
<AdminAlert tone="success">{statusMessage}</AdminAlert>
) : null}
<form
className="admin-stack"
onSubmit={(event) => {
event.preventDefault();
applyFilters();
}}
>
<AdminActionRow>
<AdminField label="关键词" compact>
<input
aria-label="关键词"
placeholder="标题、gameId 或作者 ID"
value={draftFilters.keyword}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
keyword: event.target.value,
}))
}
disabled={isLoading}
/>
</AdminField>
<AdminField label="作者" compact>
<input
aria-label="作者"
placeholder="作者 user ID"
value={draftFilters.owner}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
owner: event.target.value,
}))
}
disabled={isLoading}
/>
</AdminField>
<AdminField label="状态" compact>
<select
aria-label="状态"
value={draftFilters.status}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
status: event.target.value as
| AdminGameDistributionGameStatus
| '',
}))
}
disabled={isLoading}
>
{STATUS_FILTER_OPTIONS.map((option) => (
<option key={option.value || 'all'} value={option.value}>
{option.label}
</option>
))}
</select>
</AdminField>
<AdminButton variant="primary" type="submit" disabled={isLoading}>
<Search aria-hidden="true" />
查询
</AdminButton>
<AdminButton
variant="secondary"
type="button"
onClick={resetFilters}
disabled={isLoading && !filtersActive}
>
<X aria-hidden="true" />
重置
</AdminButton>
</AdminActionRow>
</form>
<AdminListPanel
ariaLabel="游戏列表"
busy={isLoading}
busyText="正在加载游戏列表…"
busyVariant="line"
emptyText={filtersActive ? '没有匹配的作品。' : '暂无游戏。'}
emptyVariant="text"
showEmpty={hasLoaded && games.length === 0 && !errorMessage}
title="游戏列表"
extra={
<span className="admin-muted-text">
第 {pageIndex} 页{games.length ? `,本页 ${games.length} 条` : ''}
</span>
}
rows={games}
size="wide"
tableClassName="admin-game-management-table"
columns={[
{ key: 'title', label: '标题' },
{ key: 'author', label: '作者' },
{ key: 'gameId', label: 'gameId' },
{ key: 'status', label: '状态' },
{ key: 'versionCount', label: '版本数' },
{ key: 'playCount', label: '游玩数' },
{ key: 'actions', label: '操作' },
]}
pagination={
hasLoaded ? (
<AdminPagination
ariaLabel="游戏列表分页"
info={
<>
第 {pageIndex} 页
{games.length ? `,本页 ${games.length} 条` : ''}
</>
}
pageSize={{
value: pageSize,
options: PAGE_SIZE_OPTIONS,
ariaLabel: '每页条数',
onChange: changePageSize,
}}
busy={isLoading}
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
next={{ disabled: !nextCursor, onClick: goToNextPage }}
/>
) : null
}
renderRow={(entry) => {
const status = gameStatusMeta(entry.status);
const isDeleted = entry.status === 'deleted';
const isSuspended = entry.status === 'suspended';
const busy = busyGameId === entry.gameId;
return (
<tr key={entry.gameId}>
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td className="admin-game-author-cell">
<div
className="admin-database-user-cell"
style={{
justifyContent: 'flex-start',
}}
>
<div
className="admin-user-avatar"
style={{
width: 32,
height: 32,
flex: '0 0 32px',
fontSize: 13,
}}
>
{entry.author?.avatarUrl ? (
<img
alt={`${authorName(entry)} 头像`}
src={entry.author.avatarUrl}
/>
) : (
authorInitial(entry)
)}
</div>
<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
className="admin-table-cell-ellipsis"
title={entry.gameId}
>
{entry.gameId}
</code>
</td>
<td>
<AdminStatusPill tone={status.tone}>
{status.label}
</AdminStatusPill>
{entry.deletedAt ? (
<small className="admin-muted-text">
删除于 {formatOptionalTime(entry.deletedAt)}
</small>
) : null}
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td className="admin-game-actions-cell">
<AdminActionRow>
{isDeleted ? null : isSuspended ? (
<AdminButton
variant="primary"
type="button"
disabled={busy}
onClick={() => void restoreGame(entry)}
>
{busy ? '处理中…' : '恢复'}
</AdminButton>
) : (
<AdminButton
variant="danger"
type="button"
disabled={busy}
onClick={() => openSuspendPrompt(entry)}
>
{busy ? '处理中…' : '下架'}
</AdminButton>
)}
<AdminButton
variant="secondary"
type="button"
disabled={busy}
onClick={() => setVersionGame(entry)}
>
版本历史
</AdminButton>
{canManageReviews ? (
<AdminButton
variant="secondary"
type="button"
onClick={() => {
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
}}
>
查看评价
</AdminButton>
) : null}
</AdminActionRow>
</td>
</tr>
);
}}
/>
{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="关闭版本历史"
description={
<>
{versionGame.title?.trim() || '—'}({versionGame.gameId})
</>
}
title="版本历史"
titleProps={{ id: 'admin-game-version-history-title' }}
onClose={() => setVersionGame(null)}
>
<p className="admin-muted-text">
共 {versionGame.versionCount} 个版本,展示最近 20 个
</p>
<AdminListPanel
columns={[
{
key: 'versionNumber',
label: '版本',
sortable: true,
sortValue: (version) => version.versionNumber,
},
{ key: 'status', label: '状态', sortable: true },
{
key: 'packageBytes',
label: '包大小',
sortable: true,
sortValue: (version) => version.packageBytes,
},
{ key: 'packageSha256', label: 'SHA' },
{ key: 'createdAt', label: '创建时间', sortable: true },
{ key: 'reviewedAt', label: '审核时间', sortable: true },
{ key: 'publishedAt', label: '公开时间', sortable: true },
{ key: 'reviewReason', label: '审核原因' },
{ key: 'entryUrl', label: '发行入口' },
]}
emptyText="暂无版本记录。"
emptyVariant="text"
renderRow={(version) => (
<GameVersionRow key={version.versionId} version={version} />
)}
rows={versionGame.versions}
surface="plain"
size="wide"
/>
</AdminDialog>
) : null}
{writeConfirm.confirmDialog}
</AdminPage>
);
}
function GameVersionRow({
version,
}: {
version: AdminGameDistributionGameVersionEntry;
}) {
return (
<tr>
<td>v{version.versionNumber}</td>
<td>{version.status || '—'}</td>
<td>{formatBytes(version.packageBytes)}</td>
<td>
<code>{version.packageSha256?.slice(0, 12) || '—'}</code>
</td>
<td>{formatOptionalTime(version.createdAt)}</td>
<td>{formatOptionalTime(version.reviewedAt)}</td>
<td>{formatOptionalTime(version.publishedAt)}</td>
<td>{version.reviewReason?.trim() || '—'}</td>
<td>
{version.entryUrl ? (
<a href={version.entryUrl} rel="noreferrer" target="_blank">
{version.entryUrl}
</a>
) : (
'—'
)}
</td>
</tr>
);
}