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 客户端发布前校验、阶段进度与原生幂等账本 增加 Web 作者私有作品管理、资料编辑、版本历史与软删除 增加后台作品筛选、游标分页与删除治理 同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
643 lines
20 KiB
TypeScript
643 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|