8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
871 lines
28 KiB
TypeScript
871 lines
28 KiB
TypeScript
import type { AdminStatusTone } from '@genarrative/shared/components';
|
|
import {
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminDialog,
|
|
AdminField,
|
|
AdminFormRow,
|
|
AdminInfoItem,
|
|
AdminInfoList,
|
|
AdminListPanel,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
AdminPanel,
|
|
AdminStatusPill,
|
|
formatAdminDateTime,
|
|
} from '@genarrative/shared/components';
|
|
import { Eye, FileText, RefreshCcw, Upload } from 'lucide-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,
|
|
AdminEditorAssetPreviewMedia,
|
|
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 (
|
|
<AdminPage wide>
|
|
<AdminPageHeading
|
|
title="精选审核"
|
|
actions={
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={refreshPage}
|
|
>
|
|
<RefreshCcw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
|
|
|
<AdminListPanel
|
|
alwaysRenderChildren
|
|
className="admin-stack"
|
|
emptyText="暂无精选审核素材"
|
|
rows={entries}
|
|
showEmpty={!isLoading && entries.length === 0}
|
|
toolbar={
|
|
<div className="admin-form-row admin-asset-query-filter-row">
|
|
<AdminField label="状态">
|
|
<select
|
|
value={reviewStatus}
|
|
onChange={(event) => setReviewStatus(event.target.value)}
|
|
>
|
|
{reviewStatusOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</AdminField>
|
|
<AdminField label="开始时间">
|
|
<input
|
|
type="date"
|
|
value={submittedAfter}
|
|
onChange={(event) => setSubmittedAfter(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="结束时间">
|
|
<input
|
|
type="date"
|
|
value={submittedBefore}
|
|
onChange={(event) => setSubmittedBefore(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="用户 ID">
|
|
<input
|
|
value={ownerUserId}
|
|
onChange={(event) => setOwnerUserId(event.target.value)}
|
|
/>
|
|
</AdminField>
|
|
</div>
|
|
}
|
|
pagination={
|
|
nextCursor ? (
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isLoadingMore}
|
|
type="button"
|
|
onClick={loadMore}
|
|
>
|
|
<span>{isLoadingMore ? '读取中' : '读取更多'}</span>
|
|
</AdminButton>
|
|
) : null
|
|
}
|
|
columns={[
|
|
{ key: 'thumbnail', label: '资源图' },
|
|
{ key: 'submittedAt', label: '提交时间' },
|
|
{ key: 'author', label: '作者' },
|
|
{ key: 'assetKind', label: '分类' },
|
|
{ key: 'reviewStatus', label: '状态' },
|
|
{ key: 'prompt', label: '提示词' },
|
|
{ key: 'cost', label: '成本 / 返还' },
|
|
{ key: 'review', label: '审核' },
|
|
]}
|
|
renderRow={(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>{formatAdminDateTime(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>
|
|
<AdminStatusPill tone={reviewStatusTone(entry.reviewStatus)}>
|
|
{reviewStatusLabel(entry.reviewStatus)}
|
|
</AdminStatusPill>
|
|
{entry.reviewStatus === 'approved' ? (
|
|
<small>{entry.displayEnabled ? '展示中' : '未展示'}</small>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
<AdminButton
|
|
variant="text"
|
|
disabled={promptText === '-'}
|
|
title={promptText}
|
|
type="button"
|
|
onClick={() =>
|
|
setPromptPreview({
|
|
title: entry.label || entry.showcaseId,
|
|
prompt: promptText,
|
|
})
|
|
}
|
|
className="admin-asset-query-prompt-text"
|
|
>
|
|
{promptText}
|
|
</AdminButton>
|
|
</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,
|
|
}))
|
|
}
|
|
/>
|
|
<AdminButton
|
|
variant="primary"
|
|
type="button"
|
|
onClick={() => submitReview(entry, 'approved')}
|
|
>
|
|
通过
|
|
</AdminButton>
|
|
<AdminButton
|
|
variant="danger"
|
|
type="button"
|
|
onClick={() => submitReview(entry, 'rejected')}
|
|
>
|
|
拒绝
|
|
</AdminButton>
|
|
</>
|
|
) : null}
|
|
{entry.reviewStatus === 'approved' ? (
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
onClick={() => toggleDisplay(entry)}
|
|
>
|
|
{entry.displayEnabled ? '隐藏' : '展示'}
|
|
</AdminButton>
|
|
) : null}
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
onClick={() => setDetailEntry(entry)}
|
|
>
|
|
<Eye size={16} aria-hidden="true" />
|
|
<span>详情</span>
|
|
</AdminButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
tableClassName="admin-showcase-review-table"
|
|
/>
|
|
|
|
<AdminPanel
|
|
className="admin-stack"
|
|
title="精选活动卡"
|
|
extra={
|
|
<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>
|
|
}
|
|
>
|
|
<AdminFormRow className="admin-showcase-campaign-grid">
|
|
<AdminField label="标题">
|
|
<input
|
|
value={campaignDraft.title}
|
|
onChange={(event) =>
|
|
setCampaignDraft((current) => ({
|
|
...current,
|
|
title: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="图片地址">
|
|
<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])
|
|
}
|
|
/>
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isUploadingCampaignImage}
|
|
type="button"
|
|
onClick={() => campaignImageInputRef.current?.click()}
|
|
>
|
|
<Upload size={16} aria-hidden="true" />
|
|
<span>{isUploadingCampaignImage ? '上传中' : '上传'}</span>
|
|
</AdminButton>
|
|
</div>
|
|
</AdminField>
|
|
<AdminField label="作者">
|
|
<input
|
|
value={campaignDraft.author}
|
|
onChange={(event) =>
|
|
setCampaignDraft((current) => ({
|
|
...current,
|
|
author: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="成本文案">
|
|
<input
|
|
value={campaignDraft.costText}
|
|
onChange={(event) =>
|
|
setCampaignDraft((current) => ({
|
|
...current,
|
|
costText: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</AdminField>
|
|
<AdminField label="提示词" className="admin-showcase-campaign-prompt">
|
|
<textarea
|
|
rows={3}
|
|
value={campaignDraft.prompt}
|
|
onChange={(event) =>
|
|
setCampaignDraft((current) => ({
|
|
...current,
|
|
prompt: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</AdminField>
|
|
<AdminButton
|
|
variant="primary"
|
|
disabled={isSavingCampaign}
|
|
type="button"
|
|
onClick={saveCampaign}
|
|
>
|
|
{isSavingCampaign ? '保存中' : '保存活动卡'}
|
|
</AdminButton>
|
|
</AdminFormRow>
|
|
</AdminPanel>
|
|
|
|
{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 ? (
|
|
<AdminDialog
|
|
closeLabel="关闭完整提示词"
|
|
description={promptPreview.title}
|
|
panelClassName="admin-asset-query-prompt-dialog"
|
|
title="完整提示词"
|
|
titleProps={{ id: 'admin-showcase-prompt-dialog-title' }}
|
|
onClose={() => setPromptPreview(null)}
|
|
>
|
|
<pre className="admin-code-block admin-asset-query-prompt-full">
|
|
{promptPreview.prompt}
|
|
</pre>
|
|
</AdminDialog>
|
|
) : null}
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AdminDialog
|
|
ariaLabel="精选素材详情"
|
|
closeLabel="关闭详情"
|
|
description={entry.showcaseId}
|
|
panelClassName="admin-asset-query-detail-dialog"
|
|
title={entry.label || entry.showcaseId}
|
|
onClose={onClose}
|
|
>
|
|
<div className="admin-asset-query-detail-layout">
|
|
<div className="admin-asset-query-detail-media">
|
|
{entry.sourceImageSrc?.trim() ? (
|
|
<div className="admin-asset-query-detail-media-card">
|
|
<h4>原图</h4>
|
|
<AdminEditorAssetPreviewMedia
|
|
entry={{
|
|
assetId: entry.sourceResourceId ?? `${entry.assetId}-source`,
|
|
label: entry.sourceLabel?.trim() || '原图',
|
|
imageSrc: entry.sourceImageSrc,
|
|
objectKey: entry.sourceObjectKey,
|
|
}}
|
|
token={token}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<div className="admin-asset-query-detail-media-card">
|
|
<h4>当前结果</h4>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<AdminInfoList className="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="提交时间">
|
|
{formatAdminDateTime(entry.submittedAt)}
|
|
</AdminInfoItem>
|
|
<AdminInfoItem label="审核时间">
|
|
{entry.reviewedAt ? formatAdminDateTime(entry.reviewedAt) : '-'}
|
|
</AdminInfoItem>
|
|
<AdminInfoItem label="审核备注">
|
|
{entry.reviewNote || '-'}
|
|
</AdminInfoItem>
|
|
<AdminInfoItem label="提示词">
|
|
{promptText ? (
|
|
<AdminButton
|
|
variant="text"
|
|
type="button"
|
|
onClick={() => onPromptPreview(entry, promptText)}
|
|
>
|
|
<FileText size={13} aria-hidden="true" />
|
|
<span>完整提示词</span>
|
|
</AdminButton>
|
|
) : (
|
|
'-'
|
|
)}
|
|
</AdminInfoItem>
|
|
<AdminInfoItem label="生成输入">
|
|
<pre className="admin-code-block">
|
|
{formatGenerationInputs(entry.generationInputs)}
|
|
</pre>
|
|
</AdminInfoItem>
|
|
</AdminInfoList>
|
|
</div>
|
|
</AdminDialog>
|
|
);
|
|
}
|
|
|
|
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 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 reviewStatusTone(value: string): AdminStatusTone {
|
|
if (value === 'approved') {
|
|
return 'ok';
|
|
}
|
|
if (value === 'rejected') {
|
|
return 'error';
|
|
}
|
|
return '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);
|
|
}
|