Files
Genarrative/apps/admin-web/src/pages/AdminGameReviewsPage.tsx
T

676 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="评价列表"
tableClassName="admin-game-reviews-table"
busy={loading}
busyText="正在加载评价…"
busyVariant="line"
rows={result?.reviews ?? []}
emptyText="暂无评价。"
emptyVariant="text"
size="wide"
tableClassName="admin-game-reviews-table"
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 className="admin-game-reviews-actions-cell">
<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>
);
}