auto-save 2026-05-18 23:28 (+1, ~6)

This commit is contained in:
2026-05-18 23:28:34 +08:00
parent 446e012450
commit 52a5b77681
7 changed files with 309 additions and 119 deletions

View File

@@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh">
<body className="min-h-screen">{children}</body>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}

View File

@@ -11,6 +11,7 @@ export default function Home() {
const [current, setCurrent] = useState<GenSession | null>(null);
const [loading, setLoading] = useState(false);
const [provider, setProvider] = useState<string>('?');
const [sidebarOpen, setSidebarOpen] = useState(true);
const refreshSessions = useCallback(async () => {
const r = await fetch('/api/sessions');
@@ -60,36 +61,51 @@ export default function Home() {
}
return (
<div className="flex h-screen">
<div className="flex h-screen bg-[#FAFAFA]">
<Sidebar
open={sidebarOpen}
onToggle={() => setSidebarOpen(v => !v)}
sessions={sessions}
currentId={current?.id ?? null}
onPick={id => setCurrent(sessions.find(s => s.id === id) ?? null)}
onNew={() => setCurrent(null)}
/>
<main className="flex-1 overflow-y-auto">
<header className="px-8 py-6 border-b border-white/10 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">AI </h1>
<p className="text-xs text-white/40 mt-1"> </p>
</div>
<div className="text-xs text-white/40">
provider: <span className={provider === 'poe' ? 'text-accent' : 'text-yellow-400'}>{provider}</span>
{provider === 'mock' && <span className="ml-2 text-yellow-400/80">( POE_API_KEY)</span>}
</div>
</header>
<div className="mx-auto max-w-[1180px] px-10 py-10">
<header className="flex items-start justify-between mb-10">
<div>
<h1 className="text-[26px] font-semibold tracking-tight text-zinc-900 leading-tight">
AI
</h1>
<p className="text-sm text-zinc-500 mt-1.5">
</p>
</div>
<div className="flex items-center gap-2 pt-1">
<span className={provider === 'poe' ? 'chip chip-live' : 'chip chip-mock'}>
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'poe' ? 'bg-emerald-500' : 'bg-amber-500'}`} />
{provider === 'poe' ? 'Poe · 实时生图' : provider === 'mock' ? 'Mock · 占位图' : provider}
</span>
</div>
</header>
<div className="p-8 space-y-6 max-w-6xl">
<PromptPanel onGenerate={handleGenerate} loading={loading} />
{current && (
<section className="card p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm text-white/60"> · {new Date(current.createdAt).toLocaleString('zh-CN')}</h2>
<code className="text-xs text-white/30">{current.id}</code>
</div>
<ResultGrid images={current.images} onAction={handleAction} />
</section>
)}
<div className="space-y-8">
<PromptPanel onGenerate={handleGenerate} loading={loading} />
{current && (
<section className="space-y-4">
<div className="flex items-end justify-between">
<div>
<h2 className="text-sm font-medium text-zinc-900"></h2>
<p className="text-xs text-zinc-500 mt-0.5">
{new Date(current.createdAt).toLocaleString('zh-CN')}
</p>
</div>
<code className="text-[11px] text-zinc-400 font-mono">{current.id}</code>
</div>
<ResultGrid images={current.images} onAction={handleAction} />
</section>
)}
</div>
</div>
</main>
</div>

View File

@@ -38,35 +38,42 @@ export default function PromptPanel({ onGenerate, loading }: PromptPanelProps) {
}
return (
<div className="card p-6 space-y-4">
<div className="card p-7 space-y-6">
<div>
<label className="block text-sm text-white/60 mb-2">Prompt</label>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2.5">
Prompt
</label>
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
rows={4}
placeholder="描述要生成的玩具意向…(⌘/Ctrl+Enter 提交)"
className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm resize-none focus:border-accent outline-none"
rows={3}
placeholder="描述要生成的玩具意向…"
className="field text-[15px] leading-relaxed"
/>
<p className="mt-2 text-[11px] text-zinc-400 flex items-center gap-1.5">
<kbd className="kbd"></kbd><kbd className="kbd"></kbd>
</p>
</div>
<div>
<label className="block text-sm text-white/60 mb-2"> 4 </label>
<div className="flex flex-wrap gap-2">
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2.5">
<span className="text-zinc-400 normal-case tracking-normal">· 4 </span>
</label>
<div className="flex flex-wrap gap-2.5">
{refs.map((r, i) => (
<div key={i} className="relative w-20 h-20 rounded-lg overflow-hidden border border-white/10">
<div key={i} className="relative w-20 h-20 rounded-xl overflow-hidden ring-1 ring-zinc-200 group">
<img src={r} alt="ref" className="w-full h-full object-cover" />
<button
onClick={() => setRefs(prev => prev.filter((_, j) => j !== i))}
className="absolute top-0.5 right-0.5 w-5 h-5 rounded bg-black/70 text-xs"
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-zinc-900 text-white text-[10px] opacity-0 group-hover:opacity-100 transition-opacity shadow-md"
></button>
</div>
))}
{refs.length < 4 && (
<button
onClick={() => fileInput.current?.click()}
className="w-20 h-20 rounded-lg border border-dashed border-white/20 hover:border-accent text-white/40 hover:text-accent text-2xl"
className="w-20 h-20 rounded-xl border-2 border-dashed border-zinc-200 hover:border-zinc-400 hover:bg-zinc-50 text-zinc-400 hover:text-zinc-600 text-2xl transition-colors flex items-center justify-center"
>+</button>
)}
<input
@@ -81,41 +88,59 @@ export default function PromptPanel({ onGenerate, loading }: PromptPanelProps) {
</div>
<div>
<label className="block text-sm text-white/60 mb-2"></label>
<div className="flex flex-wrap gap-2">
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2.5">
</label>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setStyle('')}
className={`btn text-xs ${style === '' ? 'btn-primary' : 'btn-ghost'}`}
className={style === '' ? 'btn btn-primary text-xs px-3 py-1.5' : 'btn btn-outline text-xs px-3 py-1.5'}
></button>
{PRESET_STYLES.map(s => (
<button
key={s.id}
onClick={() => setStyle(s.label)}
className={`btn text-xs ${style === s.label ? 'btn-primary' : 'btn-ghost'}`}
className={style === s.label ? 'btn btn-primary text-xs px-3 py-1.5' : 'btn btn-outline text-xs px-3 py-1.5'}
>{s.label}</button>
))}
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div className="flex items-end justify-between gap-4 pt-2">
<div>
<label className="block text-sm text-white/60 mb-2"></label>
<div className="flex gap-2">
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wider mb-2.5">
</label>
<div className="seg">
{[4, 8, 12].map(n => (
<button
key={n}
onClick={() => setCount(n)}
className={`btn text-xs ${count === n ? 'btn-primary' : 'btn-ghost'}`}
>{n}</button>
className={`seg-item ${count === n ? 'seg-item-active' : ''}`}
>{n} </button>
))}
</div>
</div>
<button
onClick={submit}
disabled={loading || !prompt.trim()}
className="btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
className="btn btn-primary px-5 py-2.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? '生成中…' : '🪄 批量生成'}
{loading ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
</svg>
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</>
)}
</button>
</div>
</div>

View File

@@ -9,9 +9,10 @@ export type ResultGridProps = {
};
export default function ResultGrid({ images, onAction }: ResultGridProps) {
// 键盘1-9 切换选中shift+1-9 打叉
useEffect(() => {
function handler(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
const n = parseInt(e.key, 10);
if (isNaN(n) || n < 1 || n > 9 || n > images.length) return;
const img = images[n - 1];
@@ -26,34 +27,55 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) {
}, [images, onAction]);
const cols = images.length <= 4 ? 'grid-cols-2' : images.length <= 9 ? 'grid-cols-3' : 'grid-cols-4';
const selectedCount = images.filter(i => i.status === 'selected').length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-white/40">
<span>
1-{Math.min(9, images.length)} / Shift+
</span>
<span>
<span className="text-accent font-bold">{images.filter(i => i.status === 'selected').length}</span> / {images.length}
</span>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<kbd className="kbd">1</kbd>
<span></span>
<kbd className="kbd">{Math.min(9, images.length)}</kbd>
<span></span>
<span className="text-zinc-300 mx-1">/</span>
<kbd className="kbd"></kbd>
<span>+</span>
<kbd className="kbd">1</kbd>
<span></span>
</div>
<div className="text-xs text-zinc-600">
<span className="font-semibold text-zinc-900">{selectedCount}</span>
<span className="text-zinc-400"> / {images.length}</span>
</div>
</div>
<div className={`grid ${cols} gap-3`}>
{images.map((img, i) => (
<div
key={img.id}
className={`tile ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
>
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-cover" />
<div className="tile-keynum">{i + 1}</div>
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2 opacity-0 hover:opacity-100 transition-opacity bg-gradient-to-t from-black/80 to-transparent">
{img.status === 'selected' && (
<div className="tile-badge tile-badge-selected"></div>
)}
{img.status === 'rejected' && (
<div className="tile-badge tile-badge-rejected"></div>
)}
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<button
onClick={() => onAction(img.id, img.status === 'selected' ? 'reset' : 'select')}
className="btn btn-primary text-xs flex-1"
>{img.status === 'selected' ? '✓ 已选' : '✓ 选中'}</button>
className="flex-1 px-3 py-1.5 rounded-lg bg-white text-zinc-900 text-xs font-medium hover:bg-zinc-100 transition-colors shadow-sm"
>
{img.status === 'selected' ? '✓ 已选' : '选中'}
</button>
<button
onClick={() => onAction(img.id, img.status === 'rejected' ? 'reset' : 'reject')}
className="btn btn-ghost text-xs"
></button>
className="px-3 py-1.5 rounded-lg bg-white/90 text-zinc-700 text-xs font-medium hover:bg-white hover:text-red-600 transition-colors shadow-sm"
></button>
</div>
</div>
))}

View File

@@ -3,39 +3,96 @@
import type { GenSession } from '@/lib/types';
export default function Sidebar({
open,
onToggle,
sessions,
currentId,
onPick,
onNew,
}: {
open: boolean;
onToggle: () => void;
sessions: GenSession[];
currentId: string | null;
onPick: (id: string) => void;
onNew: () => void;
}) {
if (!open) {
return (
<aside className="w-12 shrink-0 border-r border-zinc-200 bg-white flex flex-col items-center py-4">
<button
onClick={onToggle}
className="w-8 h-8 rounded-lg hover:bg-zinc-100 flex items-center justify-center text-zinc-500"
title="展开侧栏"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</aside>
);
}
return (
<aside className="w-64 shrink-0 border-r border-white/10 bg-black/40 flex flex-col">
<div className="p-4 border-b border-white/10">
<button onClick={onNew} className="btn btn-primary w-full text-sm">+ </button>
<aside className="w-72 shrink-0 border-r border-zinc-200 bg-white flex flex-col">
<div className="p-4 flex items-center gap-2">
<button
onClick={onNew}
className="btn btn-primary flex-1 text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button
onClick={onToggle}
className="w-9 h-9 rounded-xl hover:bg-zinc-100 flex items-center justify-center text-zinc-500 transition-colors"
title="收起侧栏"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
<div className="p-3 text-xs text-white/40 uppercase tracking-wider"></div>
<div className="flex-1 overflow-y-auto px-2 pb-4 space-y-1">
{sessions.length === 0 && <div className="px-3 py-4 text-xs text-white/30"></div>}
<div className="px-5 pt-1 pb-2 text-[11px] font-medium text-zinc-400 uppercase tracking-wider">
</div>
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-1">
{sessions.length === 0 && (
<div className="px-3 py-8 text-center text-xs text-zinc-400">
</div>
)}
{sessions.map(s => {
const selectedCount = s.images.filter(i => i.status === 'selected').length;
const active = currentId === s.id;
return (
<button
key={s.id}
onClick={() => onPick(s.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors ${
currentId === s.id ? 'bg-white/10' : 'hover:bg-white/5'
className={`w-full text-left px-3 py-2.5 rounded-xl text-xs transition-all ${
active
? 'bg-zinc-900 text-white shadow-sm'
: 'hover:bg-zinc-100 text-zinc-700'
}`}
>
<div className="line-clamp-2 text-white/80">{s.prompt}</div>
<div className="mt-1 text-white/40 flex justify-between">
<span>{new Date(s.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
<div className={`line-clamp-2 font-medium ${active ? 'text-white' : 'text-zinc-800'}`}>
{s.prompt}
</div>
<div className={`mt-1.5 flex justify-between items-center text-[11px] ${active ? 'text-white/60' : 'text-zinc-500'}`}>
<span>
{s.images.length} · <span className="text-accent">{selectedCount}</span>
{new Date(s.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
<span className="flex items-center gap-1">
<span>{s.images.length}</span>
{selectedCount > 0 && (
<span className={active ? 'text-white' : 'text-zinc-900 font-semibold'}>
· {selectedCount}
</span>
)}
</span>
</div>
</button>