feat: add admin gallery management UI

This commit is contained in:
FengLee
2026-05-20 10:47:13 +08:00
parent 518c02f1ba
commit 632c94be78
2 changed files with 497 additions and 0 deletions

View File

@@ -0,0 +1,489 @@
'use client';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Edit3, Eye, ImageIcon, Loader2, Mail, RefreshCcw, Search, Send } from 'lucide-react';
import { toast } from 'sonner';
import { useAuth } from '@/lib/auth-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
type GalleryWorkType = 'text2img' | 'img2img' | 'text2video' | 'img2video';
type GalleryFilterType = 'all' | 'image' | 'video' | GalleryWorkType;
interface AdminGalleryWork {
id: string;
type: GalleryWorkType;
title: string | null;
prompt: string | null;
negativePrompt: string | null;
url: string | null;
thumbnailUrl: string | null;
likes: number;
authorId: string | null;
authorEmail: string;
authorNickname: string;
authorAvatarUrl: string | null;
publishedAt: string | null;
}
interface GalleryWorksResponse {
works?: AdminGalleryWork[];
total?: number;
nextOffset?: number;
hasMore?: boolean;
error?: string;
}
type ReasonTemplateKey =
| 'remove_sensitive_words'
| 'improve_wording'
| 'remove_private_info'
| 'platform_policy_adjustment';
const PAGE_SIZE = 20;
const TYPE_OPTIONS: Array<{ value: GalleryFilterType; label: string }> = [
{ value: 'all', label: '全部公开作品' },
{ value: 'image', label: '全部图片' },
{ value: 'video', label: '全部视频' },
{ value: 'text2img', label: '文生图' },
{ value: 'img2img', label: '图生图' },
{ value: 'text2video', label: '文生视频' },
{ value: 'img2video', label: '图生视频' },
];
const TYPE_LABELS: Record<string, string> = {
text2img: '文生图',
img2img: '图生图',
text2video: '文生视频',
img2video: '图生视频',
};
const REASON_TEMPLATES: Array<{ key: ReasonTemplateKey; label: string; description: string }> = [
{ key: 'remove_sensitive_words', label: '删除敏感词', description: '删除敏感词,确保公开展示合规' },
{ key: 'improve_wording', label: '优化表述', description: '优化提示词表述,避免误导或不适内容' },
{ key: 'remove_private_info', label: '移除隐私', description: '移除个人信息或隐私相关描述' },
{ key: 'platform_policy_adjustment', label: '平台规范', description: '根据平台内容规范调整公开展示文案' },
];
function formatDateTime(value: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', { hour12: false });
}
function isVideoWork(work: AdminGalleryWork | null) {
return work?.type === 'text2video' || work?.type === 'img2video';
}
function shortId(id: string) {
return id.slice(0, 8);
}
function buildEmailTemplate(work: AdminGalleryWork, template: { key: ReasonTemplateKey; description: string }) {
return {
subject: '公开画廊作品提示词已调整',
body: [
`${work.authorNickname || '你好'}`,
'',
`你分享至妙境公开画廊的作品ID${shortId(work.id)})提示词已由管理员调整。`,
`调整原因:${template.description}`,
'',
'本次调整只影响作品在公开画廊中展示的提示词文案,不会删除你的作品或修改生成结果。',
'如有疑问,请通过平台联系方式反馈。',
].join('\n'),
};
}
function defaultEmailDraft(work: AdminGalleryWork | null) {
if (!work) return { subject: '', body: '' };
return buildEmailTemplate(work, REASON_TEMPLATES[0]);
}
export default function GalleryManagementTab() {
const { accessToken } = useAuth();
const [works, setWorks] = useState<AdminGalleryWork[]>([]);
const [searchDraft, setSearchDraft] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [type, setType] = useState<GalleryFilterType>('all');
const [total, setTotal] = useState(0);
const [nextOffset, setNextOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [emailOpen, setEmailOpen] = useState(false);
const [selectedWork, setSelectedWork] = useState<AdminGalleryWork | null>(null);
const [promptDraft, setPromptDraft] = useState('');
const [reasonKey, setReasonKey] = useState<ReasonTemplateKey | 'custom'>('remove_sensitive_words');
const [emailSubject, setEmailSubject] = useState('');
const [emailBody, setEmailBody] = useState('');
const [saving, setSaving] = useState(false);
const headers = useMemo<HeadersInit>(() => ({
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
}), [accessToken]);
const loadWorks = useCallback(async ({ append = false, offset = 0 }: { append?: boolean; offset?: number } = {}) => {
if (!accessToken) return;
append ? setLoadingMore(true) : setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String(offset),
type,
});
if (activeSearch.trim()) params.set('q', activeSearch.trim());
const res = await fetch(`/api/admin/gallery/works?${params.toString()}`, {
headers,
cache: 'no-store',
});
const data = (await res.json().catch(() => ({}))) as GalleryWorksResponse;
if (!res.ok) throw new Error(data.error || '加载画廊作品失败');
const incoming = Array.isArray(data.works) ? data.works : [];
setWorks(prev => append
? [...prev, ...incoming.filter(work => !prev.some(item => item.id === work.id))]
: incoming);
setTotal(Number(data.total || 0));
setNextOffset(Number(data.nextOffset || offset + incoming.length));
setHasMore(Boolean(data.hasMore));
} catch (error) {
toast.error(error instanceof Error ? error.message : '加载画廊作品失败');
} finally {
append ? setLoadingMore(false) : setLoading(false);
}
}, [accessToken, activeSearch, headers, type]);
useEffect(() => {
void loadWorks({ offset: 0 });
}, [loadWorks]);
function submitSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setActiveSearch(searchDraft.trim());
}
function openEditor(work: AdminGalleryWork) {
setSelectedWork(work);
setPromptDraft(work.prompt || '');
const draft = defaultEmailDraft(work);
setReasonKey('remove_sensitive_words');
setEmailSubject(draft.subject);
setEmailBody(draft.body);
setEditOpen(true);
}
function openEmailDialog() {
if (!selectedWork) return;
if (!promptDraft.trim()) {
toast.error('请填写新的提示词');
return;
}
if (promptDraft.trim() === (selectedWork.prompt || '').trim()) {
toast.error('提示词没有变化');
return;
}
const draft = defaultEmailDraft(selectedWork);
if (!emailSubject.trim()) setEmailSubject(draft.subject);
if (!emailBody.trim()) setEmailBody(draft.body);
setEditOpen(false);
setEmailOpen(true);
}
function applyReasonTemplate(key: ReasonTemplateKey) {
if (!selectedWork) return;
const template = REASON_TEMPLATES.find(item => item.key === key);
if (!template) return;
const draft = buildEmailTemplate(selectedWork, template);
setReasonKey(key);
setEmailSubject(draft.subject);
setEmailBody(draft.body);
}
async function submitPromptUpdate() {
if (!selectedWork) return;
if (!emailSubject.trim() || !emailBody.trim()) {
toast.error('请填写邮件标题和正文');
return;
}
setSaving(true);
try {
const res = await fetch('/api/admin/gallery/prompt', {
method: 'PUT',
headers,
body: JSON.stringify({
workId: selectedWork.id,
prompt: promptDraft.trim(),
emailSubject: emailSubject.trim(),
emailBody: emailBody.trim(),
reasonKey,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '修改提示词失败');
const updated = data.work as AdminGalleryWork;
setWorks(prev => prev.map(work => (work.id === updated.id ? { ...work, ...updated } : work)));
toast.success('提示词已修改,通知邮件已发送');
setEmailOpen(false);
setSelectedWork(null);
setPromptDraft('');
} catch (error) {
toast.error(error instanceof Error ? error.message : '修改提示词失败');
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between" onSubmit={submitSearch}>
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Input
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
placeholder="作品 ID、提示词、作者邮箱或昵称"
/>
<Button type="submit" variant="outline" className="shrink-0 gap-2">
<Search className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={type} onValueChange={(value) => { setType(value as GalleryFilterType); setNextOffset(0); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button type="button" variant="outline" className="gap-2" onClick={() => loadWorks({ offset: 0 })} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
</Button>
</form>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="px-3 py-10 text-center text-muted-foreground" colSpan={6}>...</td></tr>
) : works.length === 0 ? (
<tr><td className="px-3 py-10 text-center text-muted-foreground" colSpan={6}></td></tr>
) : works.map(work => (
<tr key={work.id} className="border-t align-top">
<td className="px-3 py-3">
<div className="flex min-w-[220px] items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
{isVideoWork(work) && work.url ? (
<video src={work.url} className="h-full w-full object-cover" muted playsInline />
) : work.thumbnailUrl || work.url ? (
<img src={work.thumbnailUrl || work.url || ''} alt="" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="secondary">{TYPE_LABELS[work.type] || work.type}</Badge>
<span className="font-mono text-xs text-muted-foreground">{shortId(work.id)}</span>
</div>
<div className="mt-1 max-w-[220px] truncate font-medium">{work.title || '未命名作品'}</div>
</div>
</div>
</td>
<td className="px-3 py-3">
<div className="max-w-[220px]">
<div className="truncate font-medium">{work.authorNickname || '匿名用户'}</div>
<div className="truncate text-xs text-muted-foreground">{work.authorEmail || '-'}</div>
</div>
</td>
<td className="px-3 py-3">
<div className="line-clamp-3 max-w-[380px] whitespace-pre-wrap text-muted-foreground">
{work.prompt || '-'}
</div>
</td>
<td className="px-3 py-3 text-muted-foreground">{formatDateTime(work.publishedAt)}</td>
<td className="px-3 py-3">
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Eye className="h-4 w-4" />
{Number(work.likes || 0)}
</span>
</td>
<td className="px-3 py-3 text-right">
<Button size="sm" className="gap-2" onClick={() => openEditor(work)}>
<Edit3 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span> {total} {works.length} </span>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loadingMore}
onClick={() => loadWorks({ append: true, offset: nextOffset })}
>
{loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{hasMore ? '加载更多' : '没有更多'}
</Button>
</div>
</CardContent>
</Card>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedWork && (
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-3">
<div className="aspect-square overflow-hidden rounded-lg border bg-muted">
{isVideoWork(selectedWork) && selectedWork.url ? (
<video src={selectedWork.url} className="h-full w-full object-cover" muted playsInline controls />
) : selectedWork.thumbnailUrl || selectedWork.url ? (
<img src={selectedWork.thumbnailUrl || selectedWork.url || ''} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
</div>
<div className="rounded-md border bg-muted/35 p-3 text-xs text-muted-foreground">
<div className="font-medium text-foreground">{selectedWork.authorNickname || '匿名用户'}</div>
<div className="mt-1 break-all">{selectedWork.authorEmail || '-'}</div>
<div className="mt-2 font-mono">{selectedWork.id}</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<div className="max-h-32 overflow-y-auto rounded-md border bg-muted/35 p-3 text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap font-sans">{selectedWork.prompt || '-'}</pre>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={promptDraft}
onChange={(event) => setPromptDraft(event.target.value)}
className="min-h-44"
placeholder="填写公开画廊中展示的新提示词"
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}></Button>
<Button onClick={openEmailDialog} className="gap-2">
<Mail className="h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={emailOpen} onOpenChange={setEmailOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-2 sm:grid-cols-2">
{REASON_TEMPLATES.map(template => (
<button
key={template.key}
type="button"
onClick={() => applyReasonTemplate(template.key)}
className={`rounded-md border p-3 text-left transition-colors hover:bg-muted/60 ${
reasonKey === template.key ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="font-medium">{template.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{template.description}</div>
</button>
))}
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={emailSubject}
onChange={(event) => { setEmailSubject(event.target.value); setReasonKey('custom'); }}
placeholder="填写邮件标题"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={emailBody}
onChange={(event) => { setEmailBody(event.target.value); setReasonKey('custom'); }}
className="min-h-56"
placeholder="填写邮件正文"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setEmailOpen(false); setEditOpen(true); }} disabled={saving}>
</Button>
<Button onClick={submitPromptUpdate} disabled={saving} className="gap-2">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
Database,
Eye,
Home,
Images,
Key,
LayoutDashboard,
ListChecks,
@@ -50,6 +51,7 @@ const AnnouncementTab = dynamic(() => import('@/components/admin/announcement-ta
const DataManagementTab = dynamic(() => import('@/components/admin/data-management-tab'), { ssr: false });
const SystemUpgradeTab = dynamic(() => import('@/components/admin/system-upgrade-tab'), { ssr: false });
const TaskManagementTab = dynamic(() => import('@/components/admin/task-management-tab'), { ssr: false });
const GalleryManagementTab = dynamic(() => import('@/components/admin/gallery-management-tab'), { ssr: false });
const LogManagementTab = dynamic(() => import('@/components/admin/log-management-tab'), { ssr: false });
const SettingsTab = dynamic(() => import('@/components/admin/settings-tab'), { ssr: false });
@@ -65,6 +67,7 @@ type ConsoleView =
| 'data'
| 'upgrade'
| 'tasks'
| 'gallery'
| 'logs'
| 'settings';
@@ -93,6 +96,7 @@ const CONSOLE_VIEWS: ConsoleView[] = [
'data',
'upgrade',
'tasks',
'gallery',
'logs',
'settings',
];
@@ -252,6 +256,7 @@ const VIEW_TITLES: Record<ConsoleView, { title: string; description: string }> =
data: { title: '数据管理', description: '导出、导入与恢复业务数据' },
upgrade: { title: '系统升级', description: '上传升级包,执行热更新、冷更新与失败自动回滚' },
tasks: { title: '任务管理', description: '查看生成任务状态并清理任务' },
gallery: { title: '画廊管理', description: '修改公开作品提示词并邮件通知作者' },
logs: { title: '系统日志', description: '查看平台运行、登录、安全和管理操作日志' },
settings: { title: '系统设置', description: '维护站点信息、邮箱与通知设置' },
};
@@ -368,6 +373,7 @@ export default function ConsoleDashboardPage() {
items: [
{ value: 'api', label: 'API 管理', icon: Key },
{ value: 'tasks', label: '任务管理', icon: ListChecks },
{ value: 'gallery', label: '画廊管理', icon: Images },
{ value: 'announcements', label: '公告管理', icon: Bell },
],
},
@@ -630,6 +636,8 @@ function ConsoleContent({
return <SystemUpgradeTab />;
case 'tasks':
return <TaskManagementTab />;
case 'gallery':
return <GalleryManagementTab />;
case 'logs':
return <LogManagementTab />;
case 'settings':