Files
Genarrative/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx
T
kdletters 5d6a426013 统一后台精选审核素材预览
抽取后台素材媒体共享组件并统一懒换签与限流重试
为精选审核补充列表和详情中的素材预览交互
透传精选素材缩略图字段并覆盖历史 OSS 地址回归
同步后台预览数据契约与排障文档
2026-07-22 18:49:09 +08:00

913 lines
30 KiB
TypeScript

import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
getAdminEditorShowcaseCampaign,
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
updateAdminEditorShowcaseDisplay,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminEditorShowcaseCampaign,
} from '../api/adminApiClient';
import type {
AdminEditorShowcaseAssetPayload,
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import {
AdminEditorAssetPreviewDialog,
AdminEditorAssetThumbnail,
} from '../components/AdminEditorAssetMedia';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminEditorShowcaseReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const showcaseCategoryOptions = [
{ value: 'characters', label: '角色' },
{ value: 'ui', label: 'UI' },
{ value: 'music', label: '音乐' },
{ value: 'marketing', label: '美宣' },
];
const reviewStatusOptions = [
{ value: '', label: '全部' },
{ value: 'pending', label: '待审核' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
];
export function AdminEditorShowcaseReviewPage({
token,
onUnauthorized,
}: AdminEditorShowcaseReviewPageProps) {
const [entries, setEntries] = useState<AdminEditorShowcaseAssetPayload[]>([]);
const [reviewStatus, setReviewStatus] = useState('pending');
const [ownerUserId, setOwnerUserId] = useState('');
const [submittedAfter, setSubmittedAfter] = useState('');
const [submittedBefore, setSubmittedBefore] = useState('');
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const [detailEntry, setDetailEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
} | null>(null);
const [campaignDraft, setCampaignDraft] =
useState<AdminEditorShowcaseCampaignPayload>({
enabled: false,
title: '',
imageSrc: '',
prompt: '',
author: '',
costText: '',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
});
const [isSavingCampaign, setIsSavingCampaign] = useState(false);
const [isUploadingCampaignImage, setIsUploadingCampaignImage] =
useState(false);
const campaignImageInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
void refreshPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, reviewStatus, ownerUserId, submittedAfter, submittedBefore]);
useEffect(() => {
void getAdminEditorShowcaseCampaign(token)
.then((response) => {
if (response.campaign) {
setCampaignDraft(response.campaign);
}
})
.catch((error: unknown) =>
handlePageError(error, onUnauthorized, setErrorMessage),
);
}, [token, onUnauthorized]);
async function refreshPage() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminEditorShowcaseAssets(
token,
buildListQuery(),
);
setEntries(response.entries);
setNextCursor(response.nextCursor ?? null);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
async function loadMore() {
if (!nextCursor || isLoadingMore) {
return;
}
setIsLoadingMore(true);
setErrorMessage('');
try {
const response = await listAdminEditorShowcaseAssets(token, {
...buildListQuery(),
cursor: nextCursor,
});
setEntries((current) => mergeShowcaseEntries(current, response.entries));
setNextCursor(response.nextCursor ?? null);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingMore(false);
}
}
function buildListQuery(): AdminEditorShowcaseListQuery {
return {
ownerUserId: ownerUserId || null,
reviewStatus: reviewStatus || null,
submittedAfter: dateInputToStartRfc3339(submittedAfter),
submittedBefore: dateInputToEndRfc3339(submittedBefore),
limit: 80,
};
}
async function submitReview(
entry: AdminEditorShowcaseAssetPayload,
nextStatus: 'approved' | 'rejected',
) {
setErrorMessage('');
try {
const response = await reviewAdminEditorShowcaseAsset(token, {
showcaseId: entry.showcaseId,
reviewStatus: nextStatus,
reviewNote: reviewNotes[entry.showcaseId]?.trim() || null,
});
replaceEntry(response.entry);
setReviewNotes((current) => ({
...current,
[entry.showcaseId]: '',
}));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
}
}
async function toggleDisplay(entry: AdminEditorShowcaseAssetPayload) {
setErrorMessage('');
try {
const response = await updateAdminEditorShowcaseDisplay(token, {
showcaseId: entry.showcaseId,
displayEnabled: !entry.displayEnabled,
showcaseCategory: entry.showcaseCategory ?? null,
});
replaceEntry(response.entry);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
}
}
async function updateCategory(
entry: AdminEditorShowcaseAssetPayload,
showcaseCategory: string,
) {
setErrorMessage('');
const nextCategory = showcaseCategory.trim();
try {
const response = await updateAdminEditorShowcaseDisplay(token, {
showcaseId: entry.showcaseId,
displayEnabled: entry.displayEnabled,
showcaseCategory: nextCategory,
});
replaceEntry(response.entry);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
}
}
async function saveCampaign() {
setIsSavingCampaign(true);
setErrorMessage('');
try {
const response = await upsertAdminEditorShowcaseCampaign(token, {
enabled: campaignDraft.enabled,
title: campaignDraft.title,
imageSrc: campaignDraft.imageSrc,
imageObjectKey: campaignDraft.imageObjectKey ?? null,
imageWidth: campaignDraft.imageWidth ?? null,
imageHeight: campaignDraft.imageHeight ?? null,
prompt: campaignDraft.prompt,
author: campaignDraft.author,
costText: campaignDraft.costText,
});
if (response.campaign) {
setCampaignDraft(response.campaign);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSavingCampaign(false);
}
}
async function handleCampaignImageFile(file: File | null | undefined) {
if (!file) {
return;
}
setIsUploadingCampaignImage(true);
setErrorMessage('');
try {
const upload = await uploadAdminEditorShowcaseCampaignImage(token, file);
setCampaignDraft((current) => ({
...current,
imageSrc: upload.imageSrc,
imageObjectKey: upload.imageObjectKey,
imageWidth: upload.imageWidth,
imageHeight: upload.imageHeight,
}));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsUploadingCampaignImage(false);
if (campaignImageInputRef.current) {
campaignImageInputRef.current.value = '';
}
}
}
function replaceEntry(entry: AdminEditorShowcaseAssetPayload) {
setEntries((current) =>
current.map((item) =>
item.showcaseId === entry.showcaseId ? entry : item,
),
);
setDetailEntry((current) =>
current?.showcaseId === entry.showcaseId ? entry : current,
);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>精选审核</h2>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel admin-stack">
<div className="admin-form-row admin-asset-query-filter-row">
<label className="admin-field">
<span>状态</span>
<select
value={reviewStatus}
onChange={(event) => setReviewStatus(event.target.value)}
>
{reviewStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span>开始时间</span>
<input
type="date"
value={submittedAfter}
onChange={(event) => setSubmittedAfter(event.target.value)}
/>
</label>
<label className="admin-field">
<span>结束时间</span>
<input
type="date"
value={submittedBefore}
onChange={(event) => setSubmittedBefore(event.target.value)}
/>
</label>
<label className="admin-field">
<span>用户 ID</span>
<input
value={ownerUserId}
onChange={(event) => setOwnerUserId(event.target.value)}
/>
</label>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-showcase-review-table">
<thead>
<tr>
<th>资源图</th>
<th>提交时间</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>提示词</th>
<th>成本 / 返还</th>
<th>审核</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const promptText = entry.prompt || entry.actualPrompt || '-';
return (
<tr key={entry.showcaseId}>
<td>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<small>{entry.label || '-'}</small>
</td>
<td>{formatDateTime(entry.submittedAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
{entry.reviewStatus === 'approved' ? (
<select
aria-label={`精选分类:${entry.label}`}
value={entry.showcaseCategory ?? ''}
onChange={(event) =>
updateCategory(entry, event.target.value)
}
>
<option value="">未设置</option>
{showcaseCategoryOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
'-'
)}
</td>
<td>
<span
className={reviewStatusClassName(entry.reviewStatus)}
>
{reviewStatusLabel(entry.reviewStatus)}
</span>
{entry.reviewStatus === 'approved' ? (
<small>
{entry.displayEnabled ? '展示中' : '未展示'}
</small>
) : null}
</td>
<td>
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
setPromptPreview({
title: entry.label || entry.showcaseId,
prompt: promptText,
})
}
>
{promptText}
</button>
</td>
<td>
{entry.generationCostMudPoints} 泥点
<small>{entry.refundMudPoints} 泥点</small>
</td>
<td>
<div className="admin-showcase-review-actions">
{entry.reviewStatus === 'pending' ? (
<>
<input
aria-label={`审核备注:${entry.label}`}
placeholder="审核备注"
value={reviewNotes[entry.showcaseId] ?? ''}
onChange={(event) =>
setReviewNotes((current) => ({
...current,
[entry.showcaseId]: event.target.value,
}))
}
/>
<button
className="admin-primary-button"
type="button"
onClick={() => submitReview(entry, 'approved')}
>
通过
</button>
<button
className="admin-danger-button"
type="button"
onClick={() => submitReview(entry, 'rejected')}
>
拒绝
</button>
</>
) : null}
{entry.reviewStatus === 'approved' ? (
<button
className="admin-secondary-button"
type="button"
onClick={() => toggleDisplay(entry)}
>
{entry.displayEnabled ? '隐藏' : '展示'}
</button>
) : null}
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span>详情</span>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && entries.length === 0 ? (
<div className="admin-empty-state">暂无精选审核素材</div>
) : null}
{nextCursor ? (
<button
className="admin-secondary-button"
disabled={isLoadingMore}
type="button"
onClick={loadMore}
>
<span>{isLoadingMore ? '读取中' : '读取更多'}</span>
</button>
) : null}
</section>
<section className="admin-panel admin-stack">
<div className="admin-panel-heading">
<div>
<h3>精选活动卡</h3>
</div>
<label className="admin-switch-field">
<input
type="checkbox"
checked={campaignDraft.enabled}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
enabled: event.target.checked,
}))
}
/>
<span>{campaignDraft.enabled ? '已启用' : '未启用'}</span>
</label>
</div>
<div className="admin-form-row admin-showcase-campaign-grid">
<label className="admin-field">
<span>标题</span>
<input
value={campaignDraft.title}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
title: event.target.value,
}))
}
/>
</label>
<label className="admin-field">
<span>图片地址</span>
<div className="admin-showcase-campaign-image-row">
<input
value={campaignDraft.imageSrc}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
imageSrc: event.target.value,
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
}))
}
/>
<input
ref={campaignImageInputRef}
aria-label="上传活动卡图片"
className="admin-hidden-file-input"
type="file"
accept="image/*"
onChange={(event) =>
handleCampaignImageFile(event.currentTarget.files?.[0])
}
/>
<button
className="admin-secondary-button"
disabled={isUploadingCampaignImage}
type="button"
onClick={() => campaignImageInputRef.current?.click()}
>
<Upload size={16} aria-hidden="true" />
<span>{isUploadingCampaignImage ? '上传中' : '上传'}</span>
</button>
</div>
</label>
<label className="admin-field">
<span>作者</span>
<input
value={campaignDraft.author}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
author: event.target.value,
}))
}
/>
</label>
<label className="admin-field">
<span>成本文案</span>
<input
value={campaignDraft.costText}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
costText: event.target.value,
}))
}
/>
</label>
<label className="admin-field admin-showcase-campaign-prompt">
<span>提示词</span>
<textarea
rows={3}
value={campaignDraft.prompt}
onChange={(event) =>
setCampaignDraft((current) => ({
...current,
prompt: event.target.value,
}))
}
/>
</label>
<button
className="admin-primary-button"
disabled={isSavingCampaign}
type="button"
onClick={saveCampaign}
>
{isSavingCampaign ? '保存中' : '保存活动卡'}
</button>
</div>
</section>
{detailEntry ? (
<AdminShowcaseDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.showcaseId,
prompt,
})
}
/>
) : null}
{previewEntry ? (
<AdminEditorAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
className="admin-detail-panel admin-asset-query-prompt-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="admin-showcase-prompt-dialog-title"
>
<div className="admin-panel-heading">
<div>
<h3 id="admin-showcase-prompt-dialog-title">完整提示词</h3>
<span>{promptPreview.title}</span>
</div>
<button
aria-label="关闭完整提示词"
className="admin-ghost-button"
type="button"
onClick={() => setPromptPreview(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<pre className="admin-code-block admin-asset-query-prompt-full">
{promptPreview.prompt}
</pre>
</section>
</div>
) : null}
</section>
);
}
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPreview,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onPreview: (entry: AdminEditorShowcaseAssetPayload) => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
prompt: string,
) => void;
}) {
const promptText = entry.prompt || entry.actualPrompt || '';
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="精选素材详情"
className="admin-detail-panel admin-asset-query-detail-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.showcaseId}</h3>
<span>{entry.showcaseId}</span>
</div>
<button
aria-label="关闭详情"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="admin-asset-query-detail-layout">
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="素材 ID">{entry.assetId}</AdminInfoItem>
<AdminInfoItem label="分类">
{showcaseCategoryLabel(entry.showcaseCategory)}
</AdminInfoItem>
<AdminInfoItem label="状态">
{reviewStatusLabel(entry.reviewStatus)}
</AdminInfoItem>
<AdminInfoItem label="展示">
{entry.displayEnabled ? '展示中' : '未展示'}
</AdminInfoItem>
<AdminInfoItem label="成本">
{entry.generationCostMudPoints} 泥点
</AdminInfoItem>
<AdminInfoItem label="返还">
{entry.refundMudPoints} 泥点
</AdminInfoItem>
<AdminInfoItem label="点赞数">{entry.likeCount}</AdminInfoItem>
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
</AdminInfoItem>
<AdminInfoItem label="Task ID">{entry.taskId || '-'}</AdminInfoItem>
<AdminInfoItem label="Object Key">
{entry.objectKey || '-'}
</AdminInfoItem>
<AdminInfoItem label="提交时间">
{formatDateTime(entry.submittedAt)}
</AdminInfoItem>
<AdminInfoItem label="审核时间">
{entry.reviewedAt ? formatDateTime(entry.reviewedAt) : '-'}
</AdminInfoItem>
<AdminInfoItem label="审核备注">
{entry.reviewNote || '-'}
</AdminInfoItem>
<AdminInfoItem label="提示词">
{promptText ? (
<button
className="admin-text-button"
type="button"
onClick={() => onPromptPreview(entry, promptText)}
>
<FileText size={13} aria-hidden="true" />
<span>完整提示词</span>
</button>
) : (
'-'
)}
</AdminInfoItem>
<AdminInfoItem label="生成输入">
<pre className="admin-code-block">
{formatGenerationInputs(entry.generationInputs)}
</pre>
</AdminInfoItem>
</dl>
</div>
</section>
</div>
);
}
function AdminInfoItem({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function mergeShowcaseEntries(
current: AdminEditorShowcaseAssetPayload[],
incoming: AdminEditorShowcaseAssetPayload[],
) {
const byId = new Map<string, AdminEditorShowcaseAssetPayload>();
[...current, ...incoming].forEach((entry) =>
byId.set(entry.showcaseId, entry),
);
return [...byId.values()].sort(
(left, right) =>
parseAdminTimestamp(right.submittedAt) -
parseAdminTimestamp(left.submittedAt) ||
right.showcaseId.localeCompare(left.showcaseId),
);
}
function dateInputToStartRfc3339(value: string) {
return value ? `${value}T00:00:00+08:00` : null;
}
function dateInputToEndRfc3339(value: string) {
return value ? `${value}T23:59:59.999+08:00` : null;
}
function formatDateTime(value: string) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) {
return value || '-';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
}
function parseAdminTimestamp(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
const secondsMicrosMatch = normalizedValue.match(/^(-?\d+)\.(\d{6})Z$/u);
if (secondsMicrosMatch) {
const seconds = Number(secondsMicrosMatch[1]);
const micros = Number(secondsMicrosMatch[2]);
if (Number.isFinite(seconds) && Number.isFinite(micros)) {
return seconds * 1000 + Math.floor(micros / 1000);
}
}
return Date.parse(normalizedValue);
}
function showcaseCategoryLabel(value: string | null | undefined) {
const normalizedValue = value?.trim() ?? '';
return (
showcaseCategoryOptions.find((option) => option.value === normalizedValue)
?.label ||
normalizedValue ||
'-'
);
}
function reviewStatusLabel(value: string) {
if (value === 'pending') {
return '待审核';
}
if (value === 'approved') {
return '已通过';
}
if (value === 'rejected') {
return '已拒绝';
}
return value || '-';
}
function reviewStatusClassName(value: string) {
if (value === 'approved') {
return 'admin-status admin-status-ok';
}
if (value === 'rejected') {
return 'admin-status admin-status-error';
}
return 'admin-status admin-status-pending';
}
function authorDisplayName(entry: AdminEditorShowcaseAssetPayload) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function formatGenerationInputs(
value: Record<string, unknown> | null | undefined,
) {
if (!value) {
return '-';
}
return JSON.stringify(value, null, 2);
}