feat: 完善图像工作区与创作工具体验
This commit is contained in:
667
src/pages/ImagePromptMuseum/index.tsx
Normal file
667
src/pages/ImagePromptMuseum/index.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ImageIcon,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
fetchPromptMuseumEntry,
|
||||
fetchPromptMuseumPage,
|
||||
PromptMuseumApiError,
|
||||
} from '@/lib/image-prompt-museum';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
|
||||
import type {
|
||||
PromptMuseumCard,
|
||||
PromptMuseumEntry,
|
||||
PromptMuseumFacet,
|
||||
PromptMuseumFacets,
|
||||
} from '../../../shared/image-prompt-museum';
|
||||
|
||||
type MuseumFilterState = {
|
||||
useCase: string;
|
||||
style: string;
|
||||
subject: string;
|
||||
};
|
||||
|
||||
type PageStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type DetailStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
const EMPTY_FACETS: PromptMuseumFacets = {
|
||||
useCases: [],
|
||||
styles: [],
|
||||
subjects: [],
|
||||
};
|
||||
|
||||
const EMPTY_FILTERS: MuseumFilterState = {
|
||||
useCase: '',
|
||||
style: '',
|
||||
subject: '',
|
||||
};
|
||||
|
||||
function categoryLabel(group: string): string {
|
||||
if (group === 'use_case') return '场景';
|
||||
if (group === 'style') return '风格';
|
||||
if (group === 'subject') return '主体';
|
||||
return '分类';
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof PromptMuseumApiError && error.status === 401) {
|
||||
return '请先登录后再获取灵感';
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return '获取灵感暂时不可用,请稍后再试';
|
||||
}
|
||||
|
||||
function MuseumImage({
|
||||
image,
|
||||
className,
|
||||
sizes,
|
||||
}: {
|
||||
image: { url: string; alt: string };
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed || !image.url) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center bg-surface-subtle text-muted-foreground', className)}>
|
||||
<ImageIcon className="h-8 w-8" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FacetSelect({
|
||||
label,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
items: PromptMuseumFacet[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-[112px] flex-1 flex-col gap-1 text-[10px] font-semibold text-muted-foreground sm:min-w-[124px] sm:flex-none">
|
||||
<span>{label}</span>
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-8 rounded-lg border border-border/80 bg-surface-input px-2 text-xs font-medium text-foreground outline-none transition focus:border-brand/45 focus:ring-2 focus:ring-ring/20"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
{typeof item.count === 'number' ? ` (${item.count})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptMuseumCardView({
|
||||
item,
|
||||
onOpen,
|
||||
}: {
|
||||
item: PromptMuseumCard;
|
||||
onOpen: (item: PromptMuseumCard) => void;
|
||||
}) {
|
||||
return (
|
||||
<article className="group min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`image-prompt-card-${item.id}`}
|
||||
aria-label={`查看 ${item.title}`}
|
||||
onClick={() => onOpen(item)}
|
||||
className="motion-press flex h-full w-full flex-col overflow-hidden rounded-2xl border border-border/70 bg-card text-left shadow-soft transition-[box-shadow,transform,border-color] hover:-translate-y-0.5 hover:border-brand/35 hover:shadow-float focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-surface-subtle">
|
||||
<MuseumImage
|
||||
image={item.thumbnail}
|
||||
sizes="(min-width: 1280px) 280px, (min-width: 768px) 30vw, 90vw"
|
||||
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full border border-white/70 bg-white/85 px-2 py-1 text-[10px] font-semibold text-foreground shadow-soft backdrop-blur">
|
||||
<BookOpen className="h-3 w-3 text-brand" aria-hidden="true" />
|
||||
灵感样例
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-[172px] flex-1 flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="line-clamp-2 text-base font-semibold leading-6 tracking-[-0.015em] text-foreground">
|
||||
{item.title}
|
||||
</h2>
|
||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-brand/75" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||
{item.summary}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 pt-4">
|
||||
{item.categories.slice(0, 3).map((category) => (
|
||||
<Badge
|
||||
key={`${category.group}:${category.id}`}
|
||||
variant="outline"
|
||||
className="border-border/80 bg-surface-subtle px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{category.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-border/60 pt-3 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="min-w-0 truncate">作者:{item.attribution.author.name}</span>
|
||||
<span className="shrink-0">{item.model.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AttributionBlock({ entry }: { entry: PromptMuseumEntry }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/70 bg-surface-subtle p-4">
|
||||
<h3 className="text-xs font-semibold text-foreground">来源与署名</h3>
|
||||
<dl className="mt-3 grid gap-2 text-xs leading-5">
|
||||
<div className="flex gap-3">
|
||||
<dt className="w-12 shrink-0 text-muted-foreground">作者</dt>
|
||||
<dd className="min-w-0 font-medium text-foreground">
|
||||
{entry.attribution.author.url ? (
|
||||
<a
|
||||
href={entry.attribution.author.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
{entry.attribution.author.name}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : entry.attribution.author.name}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<dt className="w-12 shrink-0 text-muted-foreground">来源</dt>
|
||||
<dd className="min-w-0 truncate font-medium text-foreground">
|
||||
<a
|
||||
href={entry.attribution.source.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
<span className="truncate">{entry.attribution.source.name}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<dt className="w-12 shrink-0 text-muted-foreground">许可</dt>
|
||||
<dd className="min-w-0 font-medium text-foreground">
|
||||
{entry.attribution.license.url ? (
|
||||
<a
|
||||
href={entry.attribution.license.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{entry.attribution.license.name}
|
||||
</a>
|
||||
) : entry.attribution.license.name}
|
||||
<p className="mt-1 font-normal text-muted-foreground">
|
||||
{entry.attribution.license.attributionText}
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBody({
|
||||
entry,
|
||||
copied,
|
||||
onCopy,
|
||||
onUse,
|
||||
}: {
|
||||
entry: PromptMuseumEntry;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
onUse: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 pb-6">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(entry.images.length > 0 ? entry.images : [entry.thumbnail]).map((image, index) => (
|
||||
<div key={`${image.url}:${index}`} className="overflow-hidden rounded-xl border border-border/70 bg-surface-subtle">
|
||||
<MuseumImage image={image} className="aspect-[4/3] h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.categories.map((category) => (
|
||||
<Badge
|
||||
key={`${category.group}:${category.id}`}
|
||||
variant="outline"
|
||||
className="border-brand/20 bg-brand-soft text-[10px] text-foreground"
|
||||
>
|
||||
{categoryLabel(category.group)} · {category.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge variant="outline" className="border-border/80 text-[10px] text-muted-foreground">
|
||||
{entry.model.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-xs font-semibold text-foreground">Prompt 原文</h3>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">{entry.language}</span>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-[280px] overflow-y-auto whitespace-pre-wrap break-words rounded-2xl border border-border/70 bg-surface-subtle p-4 font-mono text-xs leading-5 text-foreground">
|
||||
{entry.prompt}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{entry.variables.length > 0 ? (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-foreground">可替换内容</h3>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{entry.variables.map((variable) => (
|
||||
<div key={variable.name} className="rounded-xl border border-border/70 px-3 py-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-foreground">{variable.label}</span>
|
||||
<code className="text-[10px] text-muted-foreground">{`{${variable.name}}`}</code>
|
||||
</div>
|
||||
{variable.defaultValue ? (
|
||||
<p className="mt-1 text-muted-foreground">默认:{variable.defaultValue}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{entry.requiresReferenceImages ? (
|
||||
<p className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||
这个案例需要参考图。带回画布后,可以在输入框里上传或选择参考图。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AttributionBlock entry={entry} />
|
||||
|
||||
<p className="text-[10px] leading-4 text-muted-foreground">
|
||||
发布于 {formatDate(entry.publishedAt)} · 更新于 {formatDate(entry.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2 border-t border-border/70 bg-background/95 px-6 py-4 backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1 rounded-xl"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? <Check className="mr-1.5 h-4 w-4 text-emerald-600" /> : <Copy className="mr-1.5 h-4 w-4" />}
|
||||
{copied ? '已复制' : '复制 Prompt'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 rounded-xl bg-brand font-semibold text-primary-foreground hover:bg-brand/90"
|
||||
onClick={onUse}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-4 w-4" />
|
||||
使用此 Prompt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImagePromptMuseum() {
|
||||
const navigate = useNavigate();
|
||||
const setPendingPrompt = useImagePromptMuseumStore((state) => state.setPendingPrompt);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [filters, setFilters] = useState<MuseumFilterState>(EMPTY_FILTERS);
|
||||
const [items, setItems] = useState<PromptMuseumCard[]>([]);
|
||||
const [facets, setFacets] = useState<PromptMuseumFacets>(EMPTY_FACETS);
|
||||
const [total, setTotal] = useState<number | undefined>();
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageStatus, setPageStatus] = useState<PageStatus>('idle');
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [loadMoreBusy, setLoadMoreBusy] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [selectedCard, setSelectedCard] = useState<PromptMuseumCard | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<PromptMuseumEntry | null>(null);
|
||||
const [detailStatus, setDetailStatus] = useState<DetailStatus>('idle');
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
setPageStatus('loading');
|
||||
setPageError(null);
|
||||
void fetchPromptMuseumPage({
|
||||
q: searchInput.trim() || undefined,
|
||||
useCase: filters.useCase || undefined,
|
||||
style: filters.style || undefined,
|
||||
subject: filters.subject || undefined,
|
||||
}).then((page) => {
|
||||
if (cancelled) return;
|
||||
setItems(page.items);
|
||||
setFacets(page.facets);
|
||||
setTotal(page.total);
|
||||
setNextCursor(page.nextCursor);
|
||||
setLoadMoreError(null);
|
||||
setPageStatus('ready');
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setPageStatus('error');
|
||||
setPageError(errorMessage(error));
|
||||
});
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [filters, reloadKey, searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCard) return;
|
||||
let cancelled = false;
|
||||
void fetchPromptMuseumEntry(selectedCard.id).then((entry) => {
|
||||
if (cancelled) return;
|
||||
setSelectedEntry(entry);
|
||||
setDetailStatus('ready');
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setDetailStatus('error');
|
||||
setDetailError(errorMessage(error));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCard]);
|
||||
|
||||
const selectedTitle = selectedEntry?.title ?? selectedCard?.title ?? '提示词详情';
|
||||
const selectedSummary = selectedEntry?.summary ?? selectedCard?.summary ?? '';
|
||||
const activeFilters = useMemo(
|
||||
() => Object.values(filters).filter(Boolean).length,
|
||||
[filters],
|
||||
);
|
||||
|
||||
const openEntry = (item: PromptMuseumCard) => {
|
||||
setSelectedEntry(null);
|
||||
setDetailStatus('loading');
|
||||
setDetailError(null);
|
||||
setCopied(false);
|
||||
setSelectedCard(item);
|
||||
};
|
||||
|
||||
const closeEntry = () => {
|
||||
setSelectedCard(null);
|
||||
setSelectedEntry(null);
|
||||
setDetailStatus('idle');
|
||||
};
|
||||
|
||||
const usePrompt = () => {
|
||||
if (!selectedEntry) return;
|
||||
setPendingPrompt({
|
||||
promptId: selectedEntry.id,
|
||||
title: selectedEntry.title,
|
||||
prompt: selectedEntry.prompt,
|
||||
});
|
||||
closeEntry();
|
||||
navigate('/image-canvas');
|
||||
};
|
||||
|
||||
const copyPrompt = async () => {
|
||||
if (!selectedEntry) return;
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
toast.error('当前环境不支持复制,请直接选中 Prompt 文本');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedEntry.prompt);
|
||||
setCopied(true);
|
||||
toast.success('Prompt 已复制');
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
toast.error('复制失败,请直接选中 Prompt 文本');
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadMoreBusy) return;
|
||||
setLoadMoreBusy(true);
|
||||
setLoadMoreError(null);
|
||||
try {
|
||||
const page = await fetchPromptMuseumPage({
|
||||
q: searchInput.trim() || undefined,
|
||||
useCase: filters.useCase || undefined,
|
||||
style: filters.style || undefined,
|
||||
subject: filters.subject || undefined,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
setItems((current) => [...current, ...page.items]);
|
||||
setFacets(page.facets);
|
||||
setTotal((current) => page.total ?? current);
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (error: unknown) {
|
||||
setLoadMoreError(errorMessage(error));
|
||||
} finally {
|
||||
setLoadMoreBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="image-prompt-museum-page" className="flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-7xl px-5 py-4 sm:px-8 sm:py-6">
|
||||
<section
|
||||
data-testid="image-prompt-museum-filters"
|
||||
className="sticky top-0 z-30 rounded-2xl border border-border/70 bg-card/95 p-3 shadow-soft backdrop-blur sm:p-3.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-[1_1_220px]">
|
||||
<span className="mb-1 block text-[10px] font-semibold text-muted-foreground">搜索灵感</span>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="搜索海报、角色、编辑感……"
|
||||
aria-label="搜索灵感"
|
||||
className="h-8 rounded-lg pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex min-w-0 flex-[2_1_360px] flex-wrap gap-2">
|
||||
<FacetSelect
|
||||
label="使用场景"
|
||||
value={filters.useCase}
|
||||
items={facets.useCases}
|
||||
onChange={(useCase) => setFilters((current) => ({ ...current, useCase }))}
|
||||
/>
|
||||
<FacetSelect
|
||||
label="风格"
|
||||
value={filters.style}
|
||||
items={facets.styles}
|
||||
onChange={(style) => setFilters((current) => ({ ...current, style }))}
|
||||
/>
|
||||
<FacetSelect
|
||||
label="主体"
|
||||
value={filters.subject}
|
||||
items={facets.subjects}
|
||||
onChange={(subject) => setFilters((current) => ({ ...current, subject }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-border/60 pt-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{pageStatus === 'loading' ? '正在整理灵感……' : typeof total === 'number' ? `${total} 个灵感样例` : `${items.length} 个灵感样例`}
|
||||
{activeFilters > 0 ? ` · 已启用 ${activeFilters} 个筛选` : ''}
|
||||
</span>
|
||||
{activeFilters > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-brand hover:underline"
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pageStatus === 'loading' && items.length === 0 ? (
|
||||
<div data-testid="image-prompt-museum-loading" className="flex min-h-72 items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||
正在加载灵感样例
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pageStatus === 'error' ? (
|
||||
<div data-testid="image-prompt-museum-error" className="mx-auto mt-8 max-w-lg rounded-2xl border border-border/70 bg-card p-8 text-center shadow-soft">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||||
<Lightbulb className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-semibold text-foreground">暂时没有接上灵感库</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{pageError}</p>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">请检查登录状态或稍后重试。灵感数据来自服务端,不会在客户端伪造样例。</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-5 rounded-xl"
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pageStatus === 'ready' && items.length === 0 ? (
|
||||
<div data-testid="image-prompt-museum-empty" className="mx-auto mt-8 max-w-lg rounded-2xl border border-dashed border-border bg-card p-8 text-center">
|
||||
<Search className="mx-auto h-7 w-7 text-muted-foreground" />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">还没有匹配的灵感</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">换个关键词或清除筛选,看看其他创作方向。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className={cn('mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4', pageStatus === 'loading' && 'opacity-60')}>
|
||||
{items.map((item) => (
|
||||
<PromptMuseumCardView key={item.id} item={item} onOpen={openEntry} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nextCursor && items.length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8">
|
||||
{loadMoreError ? <p className="text-xs text-destructive">{loadMoreError}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-32 rounded-xl"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={loadMoreBusy}
|
||||
>
|
||||
{loadMoreBusy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
{loadMoreBusy ? '正在加载' : '加载更多'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Sheet
|
||||
open={Boolean(selectedCard)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeEntry();
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" className="flex h-full w-full max-w-xl flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
||||
<SheetHeader className="shrink-0 border-b border-border/70 px-6 py-5 pr-12 text-left">
|
||||
<SheetTitle className="text-xl leading-7">{selectedTitle}</SheetTitle>
|
||||
<SheetDescription className="mt-1 text-xs leading-5">{selectedSummary}</SheetDescription>
|
||||
<SheetClose asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="关闭详情" className="absolute right-4 top-4 h-8 w-8 rounded-lg">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
|
||||
{detailStatus === 'loading' && selectedCard ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||
正在打开 Prompt 详情
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailStatus === 'error' ? (
|
||||
<div data-testid="image-prompt-detail-error" className="flex flex-1 flex-col items-center justify-center px-8 text-center">
|
||||
<p className="text-sm font-semibold text-foreground">详情暂时无法打开</p>
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{detailError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailStatus === 'ready' && selectedEntry ? (
|
||||
<DetailBody entry={selectedEntry} copied={copied} onCopy={() => void copyPrompt()} onUse={usePrompt} />
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user