feat(ui): 替换原生select为shadcn/ui组件并支持图片消息发送

- 将 AccountBasicTab 和 ConfigForm 中的原生 select 替换为 shadcn/ui Select 组件
- ChatInput 支持拖放上传图片附件
- 添加 attachment button 并整合图片附件到消息发送
- 优化 Select 组件样式,增加过渡动画和悬停效果
- 调整 ConfigForm tab 切换样式为按钮风格
- AssetGallery 使用 useAccounts hook 获取账户列表
- 重新构建前端静态资源
This commit is contained in:
2026-05-08 02:29:07 +08:00
parent 2186b04675
commit a92379e20e
10 changed files with 583 additions and 417 deletions

File diff suppressed because one or more lines are too long

370
web/client/dist/assets/index-Cgtxrl8D.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美图 Agent</title> <title>美图 Agent</title>
<script type="module" crossorigin src="/assets/index-DSKEPThI.js"></script> <script type="module" crossorigin src="/assets/index-Cgtxrl8D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXUyFc07.css"> <link rel="stylesheet" crossorigin href="/assets/index-B_JK8LRt.css">
</head> </head>
<body class="bg-white text-zinc-900 antialiased"> <body class="bg-white text-zinc-900 antialiased">
<div id="root"></div> <div id="root"></div>

View File

@@ -50,44 +50,60 @@ export function AccountBasicTab({ account, onSave }: Props) {
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <div>
<label className="text-xs font-medium text-zinc-500"></label> <label className="text-xs font-medium text-zinc-500"></label>
<select value={form.defaultFormat} onChange={(e) => handleChange('defaultFormat', e.target.value)} <Select value={form.defaultFormat} onValueChange={(v) => handleChange('defaultFormat', v)}>
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"> <SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<option value="9:16">9:16 </option> <SelectValue />
<option value="16:9">16:9 </option> </SelectTrigger>
<option value="1:1">1:1 </option> <SelectContent>
<option value="4:3">4:3</option> <SelectItem value="9:16">9:16 </SelectItem>
</select> <SelectItem value="16:9">16:9 </SelectItem>
<SelectItem value="1:1">1:1 </SelectItem>
<SelectItem value="4:3">4:3</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-zinc-500"></label> <label className="text-xs font-medium text-zinc-500"></label>
<select value={form.imageModel} onChange={(e) => handleChange('imageModel', e.target.value)} <Select value={form.imageModel} onValueChange={(v) => handleChange('imageModel', v)}>
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"> <SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<option value="gemini">Gemini</option> <SelectValue />
<option value="mj">Midjourney</option> </SelectTrigger>
<option value="gpt">GPT Image</option> <SelectContent>
<option value="kling">Kling</option> <SelectItem value="gemini">Gemini</SelectItem>
</select> <SelectItem value="mj">Midjourney</SelectItem>
<SelectItem value="gpt">GPT Image</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-zinc-500"></label> <label className="text-xs font-medium text-zinc-500"></label>
<select value={form.videoModel} onChange={(e) => handleChange('videoModel', e.target.value)} <Select value={form.videoModel} onValueChange={(v) => handleChange('videoModel', v)}>
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"> <SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<option value="veo3-fast">Veo3 Fast</option> <SelectValue />
<option value="veo3-fast-frames">Veo3 Frames</option> </SelectTrigger>
<option value="kling">Kling</option> <SelectContent>
<option value="grok">Grok</option> <SelectItem value="veo3-fast">Veo3 Fast</SelectItem>
</select> <SelectItem value="veo3-fast-frames">Veo3 Frames</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
<SelectItem value="grok">Grok</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs font-medium text-zinc-500">Pipeline</label> <label className="text-xs font-medium text-zinc-500">Pipeline</label>
<select value={form.pipeline} onChange={(e) => handleChange('pipeline', e.target.value)} <Select value={form.pipeline} onValueChange={(v) => handleChange('pipeline', v)}>
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm"> <SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<option value="image-video">+</option> <SelectValue />
<option value="single"></option> </SelectTrigger>
</select> <SelectContent>
<SelectItem value="image-video">+</SelectItem>
<SelectItem value="single"></SelectItem>
</SelectContent>
</Select>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-zinc-500"></label> <label className="text-xs font-medium text-zinc-500"></label>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react'; import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets'; import { useAssets } from '@/hooks/useAssets';
import { useAccounts } from '@/hooks/useAccounts';
import { AssetProjectGroup } from './AssetProjectGroup'; import { AssetProjectGroup } from './AssetProjectGroup';
import { AssetPreview } from './AssetPreview'; import { AssetPreview } from './AssetPreview';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -36,15 +37,14 @@ function AssetGroupSkeleton() {
export function AssetGallery() { export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState(''); const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all'); const [typeTab, setTypeTab] = useState<TypeTab>('all');
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
const [manifests, setManifests] = useState<ManifestGroup[]>([]); const [manifests, setManifests] = useState<ManifestGroup[]>([]);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null); const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const { accounts } = useAccounts();
const { assets, loading, remove } = useAssets({ const { assets, loading, remove } = useAssets({
accountId: accountFilter || undefined, accountId: accountFilter || undefined,
}); });
useEffect(() => { useEffect(() => {
fetch('/api/accounts').then((r) => r.json()).then(setAccounts).catch(() => {});
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {}); fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
}, []); }, []);

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { Terminal, Image, Play, FileText, ArrowUp } from 'lucide-react'; import { Terminal, Image, Play, FileText, ArrowUp, X, Paperclip } from 'lucide-react';
const SLASH_COMMANDS = [ const SLASH_COMMANDS = [
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play }, { cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
@@ -9,32 +9,78 @@ const SLASH_COMMANDS = [
{ cmd: '/help', desc: '显示帮助', icon: Terminal }, { cmd: '/help', desc: '显示帮助', icon: Terminal },
]; ];
export function ChatInput({ onSend, disabled }: { onSend: (content: string) => void; disabled?: boolean }) { export interface ImageAttachment {
data: string;
mimeType: string;
preview: string;
}
export function ChatInput({ onSend, disabled }: { onSend: (content: string, images?: Array<{ data: string; mimeType: string }>) => void; disabled?: boolean }) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [showCmds, setShowCmds] = useState(false); const [showCmds, setShowCmds] = useState(false);
const [cmdIdx, setCmdIdx] = useState(0); const [cmdIdx, setCmdIdx] = useState(0);
const ref = useRef<HTMLTextAreaElement>(null); const [images, setImages] = useState<ImageAttachment[]>([]);
const [dragging, setDragging] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const textRef = useRef<HTMLTextAreaElement>(null);
const matchingCmds = input.startsWith('/') const matchingCmds = input.startsWith('/')
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0])) ? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
: []; : [];
// Auto-show/hide slash command menu const readFiles = (files: File[]) => {
useEffect(() => { if (files.length === 0) return;
if (input.startsWith('/') && matchingCmds.length > 0) { let loaded = 0;
setShowCmds(true); const results: ImageAttachment[] = [];
setCmdIdx(0); files.forEach((file, i) => {
} else { const reader = new FileReader();
setShowCmds(false); reader.onload = () => {
} const base64 = (reader.result as string).split(',')[1];
}, [input, matchingCmds.length]); results[i] = { data: base64, mimeType: file.type, preview: reader.result as string };
loaded++;
if (loaded === files.length) {
setImages((prev) => [...prev, ...results]);
}
};
reader.readAsDataURL(file);
});
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) readFiles(Array.from(e.target.files));
e.target.value = '';
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!dragging) setDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) readFiles(imageFiles);
};
const removeImage = (idx: number) => {
setImages((prev) => prev.filter((_, i) => i !== idx));
};
const handleSend = () => { const handleSend = () => {
if (!input.trim() || disabled) return; const text = input.trim();
onSend(input.trim()); if ((!text && images.length === 0) || disabled) return;
const imgs = images.length > 0 ? images.map(({ data, mimeType }) => ({ data, mimeType })) : undefined;
onSend(text || '(图片)', imgs);
setInput(''); setInput('');
setImages([]);
setShowCmds(false); setShowCmds(false);
ref.current?.focus(); textRef.current?.focus();
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -50,25 +96,25 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
} }
}; };
const canSend = input.trim().length > 0 && !disabled; const canSend = (input.trim().length > 0 || images.length > 0) && !disabled;
return ( return (
<div className="relative"> <div className="relative">
{/* Slash command menu */} {/* Slash command menu */}
{showCmds && matchingCmds.length > 0 && ( {showCmds && matchingCmds.length > 0 && (
<div className="mx-6 mb-0.5 bg-white border border-zinc-200 rounded-xl shadow-lg overflow-hidden animate-in slide-in-from-bottom-2"> <div className="mx-6 mb-1 bg-white border border-zinc-200 rounded-2xl shadow-xl shadow-zinc-200/50 overflow-hidden animate-in slide-in-from-bottom-2">
{matchingCmds.map((c, i) => { {matchingCmds.map((c, i) => {
const Icon = c.icon; const Icon = c.icon;
return ( return (
<button <button
key={c.cmd} key={c.cmd}
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); ref.current?.focus(); }} onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); textRef.current?.focus(); }}
className={`w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
i === (cmdIdx % matchingCmds.length) ? 'bg-indigo-50 text-indigo-700' : 'text-zinc-600 hover:bg-zinc-50' i === (cmdIdx % matchingCmds.length) ? 'bg-zinc-50 text-zinc-900' : 'text-zinc-500 hover:bg-zinc-50'
}`} }`}
> >
<Icon size={15} className="text-zinc-400" /> <Icon size={15} className="text-zinc-400" />
<span className="font-mono font-semibold text-xs">{c.cmd}</span> <span className="font-mono font-medium text-xs">{c.cmd}</span>
<span className="text-zinc-400 text-xs">{c.desc}</span> <span className="text-zinc-400 text-xs">{c.desc}</span>
</button> </button>
); );
@@ -76,44 +122,96 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
</div> </div>
)} )}
{/* Input bar */} {/* Input area */}
<div className="px-4 pb-4 pt-2 bg-gradient-to-t from-white via-white to-transparent"> <div
className="px-5 pb-5 pt-3"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className={` <div className={`
flex items-end gap-2 bg-zinc-50 rounded-2xl border transition-all duration-200 px-4 py-3 bg-white rounded-3xl border transition-all duration-300 relative
${disabled ? 'border-zinc-200 opacity-60' : 'border-zinc-200 focus-within:border-indigo-300 focus-within:shadow-md focus-within:shadow-indigo-50 focus-within:bg-white'} ${dragging
? 'border-zinc-400 shadow-xl shadow-zinc-200/60'
: disabled
? 'border-zinc-100 opacity-50'
: 'border-zinc-200/80 shadow-lg shadow-zinc-100/50 focus-within:border-zinc-300 focus-within:shadow-xl focus-within:shadow-zinc-200/40'
}
`}> `}>
<textarea {/* Drop overlay */}
ref={ref} {dragging && (
value={input} <div className="absolute inset-0 rounded-3xl bg-zinc-50/80 backdrop-blur-sm z-10 flex items-center justify-center pointer-events-none">
onChange={(e) => setInput(e.target.value)} <span className="text-sm text-zinc-400 font-medium"></span>
onKeyDown={handleKeyDown} </div>
rows={1} )}
placeholder={disabled ? '等待回复中...' : '输入消息,或按 / 查看快捷命令...'} {/* Image preview strip */}
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700 min-h-[24px] max-h-[120px]" {images.length > 0 && (
disabled={disabled} <div className="flex flex-wrap gap-2.5 px-5 pt-4">
style={{ height: 'auto' }} {images.map((img, i) => (
onInput={(e) => { <div key={i} className="relative group/att">
const el = e.currentTarget; <img src={img.preview} alt="" className="w-20 h-20 object-cover rounded-2xl shadow-sm" />
el.style.height = 'auto'; <button
el.style.height = Math.min(el.scrollHeight, 120) + 'px'; onClick={() => removeImage(i)}
}} className="absolute -top-2 -right-2 w-5 h-5 bg-zinc-900 text-white rounded-full flex items-center justify-center opacity-0 group-hover/att:opacity-100 transition-all duration-200 scale-75 group-hover/att:scale-100"
/> >
<button <X size={10} strokeWidth={3} />
onClick={handleSend} </button>
disabled={!canSend} </div>
className={` ))}
w-8 h-8 rounded-xl flex items-center justify-center transition-all flex-shrink-0 </div>
${canSend )}
? 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm'
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'} {/* Input row */}
`} <div className="flex items-end gap-1 px-3 py-3">
> {/* Attachment button */}
<ArrowUp size={16} strokeWidth={2.5} /> <button
</button> onClick={() => fileRef.current?.click()}
disabled={disabled}
className="flex-shrink-0 w-10 h-10 rounded-2xl flex items-center justify-center text-zinc-300 hover:text-zinc-500 hover:bg-zinc-50 active:scale-95 transition-all duration-200 disabled:opacity-40"
>
<Paperclip size={20} strokeWidth={1.8} />
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<textarea
ref={textRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入消息...'}
className="flex-1 bg-transparent text-[15px] resize-none outline-none placeholder:text-zinc-300 text-zinc-800 min-h-[40px] max-h-[140px] py-2 leading-relaxed"
disabled={disabled}
style={{ height: 'auto' }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}}
/>
{/* Send button */}
<button
onClick={handleSend}
disabled={!canSend}
className={`
flex-shrink-0 w-10 h-10 rounded-2xl flex items-center justify-center transition-all duration-200
${canSend
? 'bg-zinc-900 text-white hover:bg-zinc-800 active:scale-90'
: 'bg-zinc-100 text-zinc-300'}
`}
>
<ArrowUp size={18} strokeWidth={2.5} />
</button>
</div>
</div> </div>
<p className="text-[10px] text-zinc-400 text-center mt-1.5">
Agent
</p>
</div> </div>
</div> </div>
); );

View File

@@ -169,18 +169,18 @@ export function ConfigForm() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex gap-0 border-b border-zinc-200 px-6 bg-white"> <div className="flex gap-1 px-6 pt-4 bg-white">
<button <button
onClick={() => setTab('agent')} onClick={() => setTab('agent')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150
${tab === 'agent' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`} ${tab === 'agent' ? 'bg-zinc-900 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100'}`}
> >
Agent Agent
</button> </button>
<button <button
onClick={() => setTab('skills')} onClick={() => setTab('skills')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150
${tab === 'skills' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`} ${tab === 'skills' ? 'bg-zinc-900 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100'}`}
> >
</button> </button>

View File

@@ -14,14 +14,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'flex h-9 w-full items-center justify-between rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition-colors placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-300 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', 'flex h-9 w-full items-center justify-between rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition-colors hover:border-zinc-300 focus:outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-300 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className className
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown size={14} className="text-zinc-400" /> <ChevronDown size={14} className="text-zinc-400 transition-transform duration-200" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)); ));
@@ -35,7 +35,9 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white text-zinc-700 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative z-50 max-h-72 min-w-[8rem] overflow-hidden rounded-lg border border-zinc-200 bg-white text-zinc-700 shadow-lg',
'data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=closed]:scale-95',
'transition-[opacity,transform] duration-150',
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className className
)} )}
@@ -62,7 +64,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-indigo-50 focus:text-indigo-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-indigo-50 focus:text-indigo-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}