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