码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/components/PushConfig.tsx
PushConfig.tsx9.7 KB
import { useEffect, useState, useCallback } from 'react'
import { api } from '@/lib/api'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Loader2, Plus, Trash2, Webhook, Clock, RefreshCw, Copy } from 'lucide-react'
import { toast } from 'sonner'
import { DndPanel } from '@/components/DndPanel'

interface Channel {
  id: number
  peer_id: string
  token: string
  name: string
  ai_enabled: number
  ai_prompt: string
  created_at: number
  url: string
}

interface Schedule {
  id: number
  peer_id: string
  content: string
  ai_enabled: number
  ai_prompt: string
  repeat_type: string
  next_at: number
  created_at: number
}

function absUrl(url: string) {
  if (!url) return ''
  if (/^https?:\/\//i.test(url)) return url
  const origin = typeof window !== 'undefined' ? window.location.origin : ''
  return origin + url
}

export function PushConfig({ botId }: { botId: number }) {
  const [channels, setChannels] = useState<Channel[]>([])
  const [schedules, setSchedules] = useState<Schedule[]>([])
  const [defaultPeer, setDefaultPeer] = useState('')
  const [loading, setLoading] = useState(false)

  const [schContent, setSchContent] = useState('')
  const [schTime, setSchTime] = useState('')
  const [schAi, setSchAi] = useState(false)
  const [schPeer, setSchPeer] = useState('')
  const [savingSch, setSavingSch] = useState(false)

  const load = useCallback(async () => {
    setLoading(true)
    try {
      const d = await api.post('/api/push/channels', { bot_id: botId })
      if (d.ok) {
        setChannels(d.channels || [])
        setSchedules(d.schedules || [])
        setDefaultPeer(d.defaultPeer || '')
        setSchPeer(d.defaultPeer || '')
      }
    } catch (e: any) {
      toast.error(e.message)
    } finally {
      setLoading(false)
    }
  }, [botId])

  useEffect(() => { load() }, [load])

  async function bind(rebind: boolean) {
    try {
      const d = await api.post('/api/push/bind', { bot_id: botId, default_peer: defaultPeer, rebind })
      if (!d.ok) throw new Error(d.msg)
      toast.success(rebind ? '已重新生成链接' : '已创建推送链接')
      load()
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  async function unbind(id: number) {
    try {
      const d = await api.post('/api/push/unbind', { bot_id: botId, id })
      if (!d.ok) throw new Error(d.msg)
      toast.success('已解绑')
      load()
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  async function updateChannel(id: number, patch: Record<string, any>) {
    try {
      const d = await api.post('/api/push/channel_update', { bot_id: botId, id, ...patch })
      if (!d.ok) throw new Error(d.msg)
      toast.success('已保存')
      load()
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  async function addSchedule() {
    if (!schContent.trim() || !schTime.trim()) {
      toast.error('请填写内容与时间')
      return
    }
    setSavingSch(true)
    try {
      const d = await api.post('/api/push/schedule_add', {
        bot_id: botId,
        peer_id: schPeer || defaultPeer,
        content: schContent.trim(),
        time_desc: schTime.trim(),
        ai_enabled: schAi,
      })
      if (!d.ok) throw new Error(d.msg)
      toast.success('已添加定时推送')
      setSchContent('')
      setSchTime('')
      setSchAi(false)
      load()
    } catch (e: any) {
      toast.error(e.message)
    } finally {
      setSavingSch(false)
    }
  }

  async function delSchedule(id: number) {
    try {
      const d = await api.post('/api/push/schedule_del', { bot_id: botId, id })
      if (!d.ok) throw new Error(d.msg)
      toast.success('已取消')
      load()
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  function copy(url: string) {
    const u = absUrl(url)
    navigator.clipboard?.writeText(u).then(() => toast.success('链接已复制'))
  }

  return (
    <div className="space-y-5">
      <p className="text-[11px] leading-relaxed text-muted-foreground">
        Webhook 推送(类似 Server 酱 / Bark):在微信发送「推送 绑定」生成专属链接后,外部系统 POST 到该链接即可把消息推送到本会话。可开启「智能助手 AI 润色」,并支持「定时推送」。
      </p>

      {loading ? (
        <div className="flex justify-center py-2"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
      ) : (
        <>
          {/* 通道 */}
          <div className="space-y-2">
            <div className="flex items-center gap-1.5 text-xs font-medium">
              <Webhook className="h-3.5 w-3.5 text-muted-foreground" /> 推送通道
            </div>
            {channels.length === 0 ? (
              <p className="text-[11px] text-muted-foreground">尚未绑定。可点击下方「生成链接」,或在微信中发送「推送 绑定」。</p>
            ) : (
              channels.map(c => (
                <div key={c.id} className="space-y-2 rounded-md border p-3">
                  <div className="flex items-center gap-2">
                    <code className="flex-1 truncate rounded bg-muted px-2 py-1 text-[10px]">{absUrl(c.url)}</code>
                    <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copy(c.url)} aria-label="复制"><Copy className="h-3.5 w-3.5" /></Button>
                    <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => bind(true)} aria-label="重置"><RefreshCw className="h-3.5 w-3.5" /></Button>
                    <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => unbind(c.id)} aria-label="解绑"><Trash2 className="h-3.5 w-3.5 text-destructive" /></Button>
                  </div>
                  <p className="text-[10px] text-muted-foreground">会话:{c.peer_id}{c.name ? `(${c.name})` : ''}</p>
                  <div className="flex items-center justify-between">
                    <Label className="text-xs">智能助手 AI 润色</Label>
                    <Switch checked={c.ai_enabled === 1} onCheckedChange={ck => updateChannel(c.id, { ai_enabled: ck ? 1 : 0 })} />
                  </div>
                  <Textarea
                    value={c.ai_prompt}
                    placeholder="AI 指令(可选),如:把上面的内容总结成 3 个要点,用中文"
                    onChange={e => updateChannel(c.id, { ai_prompt: e.target.value })}
                    className="min-h-[44px] text-xs"
                  />
                </div>
              ))
            )}
            {channels.length === 0 && (
              <Button size="sm" className="h-7 text-xs" onClick={() => bind(false)}>
                <Plus className="mr-1 h-3.5 w-3.5" /> 生成链接
              </Button>
            )}
          </div>

          {/* 定时推送 */}
          <div className="space-y-2 border-t pt-3">
            <div className="flex items-center gap-1.5 text-xs font-medium">
              <Clock className="h-3.5 w-3.5 text-muted-foreground" /> 定时推送
            </div>
            {schedules.length === 0 ? (
              <p className="text-[11px] text-muted-foreground">暂无定时推送。</p>
            ) : (
              <div className="space-y-1.5">
                {schedules.map(s => (
                  <div key={s.id} className="flex items-start gap-2 rounded border p-2">
                    <div className="flex-1 text-[11px]">
                      <div className="font-medium">{s.content}</div>
                      <div className="text-muted-foreground">
                        {new Date(s.next_at * 1000).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
                        {s.repeat_type === 'daily' ? ' · 每天' : s.repeat_type === 'weekly' ? ' · 每周' : ''}
                        {s.ai_enabled === 1 ? ' · AI' : ''}
                      </div>
                    </div>
                    <Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => delSchedule(s.id)} aria-label="取消"><Trash2 className="h-3.5 w-3.5 text-destructive" /></Button>
                  </div>
                ))}
              </div>
            )}

            <div className="space-y-1.5 rounded-md border p-2">
              <Input value={schTime} placeholder="时间,如:每天9点 / 30分钟后 / 2026-07-20 14:30" onChange={e => setSchTime(e.target.value)} className="h-7 text-xs" />
              <Input value={schContent} placeholder="要推送的内容" onChange={e => setSchContent(e.target.value)} className="h-7 text-xs" />
              <div className="flex items-center justify-between">
                <Label className="text-xs">用智能助手生成/润色</Label>
                <Switch checked={schAi} onCheckedChange={setSchAi} />
              </div>
              <Button size="sm" className="h-7 w-full text-xs" disabled={savingSch} onClick={addSchedule}>
                {savingSch ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Plus className="mr-1 h-3.5 w-3.5" />}
                添加定时推送
              </Button>
            </div>
            <p className="text-[10px] leading-tight text-muted-foreground">
              定时推送默认发往最近一次收到消息的会话。AI 开启时,内容会先交给智能助手处理后再推送。
            </p>
          </div>

          <DndPanel botId={botId} />
        </>
      )}
    </div>
  )
}