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

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