Files
Genarrative/apps/admin-web/src/pages/AdminGameReviewsPage.tsx
T
lhk229 b60aea4e71
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 3m23s
Project CI / Frontend tests (push) Successful in 3m3s
Project CI / Repository checks (push) Successful in 3m42s
Project CI / AI game creator shell web tests (push) Successful in 2m3s
Project CI / Backend tests (push) Successful in 6m19s
Project CI / Native shell tests (push) Successful in 8m7s
实现网站游戏评分与评价 (#561)
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 1–10 分评价,评论可空且最多 4000 个 Unicode 码点;游客可分页浏览评价并查看平均分与评分人数。

- 新增私有评价表及共享 DTO/API,事务内校验游戏公开可见性、保证唯一性,并计算分页与全量评分统计;下架或封禁后关闭读写,重新公开及更新版本保留评价。
- 详情页支持编辑预填、取消、提交期间冻结输入、失败保留草稿,以及保存、翻页和账号/游戏切换时的旧响应隔离;复用现有认证与公共组件。
- 同步主规范、里程碑、实施计划、生成绑定、表目录和验收脚本。不扩展 AGC、外部 API、评论审核或评分缓存。

验证:
- 本轮审查复跑前端定向测试 40 项、评价领域测试 2 项,均通过。
- 网站 typecheck、Rust/TypeScript DTO 对齐、SpacetimeDB schema、文档索引、编码与 diff 检查通过。
- 实施文档另记录本地隔离数据库/API 49 项及桌面/移动视口浏览器 smoke 通过;本轮审查未重跑这些运行时验收,也未运行完整测试套件。

发布说明:新增 SpacetimeDB 表,部署时需同步 module 与 API。尚未部署 dev/production,待用户验收。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/561
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-01 19:19:58 +08:00

674 lines
21 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 {
AdminActionRow,
AdminAlert,
AdminButton,
AdminDialog,
AdminFieldGroup,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPagination,
AdminPanel,
AdminStatusPill,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
getAdminGameReview,
listAdminGameReviewGames,
listAdminGameReviews,
moderateAdminGameReview,
} from '../api/adminApiClient';
import type {
AdminGameReview,
AdminGameReviewDetailResponse,
AdminGameReviewGamesResponse,
AdminGameReviewModerationRequest,
AdminGameReviewsQuery,
AdminGameReviewsResponse,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface PageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
type Filters = Required<
Pick<AdminGameReviewsQuery, 'gameId' | 'userId' | 'keyword' | 'status'>
>;
type Action = AdminGameReviewModerationRequest['action'];
const actionLabels = { hide: '隐藏', restore: '恢复', delete: '删除' };
const emptyFilters: Filters = {
gameId: '',
userId: '',
keyword: '',
status: 'all',
};
function filtersFromHash(): Filters {
const params = new URLSearchParams(window.location.hash.split('?')[1] ?? '');
return { ...emptyFilters, gameId: params.get('gameId')?.trim() ?? '' };
}
function commentSummary(comment: string) {
if (!comment) return '仅评分';
const chars = Array.from(comment);
return chars.length > 120 ? `${chars.slice(0, 120).join('')}…` : comment;
}
export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) {
const [draft, setDraft] = useState(filtersFromHash);
const [query, setQuery] = useState<AdminGameReviewsQuery>(() => ({
...filtersFromHash(),
page: 1,
pageSize: 20,
}));
const [result, setResult] = useState<AdminGameReviewsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [pickingGame, setPickingGame] = useState(false);
const [detailId, setDetailId] = useState('');
const [pending, setPending] = useState<{
review: AdminGameReview;
action: Action;
} | null>(null);
const [reason, setReason] = useState('');
const [saveError, setSaveError] = useState('');
const [saving, setSaving] = useState(false);
const listSerial = useRef(0);
const queryRef = useRef(query);
queryRef.current = query;
const attempt = useRef<{ fingerprint: string; key: string } | null>(null);
const writeConfirm = useAdminWriteConfirm();
const load = useCallback(
async (nextQuery: AdminGameReviewsQuery) => {
const serial = ++listSerial.current;
setLoading(true);
setError('');
setResult(null);
try {
const response = await listAdminGameReviews(token, nextQuery);
if (serial !== listSerial.current) return;
const lastPage = Math.max(1, response.totalPages);
if ((nextQuery.page ?? 1) > lastPage) {
setQuery({ ...nextQuery, page: lastPage });
return;
}
setResult(response);
} catch (failure) {
if (serial === listSerial.current)
handlePageError(failure, onUnauthorized, setError);
} finally {
if (serial === listSerial.current) setLoading(false);
}
},
[token, onUnauthorized],
);
useEffect(() => {
const requestCounter = listSerial;
void load(query);
return () => {
requestCounter.current++;
};
}, [load, query]);
useEffect(() => {
const changed = () => {
const filters = filtersFromHash();
setDraft(filters);
setQuery({ ...filters, page: 1, pageSize: 20 });
setDetailId('');
setPending(null);
setNotice('');
};
window.addEventListener('hashchange', changed);
return () => window.removeEventListener('hashchange', changed);
}, []);
async function perform(target: { review: AdminGameReview; action: Action }) {
const submittedQuery = queryRef.current;
const normalizedReason = reason.replace(/\r\n?/g, '\n').trim();
if (
target.action !== 'restore' &&
(!normalizedReason || Array.from(normalizedReason).length > 4000)
) {
setSaveError('原因必须为 1–4000 个字符');
return;
}
const body: AdminGameReviewModerationRequest = {
action: target.action,
expectedCreatedAt: target.review.createdAt,
...(target.action === 'restore' ? {} : { reason: normalizedReason }),
};
const fingerprint = JSON.stringify([target.review.id, body]);
if (attempt.current?.fingerprint !== fingerprint) {
attempt.current = { fingerprint, key: `review-${crypto.randomUUID()}` };
}
setSaving(true);
setSaveError('');
try {
await moderateAdminGameReview(
token,
target.review.id,
attempt.current.key,
body,
);
if (queryRef.current === submittedQuery) {
setPending(null);
setDetailId('');
setNotice(`评价已${actionLabels[target.action]}`);
}
await load(queryRef.current);
} catch (failure) {
handlePageError(failure, onUnauthorized, (message) => {
if (queryRef.current === submittedQuery) setSaveError(message);
});
} finally {
setSaving(false);
}
}
async function startAction(review: AdminGameReview, action: Action) {
if (action === 'restore') {
const confirmed = await writeConfirm.confirmWrite({
action: '恢复评价',
target: `${review.game.title || review.gameId} · ${review.author.name || review.author.id}`,
});
if (!confirmed) return;
}
const target = { review, action };
setPending(target);
setReason('');
setSaveError('');
attempt.current = null;
if (action === 'restore') await perform(target);
}
const page = query.page ?? 1;
const pages = result?.totalPages ?? 0;
return (
<AdminPage wide>
<AdminPageHeading
title="游戏评价"
actions={
<AdminButton
variant="secondary"
disabled={loading}
onClick={() => void load(query)}
>
刷新
</AdminButton>
}
/>
{error ? (
<AdminAlert tone="warning" role="alert">
{error}
</AdminAlert>
) : null}
{notice ? <AdminAlert tone="success">{notice}</AdminAlert> : null}
<AdminPanel>
<form
className="admin-review-filters"
onSubmit={(event) => {
event.preventDefault();
setNotice('');
setQuery({ ...draft, page: 1, pageSize: 20 });
}}
>
<AdminFieldGroup label="游戏 ID" htmlFor="review-game-id">
<input
id="review-game-id"
value={draft.gameId}
onChange={(event) =>
setDraft({ ...draft, gameId: event.target.value })
}
/>
<AdminButton
type="button"
variant="secondary"
onClick={() => setPickingGame(true)}
>
按名称查找游戏
</AdminButton>
</AdminFieldGroup>
<AdminFieldGroup label="评价用户 ID" htmlFor="review-user-id">
<input
id="review-user-id"
value={draft.userId}
onChange={(event) =>
setDraft({ ...draft, userId: event.target.value })
}
/>
</AdminFieldGroup>
<AdminFieldGroup label="评论关键词" htmlFor="review-keyword">
<input
id="review-keyword"
value={draft.keyword}
onChange={(event) =>
setDraft({ ...draft, keyword: event.target.value })
}
/>
</AdminFieldGroup>
<AdminFieldGroup label="评价状态" htmlFor="review-status">
<select
id="review-status"
value={draft.status}
onChange={(event) =>
setDraft({
...draft,
status: event.target.value as Filters['status'],
})
}
>
<option value="all">全部</option>
<option value="visible">公开</option>
<option value="hidden">已隐藏</option>
</select>
</AdminFieldGroup>
<AdminActionRow>
<AdminButton type="submit">查询</AdminButton>
<AdminButton
type="button"
variant="secondary"
onClick={() => {
setDraft(emptyFilters);
setQuery({ ...emptyFilters, page: 1, pageSize: 20 });
setNotice('');
}}
>
重置
</AdminButton>
</AdminActionRow>
</form>
</AdminPanel>
<AdminListPanel
title="评价列表"
busy={loading}
busyText="正在加载评价…"
busyVariant="line"
rows={result?.reviews ?? []}
emptyText="暂无评价。"
emptyVariant="text"
size="wide"
columns={[
{ key: 'game', label: '游戏' },
{ key: 'author', label: '评价用户' },
{ key: 'score', label: '评分' },
{ key: 'comment', label: '评论' },
{ key: 'status', label: '状态' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'updatedAt', label: '修改时间' },
{ key: 'actions', label: '操作' },
]}
renderRow={(review) => (
<tr key={review.id}>
<td>
<strong>{review.game.title || '—'}</strong>
<div>
<code>{review.gameId}</code>
</div>
</td>
<td>
{review.author.name || '—'}
<div>
<code>{review.author.id}</code>
</div>
</td>
<td>{review.score}/10</td>
<td>
<p className="admin-review-comment admin-review-comment-summary">
{commentSummary(review.comment)}
</p>
</td>
<td>
<AdminStatusPill tone={review.isHidden ? 'error' : 'ok'}>
{review.isHidden ? '已隐藏' : '公开'}
</AdminStatusPill>
</td>
<td>{formatAdminDateTime(review.createdAt)}</td>
<td>{formatAdminDateTime(review.updatedAt)}</td>
<td>
<AdminActionRow>
<AdminButton
variant="secondary"
onClick={() => setDetailId(review.id)}
>
查看详情
</AdminButton>
<AdminButton
variant="secondary"
disabled={saving || writeConfirm.isConfirming}
onClick={() =>
void startAction(
review,
review.isHidden ? 'restore' : 'hide',
)
}
>
{review.isHidden ? '恢复' : '隐藏'}
</AdminButton>
<AdminButton
variant="danger"
disabled={saving || writeConfirm.isConfirming}
onClick={() => void startAction(review, 'delete')}
>
删除
</AdminButton>
</AdminActionRow>
</td>
</tr>
)}
/>
<AdminPagination
ariaLabel="评价分页"
busy={loading}
info={`共 ${result?.total ?? 0} 条 · ${pages ? page : 0} / ${pages} 页`}
previous={{
disabled: page <= 1,
onClick: () => setQuery({ ...query, page: page - 1 }),
}}
next={{
disabled: page >= pages,
onClick: () => setQuery({ ...query, page: page + 1 }),
}}
/>
{pickingGame ? (
<GamePicker
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setPickingGame(false)}
onSelect={(gameId) => {
setDraft({ ...draft, gameId });
setPickingGame(false);
}}
/>
) : null}
{detailId ? (
<ReviewDetail
key={detailId}
token={token}
reviewId={detailId}
onUnauthorized={onUnauthorized}
onClose={() => setDetailId('')}
/>
) : null}
{pending ? (
<AdminDialog
title={`${actionLabels[pending.action]}评价`}
ariaLabel={`${actionLabels[pending.action]}评价`}
closeDisabled={saving}
closeOnBackdrop={!saving}
closeOnEscape={!saving}
onClose={() => {
if (!saving) setPending(null);
}}
>
<p>
{pending.review.game.title || pending.review.gameId} ·{' '}
{pending.review.author.name || pending.review.author.id} ·{' '}
{pending.review.score}/10
</p>
{pending.action === 'delete' ? (
<AdminAlert tone="warning">
删除后不可恢复,用户可以重新评价
</AdminAlert>
) : null}
{pending.action !== 'restore' ? (
<AdminFieldGroup
label="操作原因(必填)"
htmlFor="review-moderation-reason"
>
<textarea
id="review-moderation-reason"
rows={4}
disabled={saving}
value={reason}
onChange={(event) =>
setReason(event.target.value.replace(/\r\n?/g, '\n'))
}
aria-describedby="review-reason-count"
/>
<span id="review-reason-count">
{Array.from(reason).length}/4000
</span>
</AdminFieldGroup>
) : null}
{saveError ? (
<AdminAlert tone="warning" role="alert">
{saveError}
</AdminAlert>
) : null}
<AdminActionRow>
<AdminButton
variant="secondary"
disabled={saving}
onClick={() => setPending(null)}
>
取消
</AdminButton>
<AdminButton
variant={pending.action === 'restore' ? 'primary' : 'danger'}
disabled={
saving ||
(pending.action !== 'restore' &&
Array.from(reason.trim()).length > 4000)
}
onClick={() => void perform(pending)}
>
{saving ? '处理中…' : `确认${actionLabels[pending.action]}`}
</AdminButton>
</AdminActionRow>
</AdminDialog>
) : null}
{writeConfirm.confirmDialog}
</AdminPage>
);
}
function GamePicker({
token,
onUnauthorized,
onClose,
onSelect,
}: PageProps & { onClose: () => void; onSelect: (gameId: string) => void }) {
const [draft, setDraft] = useState('');
const [query, setQuery] = useState({ query: '', page: 1, pageSize: 20 });
const [result, setResult] = useState<AdminGameReviewGamesResponse | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let current = true;
setLoading(true);
setResult(null);
setError('');
void listAdminGameReviewGames(token, query)
.then((response) => {
if (current) setResult(response);
})
.catch((failure: unknown) => {
if (current) handlePageError(failure, onUnauthorized, setError);
})
.finally(() => {
if (current) setLoading(false);
});
return () => {
current = false;
};
}, [token, query, onUnauthorized]);
const pages = result?.totalPages ?? 0;
return (
<AdminDialog title="查找游戏" ariaLabel="查找游戏" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
setQuery({ query: draft.trim(), page: 1, pageSize: 20 });
}}
>
<AdminFieldGroup label="游戏名称或 ID" htmlFor="review-game-search">
<input
id="review-game-search"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
</AdminFieldGroup>
<AdminButton type="submit">搜索</AdminButton>
</form>
{error ? (
<AdminAlert tone="warning" role="alert">
{error}
</AdminAlert>
) : null}
<AdminListPanel
rows={result?.games ?? []}
busy={loading}
busyText="正在查找游戏…"
emptyText="没有匹配的游戏。"
columns={[
{ key: 'title', label: '游戏' },
{ key: 'status', label: '游戏状态' },
{ key: 'action', label: '操作' },
]}
renderRow={(game) => (
<tr key={game.gameId}>
<td>
{game.title || '—'}
<div>
<code>{game.gameId}</code>
</div>
</td>
<td>
{{
published: '已公开',
suspended: '已下架',
unpublished: '未公开',
}[game.status] ?? game.status}
</td>
<td>
<AdminButton
variant="secondary"
onClick={() => onSelect(game.gameId)}
>
选择
</AdminButton>
</td>
</tr>
)}
/>
<AdminPagination
ariaLabel="游戏查找分页"
busy={loading}
info={`共 ${result?.total ?? 0} 条 · ${pages ? query.page : 0} / ${pages} 页`}
previous={{
disabled: query.page <= 1,
onClick: () => setQuery({ ...query, page: query.page - 1 }),
}}
next={{
disabled: query.page >= pages,
onClick: () => setQuery({ ...query, page: query.page + 1 }),
}}
/>
</AdminDialog>
);
}
function ReviewDetail({
token,
reviewId,
onUnauthorized,
onClose,
}: PageProps & { reviewId: string; onClose: () => void }) {
const [result, setResult] = useState<AdminGameReviewDetailResponse | null>(
null,
);
const [error, setError] = useState('');
useEffect(() => {
let current = true;
void getAdminGameReview(token, reviewId)
.then((response) => {
if (current) setResult(response);
})
.catch((failure: unknown) => {
if (current) handlePageError(failure, onUnauthorized, setError);
});
return () => {
current = false;
};
}, [token, reviewId, onUnauthorized]);
return (
<AdminDialog title="评价详情" ariaLabel="评价详情" onClose={onClose}>
{error ? (
<AdminAlert tone="warning" role="alert">
{error}
</AdminAlert>
) : result ? (
<>
<AdminInfoList
items={[
{ label: '游戏', value: result.review.game.title || '—' },
{ label: '游戏 ID', value: result.review.gameId },
{ label: '评价用户', value: result.review.author.name || '—' },
{ label: '用户 ID', value: result.review.author.id },
{ label: '评价 ID', value: result.review.id },
{ label: '评分', value: `${result.review.score}/10` },
{
label: '状态',
value: result.review.isHidden ? '已隐藏' : '公开',
},
{
label: '提交时间',
value: formatAdminDateTime(result.review.createdAt),
},
{
label: '修改时间',
value: formatAdminDateTime(result.review.updatedAt),
},
]}
/>
<p className="admin-review-comment">
{result.review.comment || '仅评分'}
</p>
<AdminListPanel
title="操作记录"
rows={result.operations}
emptyText="暂无操作记录。"
size="wide"
columns={[
{ key: 'action', label: '操作' },
{ key: 'admin', label: '管理员 ID' },
{ key: 'reason', label: '原因' },
{ key: 'time', label: '时间' },
]}
renderRow={(operation) => (
<tr key={operation.id}>
<td>
{actionLabels[operation.action as Action] ?? operation.action}
</td>
<td>
<code>{operation.adminUserId}</code>
</td>
<td>
<p className="admin-review-comment">
{operation.reason || '—'}
</p>
</td>
<td>{formatAdminDateTime(operation.createdAt)}</td>
</tr>
)}
/>
</>
) : (
<p role="status">正在加载评价详情…</p>
)}
</AdminDialog>
);
}