feat(channels): add messaging channels and built-in qq bot

This commit is contained in:
晴天
2026-03-10 03:33:21 +08:00
parent 9ed58af426
commit a2f8ed9d54
19 changed files with 1248 additions and 21 deletions

View File

@@ -5,6 +5,19 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [0.7.2] - 2026-03-10
### 新功能 (Features)
- **消息渠道管理** — 新增独立「消息渠道」页面,支持在面板内集中管理外部消息接入
- **内置 QQ 机器人接入** — 支持直接配置 QQ 机器人,并内置 QQBot 社区插件安装流程
- **Telegram / Discord 渠道配置** — 支持凭证填写、在线校验、保存后自动重载 Gateway 生效
### 改进 (Improvements)
- **版本号同步到 0.7.2** — 官网下载区、桌面端版本信息和构建配置统一升级到 0.7.2
- **渠道体验优化** — 本轮对外聚焦消息渠道能力,突出内置 QQ 机器人与统一接入体验
## [0.7.0] - 2026-03-08
### 新功能 (Features)

View File

@@ -149,6 +149,7 @@ docker run -d --name clawpanel --restart unless-stopped \
- **服务管理** — OpenClaw 启停控制、版本检测与一键升级、Gateway 安装/卸载、配置备份与还原
- **模型配置** — 多服务商管理、模型增删改查、批量连通性测试、延迟检测、拖拽排序、自动保存+撤销
- **网关配置** — 端口、运行模式(本地/云端)、访问权限(本机/局域网)、认证 Token、Tailscale 组网
- **消息渠道** — 统一管理 Telegram、Discord 等外部消息接入,内置 QQ 机器人配置与凭证校验
- **Agent 管理** — Agent 增删改查、身份编辑、模型配置、工作区管理
- **聊天** — 流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令
- **日志查看** — 多日志源实时查看与关键词搜索

View File

@@ -4,13 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClawPanel - OpenClaw AI Agent 可视化管理面板 | 快速搭建、配置、监控你的 AI 智能体</title>
<meta name="description" content="ClawPanel 是 OpenClaw AI Agent 框架的可视化管理面板,基于 Tauri v2 构建的跨平台桌面应用。内置 AI 助手支持工具调用(终端执行、文件读写、目录浏览),让 AI 帮你诊断和修复 OpenClaw 配置。支持仪表盘监控、多模型配置OpenAI/DeepSeek/Kimi/Anthropic、实时 AI 聊天、记忆管理、Agent 管理、网关配置、服务管控、日志查看、内网穿透、系统诊断。一键安装 OpenClaw快速搭建你的私有 AI Agent开源免费支持 Windows/macOS/Linux。">
<meta name="keywords" content="ClawPanel, OpenClaw, AI Agent, AI 智能体, 管理面板, 可视化管理, 快速搭建, 一键安装, 桌面应用, 跨平台, Tauri, Tauri v2, Rust, 开源, 免费, LLM, 大语言模型, 多模型, 模型配置, OpenAI, DeepSeek, Kimi, Anthropic, Claude, 实时聊天, AI 对话, 流式响应, 记忆管理, Agent 管理, 多 Agent, 网关配置, Gateway, 服务管理, 日志查看, 内网穿透, Cloudflare Tunnel, 系统诊断, WebSocket, 仪表盘, 监控, 配置管理, 私有部署, 本地部署, 自托管, AI 工具, AI 平台, 智能体平台, 人工智能, 深度学习, 自然语言处理, NLP, 模型调度, 模型切换, 备选模型, 开箱即用, 零代码, 低代码, admin panel, dashboard, open source AI, self-hosted AI, local AI, AI management">
<meta name="description" content="ClawPanel 是 OpenClaw AI Agent 框架的可视化管理面板,基于 Tauri v2 构建的跨平台桌面应用。内置 AI 助手支持工具调用(终端执行、文件读写、目录浏览),让 AI 帮你诊断和修复 OpenClaw 配置。支持仪表盘监控、多模型配置OpenAI/DeepSeek/Kimi/Anthropic消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、服务管控、日志查看、内网穿透、系统诊断。一键安装 OpenClaw快速搭建你的私有 AI Agent开源免费支持 Windows/macOS/Linux。">
<meta name="keywords" content="ClawPanel, OpenClaw, AI Agent, AI 智能体, 管理面板, 可视化管理, 快速搭建, 一键安装, 桌面应用, 跨平台, Tauri, Tauri v2, Rust, 开源, 免费, LLM, 大语言模型, 多模型, 模型配置, OpenAI, DeepSeek, Kimi, Anthropic, Claude, 消息渠道, QQ 机器人, Telegram, Discord, 实时聊天, AI 对话, 流式响应, 记忆管理, Agent 管理, 多 Agent, 网关配置, Gateway, 服务管理, 日志查看, 内网穿透, Cloudflare Tunnel, 系统诊断, WebSocket, 仪表盘, 监控, 配置管理, 私有部署, 本地部署, 自托管, AI 工具, AI 平台, 智能体平台, 人工智能, 深度学习, 自然语言处理, NLP, 模型调度, 模型切换, 备选模型, 开箱即用, 零代码, 低代码, admin panel, dashboard, open source AI, self-hosted AI, local AI, AI management">
<meta name="author" content="晴辰云 QingchenCloud">
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large">
<link rel="canonical" href="https://claw.qt.cool/">
<meta property="og:title" content="ClawPanel - OpenClaw AI Agent 可视化管理面板 | 快速搭建你的 AI 智能体">
<meta property="og:description" content="基于 Tauri v2 的跨平台桌面应用,为 OpenClaw AI Agent 提供可视化管理。内置 AI 助手支持工具调用,让 AI 帮你诊断修复配置。支持多模型配置、实时聊天、记忆管理等 10+ 功能模块。开源免费。">
<meta property="og:description" content="基于 Tauri v2 的跨平台桌面应用,为 OpenClaw AI Agent 提供可视化管理。内置 AI 助手支持工具调用,新增消息渠道管理与内置 QQ 机器人接入。开源免费。">
<meta property="og:type" content="website">
<meta property="og:url" content="https://claw.qt.cool/">
<meta property="og:site_name" content="ClawPanel">
@@ -21,7 +21,7 @@
<meta property="og:image:alt" content="ClawPanel 仪表盘">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ClawPanel - OpenClaw AI Agent 可视化管理面板">
<meta name="twitter:description" content="基于 Tauri v2 的跨平台桌面应用。多模型配置实时 AI 聊天、Agent 管理、内网穿透,一站式管理你的 AI 智能体。">
<meta name="twitter:description" content="基于 Tauri v2 的跨平台桌面应用。支持消息渠道管理、内置 QQ 机器人、多模型配置实时 AI 聊天。">
<meta name="twitter:image" content="https://claw.qt.cool/00.png">
<link rel="icon" href="./logo.png" type="image/png">
<script type="application/ld+json">
@@ -31,10 +31,10 @@
"name": "ClawPanel",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, macOS, Linux",
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
"url": "https://claw.qt.cool/",
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
"softwareVersion": "0.7.1",
"softwareVersion": "0.7.2",
"author": {
"@type": "Organization",
"name": "晴辰云 QingchenCloud",
@@ -601,7 +601,7 @@
<div class="hero-inner">
<div class="reveal hero-badge"><span class="pulse"></span> 🤖 内置 AI 助手 — 一键安装、配置、诊断、修复 OpenClaw</div>
<h1 class="reveal hero-title"><span class="gradient-text shimmer">AI 助手</span>驱动的<br>OpenClaw 管理面板</h1>
<p class="reveal hero-subtitle">内置智能 AI 助手,帮你<strong>一键安装 OpenClaw</strong>、自动诊断配置、排查问题、修复错误。<br>8 大工具 + 4 种模式 + 交互式问答,从新手到老手都能轻松上手</p>
<p class="reveal hero-subtitle">内置智能 AI 助手,帮你<strong>一键安装 OpenClaw</strong>、自动诊断配置、排查问题、修复错误。<br>新增消息渠道管理,内置 QQ 机器人,并支持 Telegram、Discord 等外部渠道接入</p>
<div class="reveal hero-cta">
<a href="#download" class="btn btn-primary">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 12l-4-4m4 4l4-4M2 17l.621 2.485A2 2 0 004.561 21h14.878a2 2 0 001.94-1.515L22 17"/></svg>
@@ -1124,7 +1124,7 @@
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
<div class="container-sm" style="position:relative;z-index:10">
<div class="section-header">
<div class="reveal download-version"><span class="pulse"></span> v0.7.1 最新版</div>
<div class="reveal download-version"><span class="pulse"></span> v0.7.2 最新版</div>
<h2 class="reveal section-title"><span class="gradient-text">下载安装</span></h2>
<p class="reveal section-desc">选择你的操作系统,一键下载安装</p>
</div>
@@ -1134,11 +1134,11 @@
<h3>macOS</h3>
<p class="dl-desc">支持 Apple Silicon 和 Intel 芯片</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_aarch64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_aarch64.dmg" target="_blank" rel="noopener">
Apple Silicon (M1/M2/M3/M4)
<span class="dl-format">.dmg</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_x64.dmg" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_x64.dmg" target="_blank" rel="noopener">
Intel 芯片
<span class="dl-format">.dmg</span>
</a>
@@ -1156,11 +1156,11 @@
<h3>Windows</h3>
<p class="dl-desc">支持 Windows 10 及以上版本</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_x64-setup.exe" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_x64-setup.exe" target="_blank" rel="noopener">
安装程序
<span class="dl-format">.exe</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_x64_en-US.msi" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_x64_en-US.msi" target="_blank" rel="noopener">
MSI 安装包
<span class="dl-format">.msi</span>
</a>
@@ -1171,11 +1171,11 @@
<h3>Linux</h3>
<p class="dl-desc">支持主流 Linux 发行版</p>
<div class="dl-links">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_amd64.AppImage" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_amd64.AppImage" target="_blank" rel="noopener">
通用版
<span class="dl-format">.AppImage</span>
</a>
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.1_amd64.deb" target="_blank" rel="noopener">
<a class="dl-link" href="https://github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.7.2_amd64.deb" target="_blank" rel="noopener">
Debian / Ubuntu
<span class="dl-format">.deb</span>
</a>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "clawpanel",
"version": "0.7.1",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clawpanel",
"version": "0.7.1",
"version": "0.7.2",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.5.0",

View File

@@ -1,6 +1,6 @@
{
"name": "clawpanel",
"version": "0.7.1",
"version": "0.7.2",
"private": true,
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
"type": "module",

2
src-tauri/Cargo.lock generated
View File

@@ -328,7 +328,7 @@ dependencies = [
[[package]]
name = "clawpanel"
version = "0.7.1"
version = "0.7.2"
dependencies = [
"base64 0.22.1",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "clawpanel"
version = "0.7.1"
version = "0.7.2"
edition = "2021"
description = "ClawPanel - OpenClaw 可视化管理面板"
authors = ["qingchencloud"]

View File

@@ -121,6 +121,22 @@ pub fn read_openclaw_config() -> Result<Value, String> {
Ok(config)
}
/// 供其他模块复用:读取 openclaw.json 为 JSON Value
pub fn load_openclaw_json() -> Result<Value, String> {
read_openclaw_config()
}
/// 供其他模块复用:将 JSON Value 写回 openclaw.json含备份和清理
pub fn save_openclaw_json(config: &Value) -> Result<(), String> {
write_openclaw_config(config.clone())
}
/// 供其他模块复用:触发 Gateway 重载
pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result<String, String> {
let _ = app; // 预留扩展用
reload_gateway().await
}
#[tauri::command]
pub fn write_openclaw_config(config: Value) -> Result<(), String> {
let path = super::openclaw_dir().join("openclaw.json");

View File

@@ -0,0 +1,570 @@
/// 消息渠道管理
/// 负责 Telegram / Discord / QQ Bot 等消息渠道的配置持久化与凭证校验
/// 配置写入 openclaw.json 的 channels / plugins 节点
use serde_json::{json, Map, Value};
/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值)
#[tauri::command]
pub async fn read_platform_config(platform: String) -> Result<Value, String> {
let cfg = super::config::load_openclaw_json()?;
// 从已有配置中提取用户可编辑字段
let saved = cfg
.get("channels")
.and_then(|c| c.get(&platform))
.cloned()
.unwrap_or(Value::Null);
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
let mut form = Map::new();
match platform.as_str() {
"discord" => {
// Discord 配置在 openclaw.json 中是展开的 guilds 结构
// 需要反向提取成表单字段token, guildId, channelId
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
form.insert("token".into(), Value::String(t.into()));
}
if let Some(guilds) = saved.get("guilds").and_then(|v| v.as_object()) {
if let Some(gid) = guilds.keys().next() {
form.insert("guildId".into(), Value::String(gid.clone()));
if let Some(channels) = guilds[gid].get("channels").and_then(|v| v.as_object())
{
let cids: Vec<&String> =
channels.keys().filter(|k| k.as_str() != "*").collect();
if let Some(cid) = cids.first() {
form.insert("channelId".into(), Value::String((*cid).clone()));
}
}
}
}
}
"telegram" => {
// Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串
if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) {
form.insert("botToken".into(), Value::String(t.into()));
}
if let Some(arr) = saved.get("allowFrom").and_then(|v| v.as_array()) {
let users: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
form.insert("allowedUsers".into(), Value::String(users.join(", ")));
}
}
"qqbot" => {
// QQ Bot: token 格式为 "AppID:AppSecret",拆分回表单字段
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
if let Some((app_id, app_secret)) = t.split_once(':') {
form.insert("appId".into(), Value::String(app_id.into()));
form.insert("appSecret".into(), Value::String(app_secret.into()));
}
}
}
_ => {
// 通用:原样返回字符串类型字段
if let Some(obj) = saved.as_object() {
for (k, v) in obj {
if k == "enabled" {
continue;
}
if let Some(s) = v.as_str() {
form.insert(k.clone(), Value::String(s.into()));
}
}
}
}
}
Ok(json!({ "exists": true, "values": Value::Object(form) }))
}
/// 保存平台配置到 openclaw.json
/// 前端传入的是表单字段,后端负责转换成 OpenClaw 要求的结构
#[tauri::command]
pub async fn save_messaging_platform(
platform: String,
form: Value,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let channels = cfg
.as_object_mut()
.ok_or("配置格式错误")?
.entry("channels")
.or_insert_with(|| json!({}));
let channels_map = channels.as_object_mut().ok_or("channels 节点格式错误")?;
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
match platform.as_str() {
"discord" => {
let mut entry = Map::new();
// Bot Token
if let Some(t) = form_obj.get("token").and_then(|v| v.as_str()) {
entry.insert("token".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("groupPolicy".into(), Value::String("allowlist".into()));
entry.insert("dm".into(), json!({ "enabled": false }));
entry.insert(
"retry".into(),
json!({
"attempts": 3,
"minDelayMs": 500,
"maxDelayMs": 30000,
"jitter": 0.1
}),
);
// guildId + channelId 展开为 guilds 嵌套结构
let guild_id = form_obj
.get("guildId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if !guild_id.is_empty() {
let channel_id = form_obj
.get("channelId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let channel_key = if channel_id.is_empty() {
"*".to_string()
} else {
channel_id
};
entry.insert(
"guilds".into(),
json!({
guild_id: {
"users": ["*"],
"requireMention": true,
"channels": {
channel_key: { "allow": true, "requireMention": true }
}
}
}),
);
}
channels_map.insert("discord".into(), Value::Object(entry));
}
"telegram" => {
let mut entry = Map::new();
if let Some(t) = form_obj.get("botToken").and_then(|v| v.as_str()) {
entry.insert("botToken".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
// allowedUsers 逗号字符串 → allowFrom 数组
if let Some(users_str) = form_obj.get("allowedUsers").and_then(|v| v.as_str()) {
let users: Vec<Value> = users_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| Value::String(s.into()))
.collect();
if !users.is_empty() {
entry.insert("allowFrom".into(), Value::Array(users));
}
}
channels_map.insert("telegram".into(), Value::Object(entry));
}
"qqbot" => {
let app_id = form_obj
.get("appId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let app_secret = form_obj
.get("appSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if app_id.is_empty() || app_secret.is_empty() {
return Err("AppID 和 AppSecret 不能为空".into());
}
let token = format!("{}:{}", app_id, app_secret);
let mut entry = Map::new();
entry.insert("token".into(), Value::String(token));
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert("qqbot".into(), Value::Object(entry));
}
_ => {
// 通用平台:直接保存表单字段
let mut entry = Map::new();
for (k, v) in form_obj {
entry.insert(k.clone(), v.clone());
}
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert(platform.clone(), Value::Object(entry));
}
}
// 写回配置并重载 Gateway
super::config::save_openclaw_json(&cfg)?;
// 触发 Gateway 重载使配置生效
let _ = super::config::do_reload_gateway(&app).await;
Ok(json!({ "ok": true }))
}
/// 删除指定平台配置
#[tauri::command]
pub async fn remove_messaging_platform(
platform: String,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
channels.remove(&platform);
}
super::config::save_openclaw_json(&cfg)?;
let _ = super::config::do_reload_gateway(&app).await;
Ok(json!({ "ok": true }))
}
/// 切换平台启用/禁用
#[tauri::command]
pub async fn toggle_messaging_platform(
platform: String,
enabled: bool,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
if let Some(entry) = cfg
.get_mut("channels")
.and_then(|c| c.get_mut(&platform))
.and_then(|v| v.as_object_mut())
{
entry.insert("enabled".into(), Value::Bool(enabled));
} else {
return Err(format!("平台 {} 未配置", platform));
}
super::config::save_openclaw_json(&cfg)?;
let _ = super::config::do_reload_gateway(&app).await;
Ok(json!({ "ok": true }))
}
/// 在线校验 Bot 凭证(调用平台 API 验证 Token 是否有效)
#[tauri::command]
pub async fn verify_bot_token(
platform: String,
form: Value,
) -> Result<Value, String> {
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP 客户端初始化失败: {}", e))?;
match platform.as_str() {
"discord" => verify_discord(&client, form_obj).await,
"telegram" => verify_telegram(&client, form_obj).await,
"qqbot" => verify_qqbot(&client, form_obj).await,
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
})),
}
}
/// 列出当前已配置的平台清单
#[tauri::command]
pub async fn list_configured_platforms() -> Result<Value, String> {
let cfg = super::config::load_openclaw_json()?;
let mut result: Vec<Value> = vec![];
if let Some(channels) = cfg.get("channels").and_then(|c| c.as_object()) {
for (name, val) in channels {
let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
result.push(json!({
"id": name,
"enabled": enabled
}));
}
}
Ok(json!(result))
}
// ── Discord 凭证校验 ──────────────────────────────────────
async fn verify_discord(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let token = form
.get("token")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if token.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Bot Token 不能为空"] }));
}
// 验证 Bot Token
let me_resp = client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", token))
.send()
.await
.map_err(|e| format!("Discord API 连接失败: {}", e))?;
if me_resp.status() == 401 {
return Ok(json!({ "valid": false, "errors": ["Bot Token 无效,请检查后重试"] }));
}
if !me_resp.status().is_success() {
return Ok(json!({
"valid": false,
"errors": [format!("Discord API 返回异常: {}", me_resp.status())]
}));
}
let me: Value = me_resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if me.get("bot").and_then(|v| v.as_bool()) != Some(true) {
return Ok(json!({
"valid": false,
"errors": ["提供的 Token 不属于 Bot 账号,请使用 Bot Token"]
}));
}
let bot_name = me
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("未知");
let mut details = vec![format!("Bot: @{}", bot_name)];
// 验证 Guild可选
let guild_id = form
.get("guildId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !guild_id.is_empty() {
match client
.get(format!("https://discord.com/api/v10/guilds/{}", guild_id))
.header("Authorization", format!("Bot {}", token))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let guild: Value = resp.json().await.unwrap_or_default();
let name = guild.get("name").and_then(|v| v.as_str()).unwrap_or("?");
details.push(format!("服务器: {}", name));
}
Ok(resp) if resp.status().as_u16() == 403 || resp.status().as_u16() == 404 => {
return Ok(json!({
"valid": false,
"errors": [format!("无法访问服务器 {},请确认 Bot 已加入该服务器", guild_id)]
}));
}
_ => {
details.push("服务器 ID 未能验证(网络问题)".into());
}
}
}
Ok(json!({
"valid": true,
"errors": [],
"details": details
}))
}
// ── QQ Bot 凭证校验 ──────────────────────────────────────
async fn verify_qqbot(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let app_id = form
.get("appId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let app_secret = form
.get("appSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if app_id.is_empty() {
return Ok(json!({ "valid": false, "errors": ["AppID 不能为空"] }));
}
if app_secret.is_empty() {
return Ok(json!({ "valid": false, "errors": ["AppSecret 不能为空"] }));
}
// 通过 QQ Bot API 获取 access_token 验证凭证
let resp = client
.post("https://bots.qq.com/app/getAppAccessToken")
.json(&json!({
"appId": app_id,
"clientSecret": app_secret
}))
.send()
.await
.map_err(|e| format!("QQ Bot API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body.get("access_token").and_then(|v| v.as_str()).is_some() {
Ok(json!({
"valid": true,
"errors": [],
"details": [format!("AppID: {}", app_id)]
}))
} else {
let msg = body
.get("message")
.or_else(|| body.get("msg"))
.and_then(|v| v.as_str())
.unwrap_or("凭证无效,请检查 AppID 和 AppSecret");
Ok(json!({
"valid": false,
"errors": [msg]
}))
}
}
// ── QQ Bot 插件安装(带日志流) ──────────────────────────
#[tauri::command]
pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let _ = app.emit("plugin-log", "正在安装 QQBot 社区插件 @sliverp/qqbot ...");
let _ = app.emit("plugin-progress", 10);
let mut child = crate::utils::openclaw_command()
.args(["plugins", "install", "@sliverp/qqbot@latest"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("启动 openclaw 失败: {}", e))?;
let stderr = child.stderr.take();
let app2 = app.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app2.emit("plugin-log", &line);
}
}
});
let _ = app.emit("plugin-progress", 30);
let mut progress = 30;
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app.emit("plugin-log", &line);
if progress < 90 {
progress += 10;
let _ = app.emit("plugin-progress", progress);
}
}
}
let _ = handle.join();
let _ = app.emit("plugin-progress", 95);
let status = child.wait().map_err(|e| format!("等待安装进程失败: {}", e))?;
let _ = app.emit("plugin-progress", 100);
if !status.success() {
let _ = app.emit("plugin-log", "QQBot 插件安装失败");
return Err("插件安装失败,请查看日志".into());
}
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
Ok("安装成功".into())
}
// ── Telegram 凭证校验 ─────────────────────────────────────
async fn verify_telegram(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let bot_token = form
.get("botToken")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if bot_token.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Bot Token 不能为空"] }));
}
let allowed = form
.get("allowedUsers")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if allowed.is_empty() {
return Ok(json!({ "valid": false, "errors": ["至少需要填写一个允许的用户 ID"] }));
}
let url = format!("https://api.telegram.org/bot{}/getMe", bot_token);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Telegram API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
let username = body
.get("result")
.and_then(|r| r.get("username"))
.and_then(|v| v.as_str())
.unwrap_or("未知");
Ok(json!({
"valid": true,
"errors": [],
"details": [format!("Bot: @{}", username)]
}))
} else {
let desc = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Token 无效");
Ok(json!({
"valid": false,
"errors": [desc]
}))
}
}

View File

@@ -8,6 +8,7 @@ pub mod device;
pub mod extensions;
pub mod logs;
pub mod memory;
pub mod messaging;
pub mod pairing;
pub mod service;
pub mod skills;

View File

@@ -4,7 +4,8 @@ mod tray;
mod utils;
use commands::{
agent, assistant, config, device, extensions, logs, memory, pairing, service, skills, update,
agent, assistant, config, device, extensions, logs, memory, messaging, pairing, service, skills,
update,
};
pub fn run() {
@@ -141,6 +142,14 @@ pub fn run() {
assistant::assistant_save_image,
assistant::assistant_load_image,
assistant::assistant_delete_image,
// 消息渠道管理
messaging::read_platform_config,
messaging::save_messaging_platform,
messaging::remove_messaging_platform,
messaging::toggle_messaging_platform,
messaging::verify_bot_token,
messaging::list_configured_platforms,
messaging::install_qqbot_plugin,
// Skills 管理openclaw skills CLI
skills::skills_list,
skills::skills_info,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
"productName": "ClawPanel",
"version": "0.7.1",
"version": "0.7.2",
"identifier": "ai.openclaw.clawpanel",
"build": {
"frontendDist": "../dist",

View File

@@ -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 }}

View File

@@ -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>',
}

View File

@@ -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"/>',

View File

@@ -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 }),

View File

@@ -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
View 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

View File

@@ -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; }
}