Files
Genarrative/apps/admin-web/src/pages/AdminGameManagementPage.tsx
T
suzmii a2b70d92b8
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m18s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m47s
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
作品管理与Phaser4客户端发布闭环
补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本

增加 Web 作者私有作品管理、资料编辑、版本历史与软删除

增加后台作品筛选、游标分页与删除治理

同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
2026-10-01 16:30:40 +08:00

643 lines
20 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,
AdminFieldGroup,
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;
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];
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,
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 [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
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,
);
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]: '',
}));
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"
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>
<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>
{entry.deletedAt ? (
<small className="admin-muted-text">
删除于 {formatOptionalTime(entry.deletedAt)}
</small>
) : null}
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<AdminActionRow>
{isDeleted ? null : isSuspended ? (
<AdminButton
variant="primary"
type="button"
disabled={busy}
onClick={() => void restoreGame(entry)}
>
{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="secondary"
type="button"
disabled={busy}
onClick={() => setVersionGame(entry)}
>
版本历史
</AdminButton>
</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>
);
}