8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
import {
|
||
AdminActionRow,
|
||
AdminAlert,
|
||
AdminButton,
|
||
AdminListPanel,
|
||
AdminPage,
|
||
AdminPageHeading,
|
||
formatAdminDateTime,
|
||
Modal,
|
||
TextField,
|
||
} from '@genarrative/shared/components';
|
||
import { RefreshCcw } from 'lucide-react';
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
|
||
import {
|
||
listAdminGameDistributionReviews,
|
||
reviewAdminGameDistributionVersion,
|
||
suspendAdminGameDistributionGame,
|
||
} from '../api/adminApiClient';
|
||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||
import { handlePageError } from './pageUtils';
|
||
|
||
interface AdminGameDistributionReviewPageProps {
|
||
token: string;
|
||
onUnauthorized: (message?: string) => void;
|
||
}
|
||
|
||
interface ReviewReasonPrompt {
|
||
decision: 'reject' | 'suspend';
|
||
entry: AdminGameDistributionReviewEntry;
|
||
}
|
||
|
||
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 createSuspendIdempotencyKey(gameId: string) {
|
||
const random =
|
||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||
? crypto.randomUUID()
|
||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return `game-suspend-${gameId}-${random}`.slice(0, 128);
|
||
}
|
||
|
||
function createReviewIdempotencyKey(versionId: string) {
|
||
const random =
|
||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||
? crypto.randomUUID()
|
||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return `game-review-${versionId}-${random}`.slice(0, 128);
|
||
}
|
||
|
||
export function AdminGameDistributionReviewPage({
|
||
token,
|
||
onUnauthorized,
|
||
}: AdminGameDistributionReviewPageProps) {
|
||
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
|
||
[],
|
||
);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [busyVersionId, setBusyVersionId] = useState('');
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [statusMessage, setStatusMessage] = useState('');
|
||
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
|
||
null,
|
||
);
|
||
const [reasonDraft, setReasonDraft] = useState('');
|
||
const [reasonError, setReasonError] = useState('');
|
||
const [busyGameId, setBusyGameId] = useState('');
|
||
const writeConfirm = useAdminWriteConfirm();
|
||
|
||
const loadReviews = useCallback(async () => {
|
||
setIsLoading(true);
|
||
setErrorMessage('');
|
||
try {
|
||
const response = await listAdminGameDistributionReviews(token);
|
||
setEntries(response.entries);
|
||
} catch (error) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [token, onUnauthorized]);
|
||
|
||
useEffect(() => {
|
||
void loadReviews();
|
||
}, [loadReviews]);
|
||
|
||
async function submitReview(
|
||
entry: AdminGameDistributionReviewEntry,
|
||
decision: 'approve' | 'reject',
|
||
reason = '',
|
||
) {
|
||
const trimmedReason = reason.trim();
|
||
if (decision === 'reject' && !trimmedReason) {
|
||
setErrorMessage('拒绝审核必须填写理由');
|
||
return;
|
||
}
|
||
setBusyVersionId(entry.versionId);
|
||
setErrorMessage('');
|
||
setStatusMessage('');
|
||
try {
|
||
await reviewAdminGameDistributionVersion(
|
||
token,
|
||
entry.versionId,
|
||
createReviewIdempotencyKey(entry.versionId),
|
||
decision === 'approve'
|
||
? {
|
||
decision,
|
||
expectedPublicationRevision: entry.publicationRevision,
|
||
}
|
||
: {
|
||
decision,
|
||
expectedPublicationRevision: entry.publicationRevision,
|
||
reviewReason: trimmedReason,
|
||
},
|
||
);
|
||
setStatusMessage(
|
||
decision === 'approve'
|
||
? `版本 v${entry.versionNumber} 已通过审核`
|
||
: `版本 v${entry.versionNumber} 已拒绝`,
|
||
);
|
||
await loadReviews();
|
||
} catch (error) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setBusyVersionId('');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
||
*/
|
||
async function suspendGame(
|
||
entry: AdminGameDistributionReviewEntry,
|
||
reason: string,
|
||
) {
|
||
const trimmedReason = reason.trim();
|
||
const confirmed = await writeConfirm.confirmWrite({
|
||
action: '安全下架游戏',
|
||
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
|
||
});
|
||
if (!confirmed) return;
|
||
setBusyGameId(entry.gameId);
|
||
setErrorMessage('');
|
||
setStatusMessage('');
|
||
try {
|
||
await suspendAdminGameDistributionGame(
|
||
token,
|
||
entry.gameId,
|
||
createSuspendIdempotencyKey(entry.gameId),
|
||
{
|
||
expectedPublicationRevision: entry.publicationRevision,
|
||
...(trimmedReason ? { reason: trimmedReason } : {}),
|
||
},
|
||
);
|
||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
||
await loadReviews();
|
||
} catch (error) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setBusyGameId('');
|
||
}
|
||
}
|
||
|
||
function openReasonPrompt(
|
||
entry: AdminGameDistributionReviewEntry,
|
||
decision: ReviewReasonPrompt['decision'],
|
||
) {
|
||
setReasonDraft('');
|
||
setReasonError('');
|
||
setReasonPrompt({ decision, entry });
|
||
}
|
||
|
||
function closeReasonPrompt() {
|
||
setReasonPrompt(null);
|
||
setReasonDraft('');
|
||
setReasonError('');
|
||
}
|
||
|
||
function confirmReasonPrompt() {
|
||
if (!reasonPrompt) return;
|
||
|
||
const reason = reasonDraft.trim();
|
||
if (reasonPrompt.decision === 'reject' && !reason) {
|
||
setReasonError('拒绝审核必须填写理由');
|
||
return;
|
||
}
|
||
|
||
const { decision, entry } = reasonPrompt;
|
||
closeReasonPrompt();
|
||
if (decision === 'reject') {
|
||
void submitReview(entry, 'reject', reason);
|
||
return;
|
||
}
|
||
void suspendGame(entry, reason);
|
||
}
|
||
|
||
return (
|
||
<AdminPage wide>
|
||
<AdminPageHeading
|
||
title="游戏审核"
|
||
actions={
|
||
<AdminButton
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={() => void loadReviews()}
|
||
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">共 {entries.length} 条</span>}
|
||
rows={entries}
|
||
size="wide"
|
||
columns={[
|
||
{ key: 'gameId', label: '游戏' },
|
||
{ key: 'versionNumber', label: '版本' },
|
||
{ key: 'packageBytes', label: '发行包' },
|
||
{ key: 'createdAt', label: '提交时间' },
|
||
{ key: 'actions', label: '审核' },
|
||
]}
|
||
renderRow={(entry) => {
|
||
const busy = busyVersionId === entry.versionId;
|
||
return (
|
||
<tr key={entry.versionId}>
|
||
<td>
|
||
<code>{entry.gameId}</code>
|
||
</td>
|
||
<td>
|
||
v{entry.versionNumber}
|
||
<div className="admin-muted-text">{entry.status}</div>
|
||
{entry.reviewReason ? (
|
||
<div className="admin-muted-text">{entry.reviewReason}</div>
|
||
) : null}
|
||
</td>
|
||
<td>
|
||
{formatBytes(entry.packageBytes)}
|
||
<div className="admin-muted-text">
|
||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
||
</div>
|
||
</td>
|
||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||
<td>
|
||
<AdminActionRow>
|
||
<AdminButton
|
||
variant="primary"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => void submitReview(entry, 'approve')}
|
||
>
|
||
通过
|
||
</AdminButton>
|
||
<AdminButton
|
||
variant="ghost"
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => openReasonPrompt(entry, 'reject')}
|
||
>
|
||
拒绝
|
||
</AdminButton>
|
||
<AdminButton
|
||
variant="danger"
|
||
type="button"
|
||
disabled={busy || busyGameId === entry.gameId}
|
||
onClick={() => openReasonPrompt(entry, 'suspend')}
|
||
>
|
||
{busyGameId === entry.gameId ? '正在下架…' : '安全下架'}
|
||
</AdminButton>
|
||
</AdminActionRow>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}}
|
||
/>
|
||
{reasonPrompt ? (
|
||
<Modal
|
||
open
|
||
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
|
||
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
|
||
closeLabel="关闭理由输入"
|
||
onClose={closeReasonPrompt}
|
||
size="sm"
|
||
className="genarrative-ui"
|
||
footer={
|
||
<div
|
||
className="admin-confirm-actions"
|
||
style={{
|
||
width: '100%',
|
||
}}
|
||
>
|
||
<AdminButton
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={closeReasonPrompt}
|
||
>
|
||
取消
|
||
</AdminButton>
|
||
<AdminButton
|
||
type="button"
|
||
variant={
|
||
reasonPrompt.decision === 'reject' ? 'ghost' : 'danger'
|
||
}
|
||
onClick={confirmReasonPrompt}
|
||
>
|
||
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
|
||
</AdminButton>
|
||
</div>
|
||
}
|
||
>
|
||
<TextField
|
||
autoFocus
|
||
multiline
|
||
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
|
||
value={reasonDraft}
|
||
error={reasonError}
|
||
rows={4}
|
||
onChange={(event) => {
|
||
setReasonDraft(event.target.value);
|
||
if (reasonError) setReasonError('');
|
||
}}
|
||
/>
|
||
</Modal>
|
||
) : null}
|
||
{writeConfirm.confirmDialog}
|
||
</AdminPage>
|
||
);
|
||
}
|