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 name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美图 Agent</title>
<script type="module" crossorigin src="/assets/index-DSKEPThI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXUyFc07.css">
<script type="module" crossorigin src="/assets/index-Cgtxrl8D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_JK8LRt.css">
</head>
<body class="bg-white text-zinc-900 antialiased">
<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>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={form.defaultFormat} onChange={(e) => handleChange('defaultFormat', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value="9:16">9:16 </option>
<option value="16:9">16:9 </option>
<option value="1:1">1:1 </option>
<option value="4:3">4:3</option>
</select>
<Select value={form.defaultFormat} onValueChange={(v) => handleChange('defaultFormat', v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="9:16">9:16 </SelectItem>
<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>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={form.imageModel} onChange={(e) => handleChange('imageModel', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value="gemini">Gemini</option>
<option value="mj">Midjourney</option>
<option value="gpt">GPT Image</option>
<option value="kling">Kling</option>
</select>
<Select value={form.imageModel} onValueChange={(v) => handleChange('imageModel', v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="mj">Midjourney</SelectItem>
<SelectItem value="gpt">GPT Image</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={form.videoModel} onChange={(e) => handleChange('videoModel', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value="veo3-fast">Veo3 Fast</option>
<option value="veo3-fast-frames">Veo3 Frames</option>
<option value="kling">Kling</option>
<option value="grok">Grok</option>
</select>
<Select value={form.videoModel} onValueChange={(v) => handleChange('videoModel', v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="veo3-fast">Veo3 Fast</SelectItem>
<SelectItem value="veo3-fast-frames">Veo3 Frames</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
<SelectItem value="grok">Grok</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-zinc-500">Pipeline</label>
<select value={form.pipeline} onChange={(e) => handleChange('pipeline', e.target.value)}
className="mt-1 w-full h-10 rounded-md border border-zinc-200 bg-zinc-50 px-3 text-sm">
<option value="image-video">+</option>
<option value="single"></option>
</select>
<Select value={form.pipeline} onValueChange={(v) => handleChange('pipeline', v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image-video">+</SelectItem>
<SelectItem value="single"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets';
import { useAccounts } from '@/hooks/useAccounts';
import { AssetProjectGroup } from './AssetProjectGroup';
import { AssetPreview } from './AssetPreview';
import { Button } from '@/components/ui/button';
@@ -36,15 +37,14 @@ function AssetGroupSkeleton() {
export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all');
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
const { accounts } = useAccounts();
const { assets, loading, remove } = useAssets({
accountId: accountFilter || undefined,
});
useEffect(() => {
fetch('/api/accounts').then((r) => r.json()).then(setAccounts).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 { Terminal, Image, Play, FileText, ArrowUp } from 'lucide-react';
import { useState, useRef } from 'react';
import { Terminal, Image, Play, FileText, ArrowUp, X, Paperclip } from 'lucide-react';
const SLASH_COMMANDS = [
{ cmd: '/run', desc: '执行 pipeline 阶段', icon: Play },
@@ -9,32 +9,78 @@ const SLASH_COMMANDS = [
{ 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 [showCmds, setShowCmds] = useState(false);
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('/')
? SLASH_COMMANDS.filter((c) => c.cmd.startsWith(input.split(' ')[0]))
: [];
// Auto-show/hide slash command menu
useEffect(() => {
if (input.startsWith('/') && matchingCmds.length > 0) {
setShowCmds(true);
setCmdIdx(0);
} else {
setShowCmds(false);
}
}, [input, matchingCmds.length]);
const readFiles = (files: File[]) => {
if (files.length === 0) return;
let loaded = 0;
const results: ImageAttachment[] = [];
files.forEach((file, i) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
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 = () => {
if (!input.trim() || disabled) return;
onSend(input.trim());
const text = 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('');
setImages([]);
setShowCmds(false);
ref.current?.focus();
textRef.current?.focus();
};
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 (
<div className="relative">
{/* Slash command menu */}
{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) => {
const Icon = c.icon;
return (
<button
key={c.cmd}
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); ref.current?.focus(); }}
className={`w-full flex items-center gap-2.5 px-3.5 py-2 text-sm transition-colors ${
i === (cmdIdx % matchingCmds.length) ? 'bg-indigo-50 text-indigo-700' : 'text-zinc-600 hover:bg-zinc-50'
onClick={() => { setInput(c.cmd + ' '); setShowCmds(false); textRef.current?.focus(); }}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
i === (cmdIdx % matchingCmds.length) ? 'bg-zinc-50 text-zinc-900' : 'text-zinc-500 hover:bg-zinc-50'
}`}
>
<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>
</button>
);
@@ -76,44 +122,96 @@ export function ChatInput({ onSend, disabled }: { onSend: (content: string) => v
</div>
)}
{/* Input bar */}
<div className="px-4 pb-4 pt-2 bg-gradient-to-t from-white via-white to-transparent">
{/* Input area */}
<div
className="px-5 pb-5 pt-3"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className={`
flex items-end gap-2 bg-zinc-50 rounded-2xl border transition-all duration-200 px-4 py-3
${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'}
bg-white rounded-3xl border transition-all duration-300 relative
${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
ref={ref}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={disabled ? '等待回复中...' : '输入消息,或按 / 查看快捷命令...'}
className="flex-1 bg-transparent text-sm resize-none outline-none placeholder:text-zinc-400 text-zinc-700 min-h-[24px] max-h-[120px]"
disabled={disabled}
style={{ height: 'auto' }}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}}
/>
<button
onClick={handleSend}
disabled={!canSend}
className={`
w-8 h-8 rounded-xl flex items-center justify-center transition-all flex-shrink-0
${canSend
? 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm'
: 'bg-zinc-200 text-zinc-400 cursor-not-allowed'}
`}
>
<ArrowUp size={16} strokeWidth={2.5} />
</button>
{/* Drop overlay */}
{dragging && (
<div className="absolute inset-0 rounded-3xl bg-zinc-50/80 backdrop-blur-sm z-10 flex items-center justify-center pointer-events-none">
<span className="text-sm text-zinc-400 font-medium"></span>
</div>
)}
{/* Image preview strip */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2.5 px-5 pt-4">
{images.map((img, i) => (
<div key={i} className="relative group/att">
<img src={img.preview} alt="" className="w-20 h-20 object-cover rounded-2xl shadow-sm" />
<button
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"
>
<X size={10} strokeWidth={3} />
</button>
</div>
))}
</div>
)}
{/* Input row */}
<div className="flex items-end gap-1 px-3 py-3">
{/* Attachment 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>
<p className="text-[10px] text-zinc-400 text-center mt-1.5">
Agent
</p>
</div>
</div>
);

View File

@@ -169,18 +169,18 @@ export function ConfigForm() {
return (
<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
onClick={() => setTab('agent')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
${tab === 'agent' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150
${tab === 'agent' ? 'bg-zinc-900 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100'}`}
>
Agent
</button>
<button
onClick={() => setTab('skills')}
className={`px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px
${tab === 'skills' ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'}`}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150
${tab === 'skills' ? 'bg-zinc-900 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100'}`}
>
</button>

View File

@@ -14,14 +14,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
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
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown size={14} className="text-zinc-400" />
<ChevronDown size={14} className="text-zinc-400 transition-transform duration-200" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
@@ -35,7 +35,9 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
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',
className
)}
@@ -62,7 +64,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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
)}
{...props}