Files
Genarrative/apps/admin-web/src/pages/AdminGameManagementPage.tsx
T
lhk229 cb689fea2a
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
实现后台游戏评价管理
新增后台评价查询分页、详情和独立页面权限
支持管理员隐藏、恢复和删除评价,隐藏删除必须填写原因
新增私有操作记录及幂等重试保护,兼容升级评价隐藏字段
同步网站隐藏提示、公开评价列表和评分统计
补充定向测试、运行时验证脚本及方案验收证据
2026-10-01 12:22:13 +08:00

433 lines
13 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,
AdminFieldGroup,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminStatusPill,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
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' },
};
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 [isLoading, setIsLoading] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [versionGame, setVersionGame] =
useState<AdminGameDistributionGameEntry | null>(null);
const writeConfirm = useAdminWriteConfirm();
const loadGames = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionGames(token, {
limit: 50,
});
setGames(response.games);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadGames();
}, [loadGames]);
async function suspendGame(entry: AdminGameDistributionGameEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
if (!reason) {
setErrorMessage('下架原因不能为空');
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,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: '',
}));
await loadGames();
} 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}》已恢复`);
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<AdminPage wide>
<AdminPageHeading
title="游戏管理"
actions={
<AdminButton
variant="secondary"
type="button"
onClick={() => void loadGames()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
刷新
</AdminButton>
}
/>
{errorMessage ? (
<AdminAlert tone="warning" role="alert">
{errorMessage}
</AdminAlert>
) : null}
{statusMessage ? (
<AdminAlert tone="success">{statusMessage}</AdminAlert>
) : null}
<AdminListPanel
busy={isLoading}
busyText="正在加载游戏列表…"
busyVariant="line"
emptyText="暂无游戏。"
emptyVariant="text"
title="游戏列表"
extra={<span className="admin-muted-text">共 {games.length} 条</span>}
rows={games}
size="wide"
columns={[
{ key: 'title', label: '标题' },
{ key: 'author', label: '作者' },
{ key: 'gameId', label: 'gameId' },
{ key: 'status', label: '状态' },
{ key: 'versionCount', label: '版本数' },
{ key: 'playCount', label: '游玩数' },
{ key: 'actions', label: '操作' },
]}
renderRow={(entry) => {
const status = gameStatusMeta(entry.status);
const isSuspended = entry.status === 'suspended';
const busy = busyGameId === entry.gameId;
return (
<tr key={entry.gameId}>
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td>
<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>
<span>{authorName(entry)}</span>
<small>{entry.author?.id?.trim() || '—'}</small>
</div>
</div>
</td>
<td>
<code>{entry.gameId}</code>
</td>
<td>
<AdminStatusPill tone={status.tone}>
{status.label}
</AdminStatusPill>
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<AdminActionRow>
{!isSuspended ? (
<>
<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="primary"
type="button"
disabled={busy}
onClick={() => void restoreGame(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>
);
}}
/>
{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>
);
}