149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
'use client';
|
||
|
||
import { useRef, useState } from 'react';
|
||
|
||
const PRESET_STYLES = [
|
||
{ id: 'plush', label: '毛绒玩偶' },
|
||
{ id: 'mecha', label: '机甲风' },
|
||
{ id: 'kawaii', label: '可爱萌系' },
|
||
{ id: 'blueprint', label: '专利蓝图' },
|
||
{ id: 'cyber', label: '赛博朋克' },
|
||
{ id: 'minimal', label: '极简' },
|
||
];
|
||
|
||
export type PromptPanelProps = {
|
||
onGenerate: (opts: { prompt: string; refImages: string[]; count: number; style?: string }) => void;
|
||
loading: boolean;
|
||
};
|
||
|
||
export default function PromptPanel({ onGenerate, loading }: PromptPanelProps) {
|
||
const [prompt, setPrompt] = useState('AI 毛绒陪伴玩具,机甲头盔,胸前挂 M logo,橙白配色,圆胖体型');
|
||
const [refs, setRefs] = useState<string[]>([]);
|
||
const [count, setCount] = useState(8);
|
||
const [style, setStyle] = useState<string>('');
|
||
const fileInput = useRef<HTMLInputElement>(null);
|
||
|
||
function handleFiles(files: FileList | null) {
|
||
if (!files) return;
|
||
Array.from(files).slice(0, 4 - refs.length).forEach(f => {
|
||
const r = new FileReader();
|
||
r.onload = () => setRefs(prev => [...prev, r.result as string]);
|
||
r.readAsDataURL(f);
|
||
});
|
||
}
|
||
|
||
function submit() {
|
||
if (!prompt.trim() || loading) return;
|
||
onGenerate({ prompt: prompt.trim(), refImages: refs, count, style: style || undefined });
|
||
}
|
||
|
||
return (
|
||
<div className="card p-7 space-y-6">
|
||
<div>
|
||
<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={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-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-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-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-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
|
||
ref={fileInput}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
hidden
|
||
onChange={e => handleFiles(e.target.files)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<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={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={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 pt-2">
|
||
<div>
|
||
<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={`seg-item ${count === n ? 'seg-item-active' : ''}`}
|
||
>{n} 张</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={submit}
|
||
disabled={loading || !prompt.trim()}
|
||
className="btn btn-primary px-5 py-2.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{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>
|
||
);
|
||
}
|