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:
1 parent
217319eabc
commit
9eaed2883f
5 files changed
+478
-73
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user