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
新增后台评价查询分页、详情和独立页面权限 支持管理员隐藏、恢复和删除评价,隐藏删除必须填写原因 新增私有操作记录及幂等重试保护,兼容升级评价隐藏字段 同步网站隐藏提示、公开评价列表和评分统计 补充定向测试、运行时验证脚本及方案验收证据
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|