feat(bridge): separate bridge config from .env, web panel + hot-reload

- BridgeConfigStore: file-backed JSON config (configs/bridge.json)
- Auto-creates config file only when user saves via admin panel
- Hot-reload: watches config file every 5s, reloads adapters live
- Admin API: GET/PUT/DELETE /api/v1/admin/bridge/config/:name
- Frontend: full CRUD page with platform type selector + toggle
- Removed bridge env vars from .env.example
This commit is contained in:
Aska committed 2026-08-12 17:55:15 +08:00
1 parent 217319eabc
commit 9eaed2883f
5 files changed
+478 -73

No files matched your search

+140 -20
View File
@@ -1,39 +1,159 @@
import { useEffect, useState } from 'react'
import { getToken } from '../../api/client'
const platformTypes = [
{ value: 'qq-obv11', label: 'QQ (OBv11)', fields: ['ws_url', 'access_token', 'bot_qq'] },
{ value: 'telegram', label: 'Telegram', fields: ['token'] },
{ value: 'discord', label: 'Discord', fields: ['token'] },
{ value: 'webhook', label: 'Webhook', fields: ['listen_addr'] },
]
const fieldLabels: Record<string, string> = {
ws_url: 'WebSocket 地址', access_token: 'Access Token', bot_qq: 'Bot QQ 号',
token: 'Bot Token', listen_addr: '监听地址',
}
interface Adapter { name:string; type:string; enabled:boolean; settings:Record<string,string>; created_at:string }
export default function BridgePage() {
const adapters = [
{ name: 'qq-main', platform: 'QQ (OBv11)', status: 'ready', desc: '等待环境变量 QQ_OBV11_WS_URL 配置' },
{ name: 'tg-main', platform: 'Telegram', status: 'ready', desc: '等待环境变量 TG_BOT_TOKEN 配置' },
{ name: 'dc-main', platform: 'Discord', status: 'ready', desc: '等待环境变量 DC_BOT_TOKEN 配置' },
{ name: 'webhook-main', platform: 'Webhook', status: 'ready', desc: '等待环境变量 WEBHOOK_LISTEN_ADDR 配置' },
]
const [adapters, setAdapters] = useState<Adapter[]>([])
const [editing, setEditing] = useState(false)
const [name, setName] = useState('')
const [adType, setAdType] = useState('qq-obv11')
const [settings, setSettings] = useState<Record<string,string>>({})
useEffect(() => { fetchAll() }, [])
const fetchAll = async () => {
const token = getToken()
const res = await fetch('/api/v1/admin/bridge/config', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setAdapters(data.adapters || [])
}
const save = async () => {
if (!name.trim()) return
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type: adType, enabled: true, settings }),
})
setEditing(false); setName(''); setSettings({}); fetchAll()
}
const toggle = async (a: Adapter) => {
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${a.name}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type: a.type, enabled: !a.enabled, settings: a.settings }),
})
fetchAll()
}
const remove = async (name: string) => {
const token = getToken()
await fetch(`/api/v1/admin/bridge/config/${name}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } })
fetchAll()
}
const startEdit = (a?: Adapter) => {
if (a) { setName(a.name); setAdType(a.type); setSettings(a.settings || {}) }
else { setName(''); setSettings({}) }
setEditing(true)
}
const fields = platformTypes.find(p => p.value === adType)?.fields || []
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">平台桥接</h2>
<p className="text-xs text-[var(--mx-text-dim)]">多平台消息桥接 — QQ (OBv11) / Telegram / Discord / Webhook。通过环境变量配置启用。</p>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">平台桥接配置</h2>
<button onClick={() => startEdit()}
className="px-4 py-2 rounded-full text-sm font-medium text-white transition-colors"
style={{ background: 'var(--mx-primary)' }}
>+ 添加适配器</button>
</div>
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
配置文件: configs/bridge.json · 支持热重载 · 仅在有适配器时创建
</p>
{/* Adapter list */}
{adapters.length === 0 && !editing && (
<div className="rounded-[18px] border p-8 text-center text-sm" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
暂无平台适配器。点击「添加适配器」开始配置。
</div>
)}
<div className="space-y-2">
{adapters.map(a => (
<div key={a.name} className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] px-4 py-3">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<span className="w-2.5 h-2.5 rounded-full bg-[#F2F2F2]/30" />
<span className="text-sm text-[var(--mx-text)]">{a.name}</span>
<div key={a.name}
className="rounded-[18px] border px-4 py-3 flex items-center justify-between"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
>
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${a.enabled ? '' : ''}`}
style={{ background: a.enabled ? 'var(--mx-tertiary-text)' : 'var(--mx-outline)' }} />
<div>
<p className="text-sm" style={{ color: 'var(--mx-text)' }}>{a.name}</p>
<p className="text-xs" style={{ color: 'var(--mx-text-dim)' }}>
{platformTypes.find(p=>p.value===a.type)?.label || a.type}
{a.created_at && ` · ${a.created_at.slice(0,10)}`}
</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--mx-badge-blue)]/50 text-[var(--mx-tertiary-text)]">{a.platform}</span>
</div>
<p className="text-xs text-[var(--mx-text-dim)] pl-7">{a.desc}</p>
<div className="flex items-center gap-2">
<button onClick={() => toggle(a)}
className={`w-11 h-6 rounded-full relative transition-colors`}
style={{ background: a.enabled ? 'var(--mx-primary)' : 'var(--mx-outline)' }}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform ${a.enabled ? 'translate-x-5.5' : 'translate-x-0.5'}`} />
</button>
<button onClick={() => startEdit(a)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-text-dim)' }}>编辑</button>
<button onClick={() => remove(a.name)} className="text-xs px-2 py-1 rounded-full" style={{ color: 'var(--mx-error)' }}>删除</button>
</div>
</div>
))}
</div>
<div className="bg-[var(--mx-surface)] border border-[var(--mx-outline)] rounded-[18px] p-4 mt-4">
<h3 className="text-sm font-medium text-[var(--mx-text)] mb-2">群聊行为规则</h3>
<ul className="text-xs text-[var(--mx-text-dim)] space-y-1 list-disc list-inside">
{/* Edit panel */}
{editing && (
<div className="rounded-[18px] border p-4 space-y-3" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
<h3 className="text-sm font-medium" style={{ color: 'var(--mx-text)' }}>{name ? '编辑适配器' : '新建适配器'}</h3>
<div className="flex gap-2">
<input value={name} onChange={e => setName(e.target.value)} placeholder="名称 (如 qq-main)"
className="flex-1 rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} />
<select value={adType} onChange={e => setAdType(e.target.value)}
className="rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }}>
{platformTypes.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
{fields.map(f => (
<input key={f} value={settings[f] || ''} onChange={e => setSettings({...settings, [f]: e.target.value})}
placeholder={fieldLabels[f] || f}
className="w-full rounded-[14px] border px-3 py-2 text-sm outline-none"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text)' }} />
))}
<div className="flex gap-2">
<button onClick={save} className="px-4 py-2 rounded-full text-sm font-medium text-white" style={{ background: 'var(--mx-primary)' }}>保存</button>
<button onClick={() => setEditing(false)} className="px-4 py-2 rounded-full text-sm" style={{ border: '1px solid var(--mx-outline)', color: 'var(--mx-text-dim)' }}>取消</button>
</div>
</div>
)}
{/* Group chat rules */}
<div className="rounded-[18px] border p-4 mt-4" style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}>
<h3 className="text-sm font-medium mb-2" style={{ color: 'var(--mx-text)' }}>群聊行为规则</h3>
<ul className="text-xs space-y-1 list-disc list-inside" style={{ color: 'var(--mx-text-dim)' }}>
<li>消息最大 500 字,超出截断</li>
<li>最短发送间隔 3 秒</li>
<li>每轮最多 2 条消息</li>
<li>非 @ 消息走审查模式(LLM 自主判断是否回复)</li>
<li>闭嘴关键词:闭嘴 / 别说话 / 别说了 / 安静</li>
<li>非 @ 消息走审查模式</li>
<li>闭嘴关键词:闭嘴 / 别说话 / 安静</li>
</ul>
</div>
</div>