码桶
发现社区成员的开源项目
Console.tsx23.2 KB
import { useEffect, useRef, useState, useCallback, type ChangeEvent } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { useAuth } from '@/lib/auth'
import { api, type Bot, type ChatMessage, type MsgEvent } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { toast } from 'sonner'
import { Loader2, Send, RefreshCw, MessageSquare, Bot as BotIcon, LogOut, Image as ImageIcon, Mic, Video, Paperclip, CheckCircle2, XCircle, Clock, ChevronDown, ChevronUp, Terminal, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { PluginSettingsEditor } from '@/components/PluginConfigPanel'
// ============ 机器人绑定(二维码轮询) ============
function BotBind({ bot, onBound }: { bot: Bot; onBound: () => void }) {
const [qr, setQr] = useState('')
const [status, setStatus] = useState(bot.login_status)
const [loading, setLoading] = useState(false)
const timer = useRef<number | null>(null)
const boundRef = useRef(false)
const onBoundRef = useRef(onBound)
onBoundRef.current = onBound
const fetchQr = useCallback(async () => {
setLoading(true)
try {
const d = await api.post('/api/bot_qrcode', { bot_id: bot.id })
// 二维码必须编码绑定链接(qrcode_url),而非轮询 token(qrcode),否则微信无法识别
if (d.ok) setQr(d.qrcode_url || d.qrcode)
} catch (e: any) {
toast.error(e.message)
} finally {
setLoading(false)
}
}, [bot.id])
// 仅在挂载时初始化一次:拉二维码 + 每 2 秒轮询状态。
// 不要将 status 放进依赖:否则确认后若被后端重置为 wait,会反复重新生成二维码,
// 表现为“已绑定却又让重新扫码”。onBound 用 ref 持有,避免父组件重渲染触发 effect 重跑。
useEffect(() => {
let active = true
fetchQr()
const t = window.setInterval(async () => {
if (boundRef.current) return
try {
const d = await api.post('/api/bot_status', { bot_id: bot.id })
if (!active || !d.ok) return
setStatus(d.status)
if (d.status === 'confirmed' && !boundRef.current) {
boundRef.current = true
window.clearInterval(t)
toast.success('机器人已绑定')
onBoundRef.current()
}
} catch { /* ignore */ }
}, 2000)
timer.current = t
return () => { active = false; window.clearInterval(t) }
}, [bot.id, fetchQr])
return (
<Card className="mx-auto mt-8 w-full max-w-md">
<CardHeader>
<CardTitle>绑定微信</CardTitle>
<CardDescription>使用微信扫描下方二维码完成登录绑定</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<div className="rounded-xl border bg-white p-4">
{loading && !qr ? <Loader2 className="h-40 w-40 animate-spin text-muted-foreground" /> :
qr ? <QRCodeSVG value={qr} size={160} /> : <span className="text-sm text-muted-foreground">获取二维码失败</span>}
</div>
<p className="px-2 text-center text-xs leading-relaxed text-muted-foreground">
扫码成功后因微信要求需主动发一条消息,当收到机器人回复「您已成功连接奶狗WeBot」时,即为连接成功。
</p>
<Badge variant={status === 'confirmed' ? 'success' : 'warning'}>
{status === 'confirmed' ? '已绑定' : status === 'wait' ? '等待扫码' : '未绑定'}
</Badge>
<Button variant="outline" size="sm" onClick={fetchQr} disabled={loading}>
<RefreshCw className="h-4 w-4" /> 刷新二维码
</Button>
</CardContent>
</Card>
)
}
// ============ 媒体上传按钮 ============
function MediaButton({ icon: Icon, accept, title, onPick, disabled }: {
icon: typeof ImageIcon; accept: string; title: string
onPick: (file: File) => void; disabled?: boolean
}) {
const ref = useRef<HTMLInputElement>(null)
return (
<>
<input
ref={ref}
type="file"
accept={accept}
className="hidden"
onChange={e => {
const f = e.target.files?.[0]
if (f) onPick(f)
if (ref.current) ref.current.value = ''
}}
/>
<Button variant="outline" size="icon" title={title} disabled={disabled} onClick={() => ref.current?.click()}>
<Icon className="h-4 w-4" />
</Button>
</>
)
}
// ============ 聊天控制台(三栏布局) ============
function ChatConsole({ bot }: { bot: Bot }) {
const { refresh } = useAuth()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [text, setText] = useState('')
const [sending, setSending] = useState(false)
const [uploading, setUploading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const eventsEndRef = useRef<HTMLDivElement>(null)
// 消息处理事件日志
const [events, setEvents] = useState<MsgEvent[]>([])
const [eventsOpen, setEventsOpen] = useState(false)
const [cfgOpen, setCfgOpen] = useState(false)
const [cfgPlugin, setCfgPlugin] = useState<any>(null)
const lastEventIdRef = useRef(0)
// 插件配置弹窗:插件列表 + 当前选择
const [cfgPlugins, setCfgPlugins] = useState<{ id: string; name: string }[]>([])
const [cfgSelId, setCfgSelId] = useState('')
const [cfgLoading, setCfgLoading] = useState(true)
useEffect(() => {
let active = true
setCfgLoading(true)
api.post('/api/market/list', { bot_id: bot.id, pageSize: 50 })
.then(r => {
if (!active) return
if (r.ok) {
const ps = (r.plugins || []).filter((p: any) => p.installed && p.configurable)
setCfgPlugins(ps.map((p: any) => ({ id: p.id, name: p.name })))
if (ps[0]) setCfgSelId(ps[0].id)
}
})
.catch(() => { /* ignore */ })
.finally(() => { if (active) setCfgLoading(false) })
return () => { active = false }
}, [bot.id])
// 打开弹窗时按选中插件 id 拉取完整插件(含 settingsSchema / customConfig)
useEffect(() => {
if (!cfgOpen || !cfgSelId) return
let active = true
setCfgPlugin(null)
api.post('/api/market/list', { bot_id: bot.id, pageSize: 50 })
.then(r => {
if (!active || !r.ok) return
const p = (r.plugins || []).find((x: any) => x.id === cfgSelId)
if (p) setCfgPlugin({ id: p.id, name: p.name, settingsSchema: p.settingsSchema, customConfig: p.customConfig, configurable: p.configurable })
})
.catch(() => { /* ignore */ })
return () => { active = false }
}, [cfgOpen, cfgSelId, bot.id])
useEffect(() => {
let active = true
const poll = async () => {
try {
const d = await api.post('/api/bot_events', { bot_id: bot.id, since_id: lastEventIdRef.current })
if (active && d.events?.length) {
setEvents(prev => {
const next = [...prev, ...d.events]
return next.slice(-200)
})
const maxId = d.events.reduce((m: number, e: MsgEvent) => Math.max(m, e.id), 0)
if (maxId > lastEventIdRef.current) lastEventIdRef.current = maxId
}
} catch { /* ignore */ }
}
poll()
const timer = setInterval(poll, 2000)
return () => { active = false; clearInterval(timer) }
}, [bot.id])
// 事件面板自动滚动
useEffect(() => {
if (eventsOpen && eventsEndRef.current) eventsEndRef.current.scrollTop = eventsEndRef.current.scrollHeight
}, [events, eventsOpen])
const eventCount = events.filter(e => e.type === 'error' || e.type === 'outbound_fail').length
const loadMessages = useCallback(async () => {
try {
const d = await api.post('/api/bot_messages', { bot_id: bot.id })
if (d.ok) setMessages(d.messages || [])
} catch { /* ignore */ }
}, [bot.id])
useEffect(() => {
loadMessages()
const t = window.setInterval(loadMessages, 1000)
return () => window.clearInterval(t)
}, [loadMessages])
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages])
async function send() {
const content = text.trim()
if (!content || sending) return
setSending(true)
try {
const d = await api.post('/api/bot_send', { bot_id: bot.id, content })
if (!d.ok) throw new Error(d.msg)
setText('')
loadMessages()
} catch (e: any) {
toast.error(e.message)
} finally {
setSending(false)
}
}
async function uploadFile(file: File) {
if (file.size > 50 * 1024 * 1024) { toast.error('文件不能超过 50MB'); return }
setUploading(true)
try {
const fd = new FormData()
fd.append('bot_id', String(bot.id))
fd.append('peer_id', '')
fd.append('file', file)
const d = await api.post('/api/bot_upload', fd)
if (!d.ok) throw new Error(d.msg)
toast.success('已发送')
loadMessages()
} catch (e: any) {
toast.error(e.message)
} finally {
setUploading(false)
}
}
async function unbind() {
const d = await api.post('/api/bot_unbind', { bot_id: bot.id })
if (d.ok) { toast.success('已解除绑定'); refresh() }
else toast.error(d.msg || '解绑失败')
}
return (
<div className="flex h-[calc(100vh-3.5rem)] gap-3 p-3 md:gap-4 md:p-4">
{/* ===== 中栏:聊天窗口 ===== */}
<Card className="flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-3 p-4">
{messages.length === 0 && (
<div className="flex h-40 flex-col items-center justify-center text-sm text-muted-foreground">
<MessageSquare className="mb-2 h-8 w-8 opacity-40" />
暂无消息,等待微信消息或发送一条试试
</div>
)}
{messages.map(m => {
const out = m.direction === 'out'
return (
<div key={m.id} className={`flex items-end gap-2 ${out ? 'justify-end' : 'justify-start'}`}>
{!out && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-500 text-white" title="微信">
<svg viewBox="0 0 24 24" fill="currentColor" className="h-[18px] w-[18px]" aria-hidden>
<path d="M9.5 3C5.36 3 2 5.9 2 9.4c0 1.9 1.1 3.6 2.8 4.8L4 16.4l2.9-1.4c1 .3 2.1.5 3.2.5.3 0 .6 0 .9-.1A5.5 5.5 0 0 1 9.5 13c0-2.9 2.9-5.2 6.5-5.2.4 0 .8 0 1.2.1C16.3 5.2 13.1 3 9.5 3Zm-2.5 4.2a1.1 1.1 0 1 1 0 2.2 1.1 1.1 0 0 1 0-2.2Zm5 0a1.1 1.1 0 1 1 0 2.2 1.1 1.1 0 0 1 0-2.2Z" />
<path d="M22 14.3c0-2.7-2.6-4.9-5.8-4.9s-5.8 2.2-5.8 4.9 2.6 4.9 5.8 4.9c.7 0 1.4-.1 2-.3l2 1-.5-1.7c1.4-.9 2.3-2.1 2.3-3.9Zm-7.6-1.1a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8Zm3.8 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8Z" />
</svg>
</div>
)}
<div className={`max-w-[72%] min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm ${
out ? 'bg-primary text-primary-foreground' : 'bg-muted'
}`}>
{m.msg_type === 'text' ? m.content : (() => {
// 非文本(图片/视频/文件/语音):原始 content 是 {l,r,t} JSON,
// 直接显示会溢出,这里只展示短标签;语音已识别则显示转换后的文字。
try {
const p = JSON.parse(m.content || '{}');
if (p && typeof p === 'object') {
if (m.msg_type === 'voice') {
return <span className="opacity-90">{p.t ? String(p.t) : (p.l || '[语音]')}</span>;
}
return <span className="opacity-80">{p.l || `[${m.msg_type}]`}</span>;
}
} catch (_) {}
return <span className="opacity-80">[{m.msg_type}] {m.content}</span>;
})()}
</div>
{out && (
<>
{/* 消息状态标记 */}
{m.status === 'ok' && <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" aria-label="已发送" />}
{m.status === 'failed' && (
<XCircle className="h-4 w-4 shrink-0 text-red-500 cursor-help" aria-label={m.error_msg || '发送失败'} />
)}
{m.status === 'pending' && <Clock className="h-4 w-4 shrink-0 text-amber-500 animate-pulse" aria-label="发送中…" />}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary" title="机器人">
<BotIcon className="h-5 w-5" />
</div>
</>
)}
</div>
)
})}
<div ref={bottomRef} />
</div>
</div>
{/* 消息处理事件日志 */}
<div className="border-t">
<button
onClick={() => setEventsOpen(o => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
>
<Terminal className="h-3.5 w-3.5" />
<span>处理日志</span>
{eventCount > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{eventCount}
</span>
)}
<span className="ml-auto">
{eventsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
</span>
</button>
{eventsOpen && (
<div ref={eventsEndRef} className="max-h-40 overflow-auto border-t font-mono text-[11px]">
{events.length === 0 ? (
<div className="px-3 py-4 text-center text-muted-foreground">
暂无处理记录,等待机器人收到消息后将在此显示处理流水线
</div>
) : events.slice(-50).map(e => {
const time = new Date(e.ts).toLocaleTimeString('zh-CN', { hour12: false })
const icon = e.type === 'inbound' ? 'IN' :
e.type === 'processing' ? '>>' :
e.type === 'outbound_ok' ? 'OK' :
e.type === 'outbound_fail' ? 'NG' :
e.type === 'error' ? '!!' : '--'
const cls = e.type === 'error' || e.type === 'outbound_fail' ? 'text-red-500' :
e.type === 'outbound_ok' ? 'text-emerald-600' : ''
return (
<div key={e.id} className={`flex items-start gap-1.5 px-3 py-1 border-b border-border/30 ${e.type === 'error' || e.type === 'outbound_fail' ? 'bg-red-50 dark:bg-red-950/20' : ''}`}>
<span className="shrink-0 text-muted-foreground w-14">{time}</span>
<span className="shrink-0">{icon}</span>
<span className={cls}>
{e.msg}
{e.detail && <span className="ml-1 text-muted-foreground/60">({e.detail})</span>}
</span>
</div>
)
})}
</div>
)}
</div>
<Separator />
<div className="space-y-2 p-3">
<div className="flex items-center gap-2">
<MediaButton icon={ImageIcon} accept="image/*" title="发送图片" onPick={uploadFile} disabled={uploading} />
<MediaButton icon={Mic} accept="audio/*" title="发送语音" onPick={uploadFile} disabled={uploading} />
<MediaButton icon={Video} accept="video/*" title="发送视频" onPick={uploadFile} disabled={uploading} />
<MediaButton icon={Paperclip} accept="*/*" title="发送文件" onPick={uploadFile} disabled={uploading} />
{uploading && <span className="flex items-center gap-1 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" /> 上传中…</span>}
</div>
<div className="flex items-center gap-2">
<Input
value={text}
onChange={e => setText(e.target.value)}
placeholder="输入消息,回车发送"
onKeyDown={e => e.key === 'Enter' && send()}
/>
<Button onClick={send} disabled={sending || !text.trim()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />} 发送
</Button>
</div>
</div>
</Card>
{/* ===== 右栏:机器人 / 插件配置 / 使用提示 ===== */}
<aside className="hidden w-72 shrink-0 flex-col gap-3 overflow-y-auto lg:flex">
<Card>
<CardContent className="flex flex-col items-center gap-2 py-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<BotIcon className="h-7 w-7" />
</div>
<div className="font-semibold">{bot.name}</div>
<Badge variant="success">已绑定</Badge>
</CardContent>
<CardContent className="border-t pt-4">
<div className="mb-2 text-sm font-medium">管理操作</div>
<div className="flex gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 justify-center text-red-600"><LogOut className="h-4 w-4" /> 解除绑定</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>解除机器人绑定?</AlertDialogTitle>
<AlertDialogDescription>解除后需重新扫码绑定,聊天记录保留。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={unbind} className="bg-red-600 hover:bg-red-700">解绑</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">插件配置</CardTitle></CardHeader>
<CardContent className="space-y-2">
{cfgLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />加载中…</div>
) : cfgPlugins.length ? (
<>
<Select value={cfgSelId} onValueChange={setCfgSelId}>
<SelectTrigger><SelectValue placeholder="选择插件" /></SelectTrigger>
<SelectContent>
{cfgPlugins.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" className="w-full" onClick={() => setCfgOpen(true)} disabled={!cfgSelId}>
<Settings className="h-4 w-4 mr-1.5" /> 设置
</Button>
</>
) : (
<p className="text-xs text-muted-foreground">当前没有已安装的可配置插件。</p>
)}
</CardContent>
</Card>
<Dialog open={cfgOpen} onOpenChange={setCfgOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>插件配置</DialogTitle>
<DialogDescription>
{cfgPlugins.find(p => p.id === cfgSelId)?.name || ''} 参数设置
</DialogDescription>
</DialogHeader>
{cfgPlugin ? (
<PluginSettingsEditor plugin={cfgPlugin} botId={bot.id} />
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />读取配置中…</div>
)}
</DialogContent>
</Dialog>
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">使用提示</CardTitle></CardHeader>
<CardContent className="space-y-1.5 text-xs text-muted-foreground">
<p>· 在下方输入框发送文本消息</p>
<p>· 用图片 / 语音 / 视频 / 文件按钮发送媒体</p>
<p>· 单个文件不超过 50MB</p>
<p>· 聊天记录每 4 秒自动刷新</p>
</CardContent>
</Card>
</aside>
</div>
)
}
// ============ 控制台主页 ============
export default function Console() {
const { bots, loading, refresh } = useAuth()
if (loading) {
return <div className="flex h-screen items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
}
const bot = bots[0]
if (bot && bot.login_status === 'confirmed') {
return <ChatConsole bot={bot} />
}
return (
<div className="p-3 md:p-6">
{!bot && (
<Card className="mx-auto mt-8 w-full max-w-md">
<CardHeader>
<CardTitle>创建你的机器人</CardTitle>
<CardDescription>每个账号可绑定一台微信机器人</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full" onClick={async () => {
try {
const d = await api.post('/api/bot_create')
if (!d.ok) throw new Error(d.msg)
toast.success('机器人已创建,请绑定微信')
await refresh()
} catch (e: any) { toast.error(e.message) }
}}>
<BotIcon className="h-4 w-4" /> 创建并绑定机器人
</Button>
</CardContent>
</Card>
)}
{bot && bot.login_status !== 'confirmed' && (
<BotBind bot={bot} onBound={refresh} />
)}
</div>
)
}