作品管理与Phaser4客户端发布闭环
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
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、文档与定向测试
This commit is contained in:
@@ -568,7 +568,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=50',
|
||||
'/admin/api/game-distribution/games?limit=80',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -606,6 +606,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('游戏管理列表把筛选与游标编码进查询串', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminGameDistributionGames('admin-token', {
|
||||
limit: 20,
|
||||
keyword: ' 拼图 ',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
cursor: '1710000000000:game_1',
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1',
|
||||
);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionRestoreRequest,
|
||||
AdminGameDistributionRestoreResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
@@ -1243,17 +1244,36 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台作品管理列表。
|
||||
*
|
||||
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
|
||||
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
|
||||
*/
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
options: {
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
owner?: string;
|
||||
status?: AdminGameDistributionGameStatus | '';
|
||||
cursor?: string | null;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200)
|
||||
: 50;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const keyword = options.keyword?.trim();
|
||||
if (keyword) params.set('keyword', keyword);
|
||||
const owner = options.owner?.trim();
|
||||
if (owner) params.set('owner', owner);
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.cursor) params.set('cursor', options.cursor);
|
||||
return request<AdminGameDistributionGameListResponse>(
|
||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
||||
`/admin/api/game-distribution/games?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1158,18 +1158,29 @@ export interface AdminGameDistributionGameEntry {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
status: string;
|
||||
status: AdminGameDistributionGameStatus;
|
||||
versionCount: number;
|
||||
playCount: number;
|
||||
activeVersionId: string | null;
|
||||
publicationRevision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 作者软删除时间;为空表示未删除。 */
|
||||
deletedAt: string | null;
|
||||
versions: AdminGameDistributionGameVersionEntry[];
|
||||
}
|
||||
|
||||
/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */
|
||||
export type AdminGameDistributionGameStatus =
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'suspended'
|
||||
| 'deleted';
|
||||
|
||||
export interface AdminGameDistributionGameListResponse {
|
||||
games: AdminGameDistributionGameEntry[];
|
||||
/** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreRequest {
|
||||
|
||||
@@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
updatedAt: '2026-09-20T10:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [version],
|
||||
};
|
||||
|
||||
@@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 7,
|
||||
createdAt: '2026-09-19T08:00:00Z',
|
||||
updatedAt: '2026-09-21T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
};
|
||||
|
||||
const deletedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_3',
|
||||
title: '已删除作品',
|
||||
author: {
|
||||
id: 'user_3',
|
||||
name: '作者丙',
|
||||
avatarUrl: null,
|
||||
},
|
||||
status: 'deleted',
|
||||
versionCount: 1,
|
||||
playCount: 3,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 2,
|
||||
createdAt: '2026-09-17T08:00:00Z',
|
||||
updatedAt: '2026-09-22T08:00:00Z',
|
||||
deletedAt: '2026-09-22T08:00:00Z',
|
||||
versions: [],
|
||||
};
|
||||
|
||||
@@ -263,3 +284,92 @@ test('401 交给 onUnauthorized 处理', async () => {
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
|
||||
target: { value: ' 拼图 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
|
||||
target: { value: 'user_9' },
|
||||
});
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
|
||||
target: { value: 'suspended' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
|
||||
expect(applied).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '拼图',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
|
||||
expect(reset).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '',
|
||||
owner: '',
|
||||
status: '',
|
||||
});
|
||||
expect(
|
||||
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
|
||||
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
const nextButton = screen.getByRole('button', { name: '下一页' });
|
||||
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await screen.findByText('下架游戏');
|
||||
expect(screen.queryByText('测试游戏')).toBeNull();
|
||||
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
|
||||
.calls[1]!;
|
||||
expect(secondPage?.cursor).toBe('cursor-1');
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
|
||||
games: [deletedGame],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('已删除作品')).closest('tr')!;
|
||||
expect(within(row).getByText('已删除')).toBeTruthy();
|
||||
expect(within(row).getByText(/删除于/)).toBeTruthy();
|
||||
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
|
||||
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
|
||||
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -4,15 +4,17 @@ import {
|
||||
AdminAlert,
|
||||
AdminButton,
|
||||
AdminDialog,
|
||||
AdminField,
|
||||
AdminFieldGroup,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPagination,
|
||||
AdminStatusPill,
|
||||
formatAdminDateTime,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshCcw, Search, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionGames,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameEntry,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionGameVersionEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
@@ -38,8 +41,32 @@ const GAME_STATUS_META: Record<
|
||||
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] ?? {
|
||||
@@ -89,7 +116,13 @@ export function AdminGameManagementPage({
|
||||
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('');
|
||||
@@ -99,26 +132,109 @@ export function AdminGameManagementPage({
|
||||
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 () => {
|
||||
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]);
|
||||
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(() => {
|
||||
void loadGames();
|
||||
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) {
|
||||
@@ -151,7 +267,7 @@ export function AdminGameManagementPage({
|
||||
...current,
|
||||
[entry.gameId]: '',
|
||||
}));
|
||||
await loadGames();
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -177,7 +293,7 @@ export function AdminGameManagementPage({
|
||||
{ expectedPublicationRevision: entry.publicationRevision },
|
||||
);
|
||||
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
|
||||
await loadGames();
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -193,7 +309,7 @@ export function AdminGameManagementPage({
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => void loadGames()}
|
||||
onClick={refreshCurrentPage}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" />
|
||||
@@ -211,14 +327,93 @@ export function AdminGameManagementPage({
|
||||
<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="暂无游戏。"
|
||||
emptyText={filtersActive ? '没有匹配的作品。' : '暂无游戏。'}
|
||||
emptyVariant="text"
|
||||
showEmpty={hasLoaded && games.length === 0 && !errorMessage}
|
||||
title="游戏列表"
|
||||
extra={<span className="admin-muted-text">共 {games.length} 条</span>}
|
||||
extra={
|
||||
<span className="admin-muted-text">
|
||||
第 {pageIndex} 页{games.length ? `,本页 ${games.length} 条` : ''}
|
||||
</span>
|
||||
}
|
||||
rows={games}
|
||||
size="wide"
|
||||
columns={[
|
||||
@@ -230,8 +425,31 @@ export function AdminGameManagementPage({
|
||||
{ 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 (
|
||||
@@ -277,12 +495,26 @@ export function AdminGameManagementPage({
|
||||
<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>
|
||||
{!isSuspended ? (
|
||||
{isDeleted ? null : isSuspended ? (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restoreGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</AdminButton>
|
||||
) : (
|
||||
<>
|
||||
<AdminFieldGroup
|
||||
label="下架原因"
|
||||
@@ -309,15 +541,6 @@ export function AdminGameManagementPage({
|
||||
{busy ? '处理中…' : '下架'}
|
||||
</AdminButton>
|
||||
</>
|
||||
) : (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restoreGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</AdminButton>
|
||||
)}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
|
||||
Reference in New Issue
Block a user