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
公开游戏详情页新增评分与评价:登录用户可提交并修改自己对每款游戏的唯一一条 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>
674 lines
21 KiB
TypeScript
674 lines
21 KiB
TypeScript
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>
|
||
);
|
||
}
|