码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/AutomationPage.tsx
AutomationPage.tsx22 KB
import { useEffect, useMemo, useState } from 'react'
import { useAuth } from '@/lib/auth'
import { api } from '@/lib/api'
import { toast } from 'sonner'
import { CalendarClock, Plus, Pencil, Trash2, Clock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import {
  AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
  AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'

type ReminderItem = {
  id: number
  bot_id: number
  peer_id: string
  content: string
  remind_at: number
  repeat_type: 'once' | 'daily' | 'weekly' | 'interval' | 'cron'
  repeat_rule: string
  created_at: number
  fired: number
  enabled: number
}

type Peer = { peer_id: string; last_msg: string }
type PluginInfo = { id: string; name: string; usage: string; command: string }

type FormState = {
  content: string
  repeatType: 'once' | 'daily' | 'weekly' | 'interval' | 'cron'
  onceDateTime: string
  dailyTime: string
  weeklyDay: string
  weeklyTime: string
  intervalValue: number
  intervalUnit: 'min' | 'hour'
  cronExpr: string
  peerId: string
  enabled: boolean
}

const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']

function pad(n: number) {
  return n < 10 ? '0' + n : '' + n
}

function tsToDateTimeLocal(ts: number) {
  const d = new Date(ts * 1000)
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function tsToHHmm(ts: number) {
  const d = new Date(ts * 1000)
  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}

// 根据表单计算下次触发时间戳(秒)
function computeRemindAt(f: FormState): number {
  const now = Math.floor(Date.now() / 1000)
  if (f.repeatType === 'once') {
    const t = new Date(f.onceDateTime).getTime()
    return Math.floor(t / 1000)
  }
  if (f.repeatType === 'daily') {
    const [h, m] = (f.dailyTime || '09:00').split(':').map(Number)
    const d = new Date()
    d.setHours(h, m, 0, 0)
    if (Math.floor(d.getTime() / 1000) <= now) d.setDate(d.getDate() + 1)
    return Math.floor(d.getTime() / 1000)
  }
  // weekly
  const wday = parseInt(f.weeklyDay, 10) || 1
  const [h, m] = (f.weeklyTime || '09:00').split(':').map(Number)
  const d = new Date()
  d.setHours(h, m, 0, 0)
  let diff = wday - d.getDay()
  if (diff === 0 && Math.floor(d.getTime() / 1000) <= now) diff = 7
  else if (diff < 0) diff += 7
  d.setDate(d.getDate() + diff)
  return Math.floor(d.getTime() / 1000)
  // 注:interval / cron 的实际触发时间由后端按规则计算,这里仅给一个合法未来时间戳用于校验
  return now + 1
}

function describe(item: ReminderItem) {
  if (item.repeat_type === 'once') {
    if (item.fired) return '已触发'
    return new Date(item.remind_at * 1000).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
  }
  if (item.repeat_type === 'daily') return '每天 ' + tsToHHmm(item.remind_at)
  if (item.repeat_type === 'weekly') {
    const wd = WEEKDAYS[(item.repeat_rule ? parseInt(item.repeat_rule) : new Date(item.remind_at * 1000).getDay()) || 0]
    return '每周' + wd + ' ' + tsToHHmm(item.remind_at)
  }
  if (item.repeat_type === 'interval') {
    const m = (item.repeat_rule || '').match(/^(min|hour):(\d+)$/)
    if (m) return '每 ' + m[2] + (m[1] === 'min' ? ' 分钟' : ' 小时')
    return '间隔'
  }
  if (item.repeat_type === 'cron') return 'cron: ' + (item.repeat_rule || '')
  return ''
}

export default function AutomationPage() {
  const { me } = useAuth()
  const bots = (me?.bots || []) as { id: number; name: string }[]
  const [botId, setBotId] = useState<number | ''>('')
  const [items, setItems] = useState<ReminderItem[]>([])
  const [peers, setPeers] = useState<Peer[]>([])
  const [plugins, setPlugins] = useState<PluginInfo[]>([])
  const [loading, setLoading] = useState(false)

  const [dialogOpen, setDialogOpen] = useState(false)
  const [editingId, setEditingId] = useState<number | null>(null)
  const [form, setForm] = useState<FormState>(emptyForm())
  const [saving, setSaving] = useState(false)

  const [deleteId, setDeleteId] = useState<number | null>(null)

  function emptyForm(): FormState {
    const now = new Date()
    now.setMinutes(now.getMinutes() + 5)
    return {
      content: '',
      repeatType: 'once',
      onceDateTime: tsToDateTimeLocal(Math.floor(now.getTime() / 1000)),
      dailyTime: '09:00',
      weeklyDay: '1',
      weeklyTime: '09:00',
      intervalValue: 5,
      intervalUnit: 'min',
      cronExpr: '*/5 * * * *',
      peerId: '',
      enabled: true,
    }
  }

  // 默认选中第一个机器人
  useEffect(() => {
    if (bots.length && botId === '') setBotId(bots[0].id)
  }, [bots, botId])

  async function load() {
    if (!botId) return
    setLoading(true)
    try {
      const [list, plist, pluginList] = await Promise.all([
        api.post('/api/automation/list', { bot_id: botId }),
        api.post('/api/automation/peers', { bot_id: botId }),
        api.post('/api/automation/plugins', { bot_id: botId }),
      ])
      if (list?.ok) setItems(list.items || [])
      if (plist?.ok) setPeers(plist.peers || [])
      if (pluginList?.ok) setPlugins(pluginList.plugins || [])
    } catch (e: any) {
      toast.error(e.message || '加载失败')
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    load()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [botId])

  const peerMap = useMemo(() => {
    const m: Record<string, string> = {}
    peers.forEach((p) => (m[p.peer_id] = p.last_msg))
    return m
  }, [peers])

  function openNew() {
    setEditingId(null)
    setForm(emptyForm())
    setDialogOpen(true)
  }

  // 从「可调用插件」点选:把插件指令插入到内容(空则直接填入,否则换行追加)
  function insertPluginCommand(cmd: string) {
    if (!cmd) return
    setForm((f) => ({ ...f, content: f.content.trim() ? f.content.trim() + '\n' + cmd : cmd }))
  }

  function openEdit(item: ReminderItem) {
    setEditingId(item.id)
    const f = emptyForm()
    f.content = item.content
    f.repeatType = item.repeat_type
    f.enabled = item.enabled !== 0
    f.peerId = item.peer_id || ''
    if (item.repeat_type === 'once') f.onceDateTime = tsToDateTimeLocal(item.remind_at)
    if (item.repeat_type === 'daily') f.dailyTime = tsToHHmm(item.remind_at)
    if (item.repeat_type === 'weekly') {
      f.weeklyDay = item.repeat_rule || String(new Date(item.remind_at * 1000).getDay())
      f.weeklyTime = tsToHHmm(item.remind_at)
    }
    if (item.repeat_type === 'interval') {
      const m = (item.repeat_rule || '').match(/^(min|hour):(\d+)$/)
      if (m) {
        f.intervalUnit = m[1] as 'min' | 'hour'
        f.intervalValue = parseInt(m[2], 10) || 5
      }
    }
    if (item.repeat_type === 'cron') {
      f.cronExpr = item.repeat_rule || '*/5 * * * *'
    }
    setDialogOpen(true)
  }

  async function save() {
    const content = form.content.trim()
    if (!content) {
      toast.error('请填写任务内容')
      return
    }
    const remindAt = computeRemindAt(form)
    if (!remindAt || remindAt <= 0) {
      toast.error('请设置有效的时间')
      return
    }
    if (form.repeatType === 'once' && remindAt <= Math.floor(Date.now() / 1000)) {
      toast.error('一次性任务的时间需晚于当前时间')
      return
    }
    const repeatRule =
      form.repeatType === 'weekly' ? form.weeklyDay
      : form.repeatType === 'daily' ? form.dailyTime
      : form.repeatType === 'interval' ? `${form.intervalUnit}:${form.intervalValue}`
      : form.repeatType === 'cron' ? form.cronExpr.trim()
      : ''
    const payload: any = {
      bot_id: botId,
      content,
      remind_at: remindAt,
      repeat_type: form.repeatType,
      repeat_rule: repeatRule,
      peer_id: form.peerId,
      enabled: form.enabled ? 1 : 0,
    }
    setSaving(true)
    try {
      if (editingId) {
        payload.id = editingId
        const d = await api.post('/api/automation/update', payload)
        if (!d?.ok) throw new Error(d?.msg || '保存失败')
        toast.success('已更新')
      } else {
        const d = await api.post('/api/automation/add', payload)
        if (!d?.ok) throw new Error(d?.msg || '添加失败')
        toast.success('已添加')
      }
      setDialogOpen(false)
      load()
    } catch (e: any) {
      toast.error(e.message || '保存失败')
    } finally {
      setSaving(false)
    }
  }

  async function toggle(item: ReminderItem, enabled: boolean) {
    try {
      const d = await api.post('/api/automation/toggle', { bot_id: botId, id: item.id, enabled: enabled ? 1 : 0 })
      if (!d?.ok) throw new Error(d?.msg || '操作失败')
      setItems((prev) => prev.map((x) => (x.id === item.id ? { ...x, enabled: enabled ? 1 : 0 } : x)))
      toast.success(enabled ? '已启用' : '已暂停')
    } catch (e: any) {
      toast.error(e.message)
    }
  }

  async function confirmDelete() {
    if (!deleteId) return
    try {
      const d = await api.post('/api/automation/delete', { bot_id: botId, id: deleteId })
      if (!d?.ok) throw new Error(d?.msg || '删除失败')
      toast.success('已删除')
      setItems((prev) => prev.filter((x) => x.id !== deleteId))
    } catch (e: any) {
      toast.error(e.message)
    } finally {
      setDeleteId(null)
    }
  }

  if (!me) {
    return <div className="p-8 text-sm text-muted-foreground">请先登录</div>
  }

  return (
    <div className="mx-auto max-w-6xl space-y-6 p-4 md:p-6">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div>
          <h1 className="flex items-center gap-2 text-xl font-bold">
            <CalendarClock className="h-5 w-5 text-primary" /> 自动化设置
          </h1>
          <p className="mt-1 text-sm text-muted-foreground">管理机器人的定时任务(提醒 / 定时推送)。通过机器人对话创建的定时任务也会显示在这里。</p>
        </div>
        <div className="flex items-center gap-2">
          {/* 一个项目只有一个机器人,选择机器人无意义;仅当存在多个机器人时才显示切换 */}
          {bots.length > 1 && (
            <Select value={String(botId)} onValueChange={(v) => setBotId(Number(v))}>
              <SelectTrigger className="w-44">
                <SelectValue placeholder="选择机器人" />
              </SelectTrigger>
              <SelectContent>
                {bots.map((b) => (
                  <SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>
                ))}
              </SelectContent>
            </Select>
          )}
          <Button onClick={openNew} disabled={!botId}>
            <Plus className="h-4 w-4" /> 新增自动化
          </Button>
        </div>
      </div>

      {bots.length === 0 ? (
        <Card>
          <CardContent className="py-12 text-center text-sm text-muted-foreground">你还没有可用的机器人</CardContent>
        </Card>
      ) : loading ? (
        <div className="space-y-3">
          {[0, 1, 2].map((i) => (
            <div key={i} className="h-20 animate-pulse rounded-lg border bg-muted/30" />
          ))}
        </div>
      ) : items.length === 0 ? (
        <Card>
          <CardContent className="py-12 text-center text-sm text-muted-foreground">
            暂无定时任务。点击右上角「新增自动化」创建,或在机器人对话中发送「提醒 明天下午3点 开会」也会自动出现在这里。
          </CardContent>
        </Card>
      ) : (
        <div className="space-y-3">
          {items.map((item) => (
            <Card key={item.id}>
              <CardContent className="flex items-start gap-3 p-4">
                <div className="min-w-0 flex-1">
                  <div className="flex flex-wrap items-center gap-2">
                    <Badge variant={item.repeat_type === 'once' ? 'secondary' : item.repeat_type === 'daily' ? 'success' : 'warning'}>
                      {item.repeat_type === 'once' ? '一次性'
                        : item.repeat_type === 'daily' ? '每天'
                        : item.repeat_type === 'weekly' ? '每周'
                        : item.repeat_type === 'interval' ? '间隔'
                        : '定时'}
                    </Badge>
                    {item.enabled === 0 && <Badge variant="outline">已暂停</Badge>}
                    <span className="flex items-center gap-1 text-xs text-muted-foreground">
                      <Clock className="h-3 w-3" /> {describe(item)}
                    </span>
                  </div>
                  <p className="mt-2 break-words text-sm">{item.content}</p>
                  <p className="mt-1 text-xs text-muted-foreground">
                    目标:{item.peer_id ? (peerMap[item.peer_id] ? `「${peerMap[item.peer_id]}」` : item.peer_id) : '最近会话'}
                  </p>
                </div>
                <div className="flex shrink-0 flex-col items-end gap-3">
                  <Switch checked={item.enabled !== 0} onCheckedChange={(v) => toggle(item, v)} />
                  <div className="flex gap-1">
                    <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(item)} title="编辑">
                      <Pencil className="h-4 w-4" />
                    </Button>
                    <Button variant="ghost" size="icon" className="h-8 w-8 text-red-600 hover:text-red-600" onClick={() => setDeleteId(item.id)} title="删除">
                      <Trash2 className="h-4 w-4" />
                    </Button>
                  </div>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}

      {/* 新增 / 编辑 弹窗 */}
      <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
        <DialogContent className="max-w-md">
          <DialogHeader>
            <DialogTitle>{editingId ? '编辑自动化' : '新增自动化'}</DialogTitle>
            <DialogDescription>设置后由机器人按时间自动推送内容到指定会话。</DialogDescription>
          </DialogHeader>
          <div className="space-y-4">
            <div className="space-y-1.5">
              <Label>任务内容</Label>
              <Textarea
                value={form.content}
                onChange={(e) => setForm({ ...form, content: e.target.value })}
                placeholder="例如:该喝水了 / 发送每日简报到群"
                rows={3}
              />
              <p className="text-xs text-muted-foreground">定时触发时,内容会作为机器人指令执行,可直接调用已启用插件。</p>
            </div>

            {plugins.length > 0 && (
              <div className="space-y-2">
                <Label className="text-xs text-muted-foreground">可调用插件(点击插入指令)</Label>
                <div className="flex flex-wrap gap-1.5">
                  {plugins.map((p) => (
                    <button
                      key={p.id}
                      type="button"
                      onClick={() => insertPluginCommand(p.command)}
                      disabled={!p.command}
                      title={p.usage}
                      className="rounded-full border px-2.5 py-1 text-xs transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-40"
                    >
                      {p.name}
                    </button>
                  ))}
                </div>
              </div>
            )}

            <div className="space-y-1.5">
              <Label>重复方式</Label>
              <Select value={form.repeatType} onValueChange={(v) => setForm({ ...form, repeatType: v as FormState['repeatType'] })}>
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="once">一次性</SelectItem>
                  <SelectItem value="daily">每天</SelectItem>
                  <SelectItem value="weekly">每周</SelectItem>
                  <SelectItem value="interval">间隔(每 N 分钟/小时)</SelectItem>
                  <SelectItem value="cron">定时(cron 表达式)</SelectItem>
                </SelectContent>
              </Select>
            </div>

            {form.repeatType === 'once' && (
              <div className="space-y-1.5">
                <Label>执行时间</Label>
                <Input type="datetime-local" value={form.onceDateTime} onChange={(e) => setForm({ ...form, onceDateTime: e.target.value })} />
              </div>
            )}
            {form.repeatType === 'daily' && (
              <div className="space-y-1.5">
                <Label>每天时间</Label>
                <Input type="time" value={form.dailyTime} onChange={(e) => setForm({ ...form, dailyTime: e.target.value })} />
              </div>
            )}
            {form.repeatType === 'weekly' && (
              <div className="grid grid-cols-2 gap-3">
                <div className="space-y-1.5">
                  <Label>星期</Label>
                  <Select value={form.weeklyDay} onValueChange={(v) => setForm({ ...form, weeklyDay: v })}>
                    <SelectTrigger>
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      {WEEKDAYS.map((w, i) => (
                        <SelectItem key={i} value={String(i)}>周{WEEKDAYS[i] === '日' ? '日' : WEEKDAYS[i]}</SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>
                <div className="space-y-1.5">
                  <Label>时间</Label>
                  <Input type="time" value={form.weeklyTime} onChange={(e) => setForm({ ...form, weeklyTime: e.target.value })} />
                </div>
              </div>
            )}
            {form.repeatType === 'interval' && (
              <div className="grid grid-cols-2 gap-3">
                <div className="space-y-1.5">
                  <Label>间隔</Label>
                  <Input
                    type="number"
                    min={1}
                    max={10080}
                    value={form.intervalValue}
                    onChange={(e) => setForm({ ...form, intervalValue: Math.max(1, parseInt(e.target.value || '1', 10) || 1) })}
                  />
                </div>
                <div className="space-y-1.5">
                  <Label>单位</Label>
                  <Select value={form.intervalUnit} onValueChange={(v) => setForm({ ...form, intervalUnit: v as 'min' | 'hour' })}>
                    <SelectTrigger>
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value="min">分钟</SelectItem>
                      <SelectItem value="hour">小时</SelectItem>
                    </SelectContent>
                  </Select>
                </div>
              </div>
            )}
            {form.repeatType === 'cron' && (
              <div className="space-y-1.5">
                <Label>cron 表达式(分 时 日 月 周)</Label>
                <Input
                  value={form.cronExpr}
                  onChange={(e) => setForm({ ...form, cronExpr: e.target.value })}
                  placeholder="*/5 * * * *"
                />
                <p className="text-xs text-muted-foreground">
                  标准 5 字段:分 时 日 月 周。例:<code className="rounded bg-muted px-1">*/5 * * * *</code> 每5分钟、
                  <code className="rounded bg-muted px-1">0 9 * * 1-5</code> 工作日9点、
                  <code className="rounded bg-muted px-1">0 0 1 * *</code> 每月1号。支持 , - / 与 ? 通配。
                </p>
              </div>
            )}

            <div className="flex items-center justify-between rounded-lg border p-3">
              <div>
                <p className="text-sm font-medium">启用</p>
                <p className="text-xs text-muted-foreground">关闭后该任务不会触发</p>
              </div>
              <Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
            </div>
          </div>
          <DialogFooter>
            <Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
            <Button onClick={save} disabled={saving}>{saving ? '保存中…' : '保存'}</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>

      {/* 删除确认 */}
      <AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>确认删除</AlertDialogTitle>
            <AlertDialogDescription>删除后该定时任务将不再触发,且无法恢复。</AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>取消</AlertDialogCancel>
            <AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">删除</AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  )
}