码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/Console.tsx
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>
  )
}