码桶
发现社区成员的开源项目
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>
)
}