mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-24 01:39:58 +08:00
feat(channels): add messaging channels and built-in qq bot
This commit is contained in:
@@ -137,6 +137,50 @@ export function showModal({ title, fields, onConfirm }) {
|
||||
if (firstInput) firstInput.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用内容弹窗 — 支持自定义 HTML 和按钮
|
||||
* @param {{ title, content, buttons, width }} opts
|
||||
* buttons: [{ label, className, id }]
|
||||
* @returns {HTMLElement} overlay 元素(带 .close() 方法)
|
||||
*/
|
||||
export function showContentModal({ title, content, buttons = [], width = 480 }) {
|
||||
const overlay = document.createElement('div')
|
||||
overlay.className = 'modal-overlay'
|
||||
|
||||
const btnsHtml = buttons.map(b =>
|
||||
`<button class="${b.className || 'btn btn-primary btn-sm'}" id="${b.id || ''}">${b.label}</button>`
|
||||
).join('')
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="max-width:${width}px">
|
||||
<div class="modal-title">${title}</div>
|
||||
<div class="modal-content-body">${content}</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
${btnsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
overlay.close = () => overlay.remove()
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove()
|
||||
})
|
||||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') overlay.remove()
|
||||
})
|
||||
|
||||
// 自动聚焦第一个输入框或按钮
|
||||
const firstInput = overlay.querySelector('input, textarea, select')
|
||||
if (firstInput) firstInput.focus()
|
||||
|
||||
return overlay
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级进度弹窗 — 带进度条和实时日志
|
||||
* @returns {{ appendLog, setProgress, setDone, setError, destroy }}
|
||||
|
||||
@@ -25,6 +25,7 @@ const NAV_ITEMS_FULL = [
|
||||
{ route: '/models', label: '模型配置', icon: 'models' },
|
||||
{ route: '/agents', label: 'Agent 管理', icon: 'agents' },
|
||||
{ route: '/gateway', label: 'Gateway', icon: 'gateway' },
|
||||
{ route: '/channels', label: '消息渠道', icon: 'channels' },
|
||||
{ route: '/security', label: '安全设置', icon: 'security' },
|
||||
]
|
||||
},
|
||||
@@ -88,6 +89,7 @@ const ICONS = {
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
|
||||
docker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="11" width="4" height="3" rx=".5"/><rect x="6" y="11" width="4" height="3" rx=".5"/><rect x="11" y="11" width="4" height="3" rx=".5"/><rect x="6" y="7" width="4" height="3" rx=".5"/><rect x="11" y="7" width="4" height="3" rx=".5"/><rect x="16" y="11" width="4" height="3" rx=".5"/><rect x="11" y="3" width="4" height="3" rx=".5"/><path d="M2 17c1 3 4 5 10 5s9-2 10-5"/></svg>',
|
||||
channels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
|
||||
debug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ const PATHS = {
|
||||
'tent': '<path d="M19 20L12 4 5 20"/><path d="M3 20h18"/><path d="M12 4v16"/>',
|
||||
'scroll': '<path d="M8 21h12a2 2 0 002-2v-2H10v2a2 2 0 11-4 0V5a2 2 0 012-2h12v16"/>',
|
||||
'play': '<polygon points="5 3 19 12 5 21 5 3"/>',
|
||||
'pause': '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>',
|
||||
'alert-circle': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
|
||||
'message-circle': '<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
|
||||
'stop': '<rect x="6" y="6" width="12" height="12" rx="1"/>',
|
||||
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>',
|
||||
'rocket': '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
|
||||
|
||||
@@ -192,6 +192,15 @@ export const api = {
|
||||
deleteMemoryFile: (path, agentId) => { invalidate('list_memory_files'); return invoke('delete_memory_file', { path, agentId: agentId || null }) },
|
||||
exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }),
|
||||
|
||||
// 消息渠道管理
|
||||
readPlatformConfig: (platform) => invoke('read_platform_config', { platform }),
|
||||
saveMessagingPlatform: (platform, form) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form }) },
|
||||
removeMessagingPlatform: (platform) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('remove_messaging_platform', { platform }) },
|
||||
toggleMessagingPlatform: (platform, enabled) => { invalidate('list_configured_platforms'); return invoke('toggle_messaging_platform', { platform, enabled }) },
|
||||
verifyBotToken: (platform, form) => invoke('verify_bot_token', { platform, form }),
|
||||
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
|
||||
installQqbotPlugin: () => invoke('install_qqbot_plugin'),
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
readPanelConfig: () => invoke('read_panel_config'),
|
||||
writePanelConfig: (config) => invoke('write_panel_config', { config }),
|
||||
|
||||
@@ -303,6 +303,7 @@ async function boot() {
|
||||
registerRoute('/assistant', () => import('./pages/assistant.js'))
|
||||
registerRoute('/setup', () => import('./pages/setup.js'))
|
||||
registerRoute('/docker', () => import('./pages/docker.js'))
|
||||
registerRoute('/channels', () => import('./pages/channels.js'))
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
|
||||
405
src/pages/channels.js
Normal file
405
src/pages/channels.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* 消息渠道管理
|
||||
* 配置 Telegram / Discord 等外部消息接入,凭证校验后写入 openclaw.json
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showContentModal, showConfirm } from '../components/modal.js'
|
||||
import { icon } from '../lib/icons.js'
|
||||
|
||||
// ── 渠道注册表:定义每个支持的消息渠道的元数据和表单规格 ──
|
||||
|
||||
const PLATFORM_REGISTRY = {
|
||||
qqbot: {
|
||||
label: 'QQ 机器人',
|
||||
iconName: 'message-square',
|
||||
desc: '内置 QQ 机器人接入能力,通过 QQ 开放平台快速启用',
|
||||
guide: [
|
||||
'使用手机 QQ 扫描二维码,<a href="https://q.qq.com/qqbot/openclaw/login.html" target="_blank" style="color:var(--accent);text-decoration:underline">打开 QQ 机器人开放平台</a> 完成注册登录',
|
||||
'点击「创建机器人」,设置机器人名称和头像',
|
||||
'创建完成后,在机器人详情页复制 <b>AppID</b> 和 <b>AppSecret</b>(AppSecret 仅显示一次,请妥善保存)',
|
||||
'将 AppID 和 AppSecret 填入下方表单,点击「校验凭证」验证后保存',
|
||||
'ClawPanel 会自动安装 QQBot 社区插件并写入配置,保存后 Gateway 自动重载生效',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">详细教程:<a href="https://cloud.tencent.com/developer/article/2626045" target="_blank" style="color:var(--accent);text-decoration:underline">腾讯云 - 快速搭建 AI 私人 QQ 助理</a></div>',
|
||||
fields: [
|
||||
{ key: 'appId', label: 'AppID', placeholder: '如 1903224859', required: true },
|
||||
{ key: 'appSecret', label: 'AppSecret', placeholder: '如 cisldqspngYlyPdc', secret: true, required: true },
|
||||
],
|
||||
pluginRequired: '@sliverp/qqbot@latest',
|
||||
},
|
||||
telegram: {
|
||||
label: 'Telegram',
|
||||
iconName: 'send',
|
||||
desc: '通过 BotFather 创建机器人,用 Bot Token 接入',
|
||||
guide: [
|
||||
'在 Telegram 中搜索 <a href="https://t.me/BotFather" target="_blank" style="color:var(--accent);text-decoration:underline">@BotFather</a>,发送 <b>/newbot</b> 创建机器人',
|
||||
'按提示设置机器人名称和用户名,成功后 BotFather 会返回 <b>Bot Token</b>',
|
||||
'获取你的 Telegram 用户 ID:发送消息给 <a href="https://t.me/userinfobot" target="_blank" style="color:var(--accent);text-decoration:underline">@userinfobot</a> 即可查看',
|
||||
'将 Bot Token 和用户 ID 填入下方表单,点击「校验凭证」验证后保存',
|
||||
],
|
||||
fields: [
|
||||
{ key: 'botToken', label: 'Bot Token', placeholder: '123456:ABC-DEF...', secret: true, required: true },
|
||||
{ key: 'allowedUsers', label: '允许的用户 ID', placeholder: '多个用逗号分隔,如 12345, 67890', required: true },
|
||||
],
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
iconName: 'message-circle',
|
||||
desc: '通过 Discord Developer Portal 创建 Bot 应用接入',
|
||||
guide: [
|
||||
'前往 <a href="https://discord.com/developers/applications" target="_blank" style="color:var(--accent);text-decoration:underline">Discord Developer Portal</a>,点击 New Application 创建应用',
|
||||
'进入应用 → 左侧 <b>Bot</b> 页面 → 点击 Reset Token 生成 Bot Token,并开启 <b>Message Content Intent</b>',
|
||||
'左侧 <b>OAuth2</b> → URL Generator,勾选 bot 权限,复制链接将 Bot 邀请到你的服务器',
|
||||
'将 Bot Token 和服务器 ID 填入下方表单,点击「校验凭证」验证后保存',
|
||||
],
|
||||
fields: [
|
||||
{ key: 'token', label: 'Bot Token', placeholder: 'MTIz...', secret: true, required: true },
|
||||
{ key: 'guildId', label: '服务器 ID', placeholder: '右键服务器 → 复制服务器 ID', required: false },
|
||||
{ key: 'channelId', label: '频道 ID(可选)', placeholder: '不填则监听所有频道', required: false },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── 页面生命周期 ──
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">消息渠道</h1>
|
||||
<p class="page-desc">内置 QQ 机器人,并支持 Telegram、Discord 等外部消息渠道接入</p>
|
||||
</div>
|
||||
<div id="platforms-configured" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">可接入平台</div>
|
||||
<div id="platforms-available" class="platforms-grid"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { configured: [] }
|
||||
await loadPlatforms(page, state)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
export function cleanup() {}
|
||||
|
||||
// ── 数据加载 ──
|
||||
|
||||
async function loadPlatforms(page, state) {
|
||||
try {
|
||||
const list = await api.listConfiguredPlatforms()
|
||||
state.configured = Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
toast('加载平台列表失败: ' + e, 'error')
|
||||
state.configured = []
|
||||
}
|
||||
renderConfigured(page, state)
|
||||
renderAvailable(page, state)
|
||||
}
|
||||
|
||||
// ── 已配置平台渲染 ──
|
||||
|
||||
function renderConfigured(page, state) {
|
||||
const el = page.querySelector('#platforms-configured')
|
||||
if (!state.configured.length) {
|
||||
el.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">已接入</div>
|
||||
<div class="platforms-grid">
|
||||
${state.configured.map(p => {
|
||||
const reg = PLATFORM_REGISTRY[p.id]
|
||||
const label = reg?.label || p.id
|
||||
const ic = icon(reg?.iconName || 'radio', 22)
|
||||
return `
|
||||
<div class="platform-card ${p.enabled ? 'active' : 'inactive'}" data-pid="${p.id}">
|
||||
<div class="platform-card-header">
|
||||
<span class="platform-emoji">${ic}</span>
|
||||
<span class="platform-name">${label}</span>
|
||||
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
|
||||
</div>
|
||||
<div class="platform-card-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)} 编辑</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">${p.enabled ? icon('pause', 14) + ' 禁用' : icon('play', 14) + ' 启用'}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="remove">${icon('trash', 14)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 绑定事件
|
||||
el.querySelectorAll('.platform-card').forEach(card => {
|
||||
const pid = card.dataset.pid
|
||||
card.querySelector('[data-action="edit"]').onclick = () => openConfigDialog(pid, page, state)
|
||||
card.querySelector('[data-action="toggle"]').onclick = async () => {
|
||||
const cur = state.configured.find(p => p.id === pid)
|
||||
if (!cur) return
|
||||
try {
|
||||
await api.toggleMessagingPlatform(pid, !cur.enabled)
|
||||
toast(`${PLATFORM_REGISTRY[pid]?.label || pid} 已${cur.enabled ? '禁用' : '启用'}`, 'success')
|
||||
await loadPlatforms(page, state)
|
||||
} catch (e) { toast('操作失败: ' + e, 'error') }
|
||||
}
|
||||
card.querySelector('[data-action="remove"]').onclick = async () => {
|
||||
const yes = await showConfirm(`确定移除 ${PLATFORM_REGISTRY[pid]?.label || pid}?配置将被删除。`)
|
||||
if (!yes) return
|
||||
try {
|
||||
await api.removeMessagingPlatform(pid)
|
||||
toast('已移除', 'info')
|
||||
await loadPlatforms(page, state)
|
||||
} catch (e) { toast('移除失败: ' + e, 'error') }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 可接入平台渲染 ──
|
||||
|
||||
function renderAvailable(page, state) {
|
||||
const el = page.querySelector('#platforms-available')
|
||||
const configuredIds = new Set(state.configured.map(p => p.id))
|
||||
|
||||
el.innerHTML = Object.entries(PLATFORM_REGISTRY).map(([pid, reg]) => {
|
||||
const done = configuredIds.has(pid)
|
||||
return `
|
||||
<button class="platform-pick ${done ? 'configured' : ''}" data-pid="${pid}">
|
||||
<span class="platform-emoji">${icon(reg.iconName, 28)}</span>
|
||||
<span class="platform-pick-name">${reg.label}</span>
|
||||
<span class="platform-pick-desc">${reg.desc}</span>
|
||||
${done ? `<span class="platform-pick-badge">已接入</span>` : ''}
|
||||
</button>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
el.querySelectorAll('.platform-pick').forEach(btn => {
|
||||
btn.onclick = () => openConfigDialog(btn.dataset.pid, page, state)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 配置弹窗(新增 / 编辑共用) ──
|
||||
|
||||
async function openConfigDialog(pid, page, state) {
|
||||
const reg = PLATFORM_REGISTRY[pid]
|
||||
if (!reg) { toast('未知平台', 'error'); return }
|
||||
|
||||
// 尝试加载已有配置
|
||||
let existing = {}
|
||||
let isEdit = false
|
||||
try {
|
||||
const res = await api.readPlatformConfig(pid)
|
||||
if (res?.exists && res.values) {
|
||||
existing = res.values
|
||||
isEdit = true
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const formId = 'platform-form-' + Date.now()
|
||||
|
||||
const fieldsHtml = reg.fields.map((f, i) => {
|
||||
const val = existing[f.key] || ''
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="form-label">${f.label}${f.required ? ' *' : ''}</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input class="form-input" name="${f.key}" type="${f.secret ? 'password' : 'text'}"
|
||||
value="${escapeAttr(val)}" placeholder="${f.placeholder || ''}"
|
||||
${i === 0 ? 'autofocus' : ''} style="flex:1">
|
||||
${f.secret ? `<button type="button" class="btn btn-sm btn-secondary toggle-vis" data-field="${f.key}">显示</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
const guideHtml = reg.guide?.length ? `
|
||||
<div style="background:var(--bg-tertiary);padding:12px 16px;border-radius:var(--radius-md);margin-bottom:var(--space-md)">
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm);margin-bottom:6px">接入步骤</div>
|
||||
<ol style="margin:0;padding-left:20px;font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.8">
|
||||
${reg.guide.map(s => `<li>${s}</li>`).join('')}
|
||||
</ol>
|
||||
${reg.guideFooter || ''}
|
||||
</div>
|
||||
` : ''
|
||||
|
||||
const content = `
|
||||
${guideHtml}
|
||||
${isEdit ? `<div style="background:var(--accent-muted);color:var(--accent);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">当前已有配置,修改后点击保存即可覆盖</div>` : ''}
|
||||
<form id="${formId}">
|
||||
${fieldsHtml}
|
||||
</form>
|
||||
<div id="verify-result" style="margin-top:var(--space-sm)"></div>
|
||||
`
|
||||
|
||||
const modal = showContentModal({
|
||||
title: `${isEdit ? '编辑' : '接入'} ${reg.label}`,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: '校验凭证', className: 'btn btn-secondary', id: 'btn-verify' },
|
||||
{ label: isEdit ? '保存' : '接入并保存', className: 'btn btn-primary', id: 'btn-save' },
|
||||
],
|
||||
width: 520,
|
||||
})
|
||||
|
||||
// 外部链接用系统浏览器打开
|
||||
modal.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('a[href]')
|
||||
if (!a) return
|
||||
const href = a.getAttribute('href')
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
e.preventDefault()
|
||||
import('@tauri-apps/plugin-shell').then(({ open }) => open(href)).catch(() => window.open(href, '_blank'))
|
||||
}
|
||||
})
|
||||
|
||||
// 密码显隐
|
||||
modal.querySelectorAll('.toggle-vis').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const input = modal.querySelector(`input[name="${btn.dataset.field}"]`)
|
||||
if (!input) return
|
||||
const show = input.type === 'password'
|
||||
input.type = show ? 'text' : 'password'
|
||||
btn.textContent = show ? '隐藏' : '显示'
|
||||
}
|
||||
})
|
||||
|
||||
// 收集表单值
|
||||
const collectForm = () => {
|
||||
const obj = {}
|
||||
reg.fields.forEach(f => {
|
||||
const el = modal.querySelector(`input[name="${f.key}"]`)
|
||||
if (el) obj[f.key] = el.value.trim()
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
// 校验按钮
|
||||
const btnVerify = modal.querySelector('#btn-verify')
|
||||
const btnSave = modal.querySelector('#btn-save')
|
||||
const resultEl = modal.querySelector('#verify-result')
|
||||
|
||||
btnVerify.onclick = async () => {
|
||||
const form = collectForm()
|
||||
// 前端基础检查
|
||||
for (const f of reg.fields) {
|
||||
if (f.required && !form[f.key]) {
|
||||
toast(`请填写「${f.label}」`, 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
btnVerify.disabled = true
|
||||
btnVerify.textContent = '校验中...'
|
||||
resultEl.innerHTML = ''
|
||||
try {
|
||||
const res = await api.verifyBotToken(pid, form)
|
||||
if (res.valid) {
|
||||
const details = (res.details || []).join(' · ')
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--success-muted);color:var(--success);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('check', 14)} 凭证有效${details ? ' — ' + details : ''}
|
||||
</div>`
|
||||
} else {
|
||||
const errs = (res.errors || ['校验失败']).join('<br>')
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--error-muted, #fee2e2);color:var(--error);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('x', 14)} ${errs}
|
||||
</div>`
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">校验请求失败: ${e}</div>`
|
||||
} finally {
|
||||
btnVerify.disabled = false
|
||||
btnVerify.textContent = '校验凭证'
|
||||
}
|
||||
}
|
||||
|
||||
// 保存按钮
|
||||
btnSave.onclick = async () => {
|
||||
const form = collectForm()
|
||||
for (const f of reg.fields) {
|
||||
if (f.required && !form[f.key]) {
|
||||
toast(`请填写「${f.label}」`, 'warning')
|
||||
return
|
||||
}
|
||||
}
|
||||
btnSave.disabled = true
|
||||
btnVerify.disabled = true
|
||||
btnSave.textContent = '保存中...'
|
||||
|
||||
try {
|
||||
// 如果需要安装插件,先安装并显示日志
|
||||
if (reg.pluginRequired) {
|
||||
btnSave.textContent = '安装插件中...'
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${icon('download', 14)}
|
||||
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
|
||||
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
|
||||
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
`
|
||||
const logBox = resultEl.querySelector('#plugin-log-box')
|
||||
const progressBar = resultEl.querySelector('#plugin-progress-bar')
|
||||
const progressText = resultEl.querySelector('#plugin-progress-text')
|
||||
|
||||
// 监听 Tauri 事件
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('plugin-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
unlistenProgress = await listen('plugin-progress', (e) => {
|
||||
const pct = e.payload
|
||||
progressBar.style.width = pct + '%'
|
||||
progressText.textContent = pct + '%'
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await api.installQqbotPlugin()
|
||||
} catch (e) {
|
||||
toast('插件安装失败: ' + e, 'error')
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
return
|
||||
}
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
}
|
||||
|
||||
// 写入配置
|
||||
btnSave.textContent = '写入配置...'
|
||||
await api.saveMessagingPlatform(pid, form)
|
||||
toast(`${reg.label} 配置已保存,Gateway 正在重载`, 'success')
|
||||
modal.close?.() || modal.remove?.()
|
||||
await loadPlatforms(page, state)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
} finally {
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
@@ -2248,3 +2248,155 @@ details.docker-other-section[open] > .docker-other-toggle::before {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── 消息渠道管理 ── */
|
||||
|
||||
.platforms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.platform-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
.platform-card.active {
|
||||
border-left: 3px solid var(--success, #22c55e);
|
||||
}
|
||||
.platform-card.inactive {
|
||||
border-left: 3px solid var(--text-tertiary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.platform-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.platform-emoji {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
.platform-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
flex: 1;
|
||||
}
|
||||
.platform-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.platform-status-dot.on {
|
||||
background: var(--success, #22c55e);
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
.platform-status-dot.off {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.platform-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 可接入平台选择按钮 */
|
||||
.platform-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.platform-pick:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.platform-pick.configured {
|
||||
border-color: var(--success, #22c55e);
|
||||
background: rgba(34, 197, 94, 0.04);
|
||||
}
|
||||
.platform-pick-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
.platform-pick-desc {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.platform-pick-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--success, #22c55e);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* 表单开关 */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 22px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: var(--accent);
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* 加载占位 */
|
||||
.loading-placeholder {
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--bg-tertiary) 50%, var(--bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user