码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/components/DndPanel.tsx
DndPanel.tsx8.2 KB
import { useState, useEffect } from 'react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Clock, Loader2, Save } from 'lucide-react'
import { api } from '@/lib/api'
import { toast } from 'sonner'

const CAT_LABEL: Record<string, string> = {
  webhook: '外部 Webhook 推送',
  schedule: '定时推送',
  reminder: '提醒',
  ai: 'AI 主动发的消息',
}

/**
 * 勿扰 / 推送设置面板:按会话(peer)设置勿扰时段与静音类别。
 * embedded=true 时用于插件弹窗(紧凑字号);false 时用于独立页面(标准字号)。
 */
export function DndPanel({ botId, embedded = true }: { botId: number; embedded?: boolean }) {
  const [peers, setPeers] = useState<{ peer_id: string; cnt: number }[]>([])
  const [selPeer, setSelPeer] = useState('')
  const [dndCfg, setDndCfg] = useState<{ enabled: boolean; start_min: number; end_min: number; mask: number }>({
    enabled: false,
    start_min: 1320,
    end_min: 480,
    mask: 7,
  })
  const [cats, setCats] = useState<Record<string, number>>({})
  const [, setDefaultMask] = useState(7)
  const [savingDnd, setSavingDnd] = useState(false)

  // 尺寸预设:弹窗紧凑 vs 页面标准
  const S = embedded
    ? {
        wrap: 'space-y-2 border-t pt-3',
        title: 'text-xs',
        titleIcon: 'h-3.5 w-3.5',
        desc: 'text-[10px] leading-tight',
        box: 'space-y-1.5 rounded-md border p-2',
        label: 'text-[11px]',
        labelSm: 'text-[10px]',
        input: 'h-7 text-xs',
        sel: 'h-7 text-xs',
        catLabel: 'text-[11px]',
        btn: 'h-7 w-full text-xs',
        btnIcon: 'h-3.5 w-3.5',
      }
    : {
        wrap: 'space-y-4',
        title: 'text-base',
        titleIcon: 'h-5 w-5',
        desc: 'text-sm leading-relaxed',
        box: 'space-y-4 rounded-lg border p-4',
        label: 'text-sm',
        labelSm: 'text-xs',
        input: 'h-9 text-sm',
        sel: 'h-9 text-sm',
        catLabel: 'text-sm font-normal',
        btn: 'h-9 text-sm',
        btnIcon: 'h-4 w-4',
      }

  async function loadPeers() {
    try {
      const dn = await api.post('/api/push/dnd_list', { bot_id: botId })
      if (dn.ok) {
        const ps = dn.peers || []
        setPeers(ps)
        setCats(dn.cats || {})
        setDefaultMask(dn.defaultMask ?? 7)
        // 只有一个会话时直接选中,无需手动选择
        if (ps.length === 1) loadDnd(ps[0].peer_id)
        else setSelPeer('')
      }
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  async function loadDnd(peerId: string) {
    if (!peerId) {
      setSelPeer('')
      return
    }
    setSelPeer(peerId)
    try {
      const d = await api.post('/api/push/dnd_get', { bot_id: botId, peer_id: peerId })
      if (d.ok)
        setDndCfg({
          enabled: !!d.dnd.enabled,
          start_min: d.dnd.start_min,
          end_min: d.dnd.end_min,
          mask: d.dnd.mask,
        })
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  // botId 变化时重新拉取会话列表
  useEffect(() => {
    setSelPeer('')
    loadPeers()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [botId])

  function minToStr(m: number) {
    m = m || 0
    return String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0')
  }
  function strToMin(s: string) {
    const mm = (s || '').match(/^(\d{1,2}):(\d{2})$/)
    if (!mm) return null
    const h = parseInt(mm[1], 10),
      mi = parseInt(mm[2], 10)
    if (h > 23 || mi > 59) return null
    return h * 60 + mi
  }
  function toggleCat(key: string, on: boolean) {
    const bit = cats[key] || 0
    setDndCfg((prev) => ({ ...prev, mask: on ? prev.mask | bit : prev.mask & ~bit }))
  }
  async function saveDnd() {
    if (!selPeer) {
      toast.error('请先选择要设置的会话')
      return
    }
    const sMin = strToMin(minToStr(dndCfg.start_min))
    const eMin = strToMin(minToStr(dndCfg.end_min))
    if (sMin == null || eMin == null) {
      toast.error('时间格式不正确')
      return
    }
    setSavingDnd(true)
    try {
      const d = await api.post('/api/push/dnd_save', {
        bot_id: botId,
        peer_id: selPeer,
        enabled: dndCfg.enabled,
        start_min: sMin,
        end_min: eMin,
        mask: dndCfg.mask,
      })
      if (!d.ok) throw new Error(d.msg)
      toast.success('已保存推送设置')
    } catch (e: any) {
      toast.error(e.message)
    } finally {
      setSavingDnd(false)
    }
  }

  return (
    <div className={S.wrap}>
      <div className={'flex items-center gap-1.5 font-medium ' + S.title}>
        <Clock className={S.titleIcon + ' text-muted-foreground'} /> 勿扰时段(推送设置)
      </div>
      <p className={'text-muted-foreground ' + S.desc}>
        设置后,在该时段内机器人主动发出的指定类别消息会被延后到时段结束再发。AI 主动发的消息默认不禁(可在下方取消勾选来禁)。
      </p>

      {peers.length === 0 && (
        <p className={'text-muted-foreground ' + S.desc}>暂无往来会话,可先在微信中与该机器人对话。</p>
      )}

      {peers.length > 1 && (
        <div className={S.box}>
          <Label className={S.label}>选择会话(用户)</Label>
          <select
            value={selPeer}
            onChange={(e) => loadDnd(e.target.value)}
            className={'w-full rounded border bg-background px-2 ' + S.sel}
          >
            <option value="">— 请选择会话 —</option>
            {peers.map((p) => (
              <option key={p.peer_id} value={p.peer_id}>
                {p.peer_id}({p.cnt} 条)
              </option>
            ))}
          </select>
        </div>
      )}

      {selPeer && (
        <div className={S.box}>
          <div className="flex items-center justify-between pt-1">
            <Label className={S.label}>启用勿扰时段</Label>
            <Switch checked={dndCfg.enabled} onCheckedChange={(ck) => setDndCfg((prev) => ({ ...prev, enabled: ck }))} />
          </div>

          <div className="grid grid-cols-2 gap-3">
            <div className="space-y-1">
              <Label className={S.labelSm}>开始(HH:MM)</Label>
              <Input
                value={minToStr(dndCfg.start_min)}
                onChange={(e) => {
                  const m = strToMin(e.target.value)
                  if (m != null) setDndCfg((prev) => ({ ...prev, start_min: m }))
                }}
                className={S.input}
              />
            </div>
            <div className="space-y-1">
              <Label className={S.labelSm}>结束(HH:MM)</Label>
              <Input
                value={minToStr(dndCfg.end_min)}
                onChange={(e) => {
                  const m = strToMin(e.target.value)
                  if (m != null) setDndCfg((prev) => ({ ...prev, end_min: m }))
                }}
                className={S.input}
              />
            </div>
          </div>

          <div className="space-y-2 pt-1">
            <Label className={S.labelSm}>勿扰时段内静音哪些主动消息</Label>
            {Object.keys(cats).map((k) => (
              <div key={k} className="flex items-center justify-between">
                <Label className={S.catLabel}>{CAT_LABEL[k] || k}</Label>
                <Switch checked={(dndCfg.mask & (cats[k] || 0)) !== 0} onCheckedChange={(ck) => toggleCat(k, ck)} />
              </div>
            ))}
          </div>

          <Button className={S.btn} disabled={savingDnd} onClick={saveDnd}>
            {savingDnd ? <Loader2 className={'mr-1 animate-spin ' + S.btnIcon} /> : <Save className={'mr-1 ' + S.btnIcon} />}
            保存推送设置
          </Button>
        </div>
      )}
      <p className={'text-muted-foreground ' + S.desc}>
        也可在微信中发送「勿扰 22:00-08:00」快速设置,发送「勿扰 状态」查看,发送「勿扰 关」关闭。
      </p>
    </div>
  )
}