feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View 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>
);
}