cf19241544
- 后台审核页删除「发行入口」输入框与前端 HTTPS 校验,审核请求只提交结论与公开修订号
- api-server 新增 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE 模板配置,审核通过时读版本取 gameId 并派生该游戏独立来源地址
- 模板缺 {gameId}、生产未配置模板、gameId 非主机安全字符或派生结果非法时审核通过失败关闭,非生产未配置时回落本地回环发行网关
- 同步更新发行来源 nginx 模板与说明、两份 api-server 环境变量样例、平台与运维主规范、发行里程碑实施计划和决策记录
- 真实栈 e2e 脚本不再传 entryUrl,并断言审核结果里的入口为服务端派生值
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
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;
|
||
}
|
||
|
||
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 formatTime(value: string) {
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return value;
|
||
return parsed.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
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 [reasonByVersion, setReasonByVersion] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
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',
|
||
) {
|
||
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
|
||
if (decision === 'reject' && !reason) {
|
||
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: reason,
|
||
},
|
||
);
|
||
setStatusMessage(
|
||
decision === 'approve'
|
||
? `版本 v${entry.versionNumber} 已通过审核`
|
||
: `版本 v${entry.versionNumber} 已拒绝`,
|
||
);
|
||
await loadReviews();
|
||
} catch (error) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setBusyVersionId('');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
||
*/
|
||
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
|
||
const reason = (suspendReasonByGame[entry.gameId] ?? '').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,
|
||
...(reason ? { reason } : {}),
|
||
},
|
||
);
|
||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
||
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
|
||
await loadReviews();
|
||
} catch (error) {
|
||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||
} finally {
|
||
setBusyGameId('');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="admin-page admin-page-wide">
|
||
<div className="admin-page-heading">
|
||
<h1>游戏审核</h1>
|
||
<button
|
||
type="button"
|
||
className="admin-secondary-button"
|
||
onClick={() => void loadReviews()}
|
||
disabled={isLoading}
|
||
>
|
||
<RefreshCcw aria-hidden="true" />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
{errorMessage ? (
|
||
<div className="admin-alert admin-alert-warning" role="alert">
|
||
{errorMessage}
|
||
</div>
|
||
) : null}
|
||
{statusMessage ? (
|
||
<div className="admin-alert admin-alert-success" role="status">
|
||
{statusMessage}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="admin-panel">
|
||
<div className="admin-panel-heading">
|
||
<h2>待审版本</h2>
|
||
<span className="admin-muted-text">共 {entries.length} 条</span>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<p className="admin-muted-text">正在加载待审版本…</p>
|
||
) : null}
|
||
|
||
{!isLoading && entries.length === 0 ? (
|
||
<p className="admin-muted-text">当前没有待审核的游戏版本。</p>
|
||
) : null}
|
||
|
||
{!isLoading && entries.length > 0 ? (
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table admin-table-wide">
|
||
<thead>
|
||
<tr>
|
||
<th>游戏</th>
|
||
<th>版本</th>
|
||
<th>发行包</th>
|
||
<th>提交时间</th>
|
||
<th>审核</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.map((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>{formatTime(entry.createdAt)}</td>
|
||
<td>
|
||
<div className="admin-action-row">
|
||
<div className="admin-field">
|
||
<span className="admin-muted-text">
|
||
通过后由系统分配发行地址
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="admin-primary-button"
|
||
disabled={busy}
|
||
onClick={() => void submitReview(entry, 'approve')}
|
||
>
|
||
通过
|
||
</button>
|
||
<div className="admin-field">
|
||
<label
|
||
htmlFor={`game-reject-reason-${entry.versionId}`}
|
||
>
|
||
拒绝理由
|
||
</label>
|
||
<input
|
||
id={`game-reject-reason-${entry.versionId}`}
|
||
value={reasonByVersion[entry.versionId] ?? ''}
|
||
onChange={(event) =>
|
||
setReasonByVersion((current) => ({
|
||
...current,
|
||
[entry.versionId]: event.target.value,
|
||
}))
|
||
}
|
||
disabled={busy}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="admin-ghost-button"
|
||
disabled={busy}
|
||
onClick={() => void submitReview(entry, 'reject')}
|
||
>
|
||
拒绝
|
||
</button>
|
||
<div className="admin-field">
|
||
<label
|
||
htmlFor={`game-suspend-reason-${entry.versionId}`}
|
||
>
|
||
下架原因
|
||
</label>
|
||
<input
|
||
id={`game-suspend-reason-${entry.versionId}`}
|
||
value={suspendReasonByGame[entry.gameId] ?? ''}
|
||
onChange={(event) =>
|
||
setSuspendReasonByGame((current) => ({
|
||
...current,
|
||
[entry.gameId]: event.target.value,
|
||
}))
|
||
}
|
||
disabled={busy}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="admin-danger-button"
|
||
disabled={busy || busyGameId === entry.gameId}
|
||
onClick={() => void suspendGame(entry)}
|
||
>
|
||
{busyGameId === entry.gameId
|
||
? '正在下架…'
|
||
: '安全下架'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{writeConfirm.confirmDialog}
|
||
</section>
|
||
);
|
||
}
|