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

- 在 AccountForm、AssetGallery、ChatView、ConfigForm、PromptEditor 中将 `<select>` 替换为 shadcn/ui 的 Select 组件以统一 UI 风格
- 在 ChatView 和 useChat hook 中支持发送图片附件
- 更新 pi-bridge 和 ws/chat 以处理 agent 调用中的图片数据
This commit is contained in:
2026-05-08 02:18:50 +08:00
parent 803b55605f
commit 2186b04675
9 changed files with 110 additions and 84 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import type { Account } from '@/types';
interface Props {

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import type { Account } from '@/types';
interface Props {
@@ -64,41 +65,44 @@ export function AccountForm({ account, onSave, onDelete, onCancel }: Props) {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs 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>
</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>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs 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 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 Fast 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 Fast Frames</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
<SelectItem value="grok">Grok</SelectItem>
</SelectContent>
</Select>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { useAssets } from '@/hooks/useAssets';
import { AssetProjectGroup } from './AssetProjectGroup';
import { AssetPreview } from './AssetPreview';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import type { Asset } from '@/types';
interface ManifestGroup {
@@ -125,16 +126,17 @@ export function AssetGallery() {
))}
</div>
<select
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
className="h-8 rounded-lg border border-zinc-100 bg-zinc-50/80 px-2 text-xs hover:border-zinc-200 transition-colors"
>
<option value=""></option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<Select value={accountFilter || 'all'} onValueChange={(v) => setAccountFilter(v === 'all' ? '' : v)}>
<SelectTrigger className="h-8 w-auto min-w-[100px] text-xs border-zinc-100 bg-zinc-50/80 hover:border-zinc-200">
<SelectValue placeholder="全部账号" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-8 text-xs rounded-lg" onClick={handleScan}>

View File

@@ -126,10 +126,10 @@ export function ChatView() {
creatingRef.current = false;
}, [createConversation, selectedAccountId]);
const handleSend = useCallback((content: string) => {
const handleSend = useCallback((content: string, images?: Array<{ data: string; mimeType: string }>) => {
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
if (conversationId) {
send(content);
send(content, images);
} else {
handleSendNew(content);
}
@@ -154,14 +154,18 @@ export function ChatView() {
{accounts.length > 0 && (
<div className="flex items-center gap-2 mt-3 justify-center">
<span className="text-xs text-zinc-400">:</span>
<select
<Select
value={selectedAccountId || ''}
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
className="h-7 rounded-lg border border-zinc-100 bg-white px-2.5 text-xs text-zinc-600 hover:border-zinc-200 transition-colors"
onValueChange={(v) => useAppStore.getState().setSelectedAccountId(v === 'none' ? null : v)}
>
<option value=""></option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<SelectTrigger className="h-7 w-auto min-w-[100px] text-xs px-2.5 border-zinc-100 hover:border-zinc-200">
<SelectValue placeholder="不指定" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accounts.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
{recentConvs.length > 0 && (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { api } from '@/lib/api';
import { Save, Check, Loader2, TestTube2 } from 'lucide-react';
@@ -272,35 +273,47 @@ export function ConfigForm() {
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={agentForm.defaultImageModel} onChange={(e) => handleAgentChange('defaultImageModel', 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=""></option>
<option value="gemini">Gemini</option>
<option value="mj">Midjourney</option>
<option value="gpt">GPT Image</option>
<option value="kling">Kling</option>
</select>
<Select value={agentForm.defaultImageModel || 'none'} onValueChange={(v) => handleAgentChange('defaultImageModel', v === 'none' ? '' : v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue placeholder="未设置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<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={agentForm.defaultVideoModel} onChange={(e) => handleAgentChange('defaultVideoModel', 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=""></option>
<option value="veo3-fast">Veo3 Fast</option>
<option value="veo3-fast-frames">Veo3 Fast Frames</option>
<option value="kling">Kling</option>
<option value="grok">Grok</option>
</select>
<Select value={agentForm.defaultVideoModel || 'none'} onValueChange={(v) => handleAgentChange('defaultVideoModel', v === 'none' ? '' : v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue placeholder="未设置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="veo3-fast">Veo3 Fast</SelectItem>
<SelectItem value="veo3-fast-frames">Veo3 Fast Frames</SelectItem>
<SelectItem value="kling">Kling</SelectItem>
<SelectItem value="grok">Grok</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select value={agentForm.defaultFormat} onChange={(e) => handleAgentChange('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=""></option>
<option value="9:16">9:16 </option>
<option value="16:9">16:9 </option>
<option value="1:1">1:1 </option>
</select>
<Select value={agentForm.defaultFormat || 'none'} onValueChange={(v) => handleAgentChange('defaultFormat', v === 'none' ? '' : v)}>
<SelectTrigger className="mt-1 w-full h-10 text-sm bg-zinc-50 border-zinc-200">
<SelectValue placeholder="未设置" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="9:16">9:16 </SelectItem>
<SelectItem value="16:9">16:9 </SelectItem>
<SelectItem value="1:1">1:1 </SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import 'prismjs/themes/prism.css';
import { useAccounts } from '@/hooks/useAccounts';
import { usePrompts } from '@/hooks/usePrompts';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
const PROMPT_TYPES = [
{ type: 'storyboard', label: '分镜' },
@@ -54,15 +55,16 @@ export function PromptEditor({ accountId: accountIdProp }: { accountId?: string
<div className="w-48 border-r border-zinc-200 p-3 space-y-3 bg-zinc-50">
<div>
<label className="text-xs font-medium text-zinc-500"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="mt-1 w-full h-9 rounded-md border border-zinc-200 bg-white px-2 text-sm text-zinc-700"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger className="mt-1 w-full h-9 text-sm">
<SelectValue placeholder="选择账户" />
</SelectTrigger>
<SelectContent>
{accounts.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-zinc-500"></label>

View File

@@ -150,8 +150,8 @@ export function useChat(conversationId: string | null) {
}
}, [conversationId, connected]);
const send = useCallback((content: string) => {
chatSocket.send('chat', { content });
const send = useCallback((content: string, images?: Array<{ data: string; mimeType: string }>) => {
chatSocket.send('chat', { content, images });
}, []);
const stop = useCallback(() => {