diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 07879673..bacfb29a 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -59,17 +59,28 @@ fn gateway_auth_value(cfg: &Value, key: &str) -> Option { } /// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值) +/// account_id: 可选,指定时读取 channels..accounts.(多账号模式) #[tauri::command] -pub async fn read_platform_config(platform: String) -> Result { +pub async fn read_platform_config( + platform: String, + account_id: Option, +) -> Result { let cfg = super::config::load_openclaw_json()?; let storage_key = platform_storage_key(&platform); // 从已有配置中提取用户可编辑字段 - let saved = cfg - .get("channels") - .and_then(|c| c.get(storage_key)) - .cloned() - .unwrap_or(Value::Null); + // 多账号模式:优先从 accounts. 读取 + let channel_val = cfg.get("channels").and_then(|c| c.get(storage_key)); + + let saved = match (&account_id, channel_val) { + (Some(acct), Some(ch)) if !acct.is_empty() => ch + .get("accounts") + .and_then(|a| a.get(acct.as_str())) + .cloned() + .unwrap_or(Value::Null), + (_, Some(ch)) => ch.clone(), + _ => Value::Null, + }; let mut form = Map::new(); let exists = !saved.is_null(); @@ -443,16 +454,54 @@ pub async fn save_messaging_platform( } /// 删除指定平台配置 +/// account_id: 可选,指定时仅删除 channels..accounts.(多账号模式) +/// 未指定时删除整个平台配置 #[tauri::command] pub async fn remove_messaging_platform( platform: String, + account_id: Option, app: tauri::AppHandle, ) -> Result { let mut cfg = super::config::load_openclaw_json()?; let storage_key = platform_storage_key(&platform); - if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) { - channels.remove(storage_key); + match &account_id { + Some(acct) if !acct.is_empty() => { + // 多账号模式:仅删除指定账号 + if let Some(channel) = cfg.get_mut("channels").and_then(|c| c.get_mut(storage_key)) { + if let Some(accounts) = channel.get_mut("accounts").and_then(|a| a.as_object_mut()) + { + accounts.remove(acct.as_str()); + } + } + } + _ => { + // 整平台删除 + if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) { + channels.remove(storage_key); + } + } + } + + // 清理对应的 bindings 条目 + let binding_channel = platform_list_id(&platform); + if let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) { + bindings.retain(|b| { + let m = match b.get("match") { + Some(m) => m, + None => return true, + }; + if m.get("channel").and_then(|v| v.as_str()) != Some(binding_channel) { + return true; // 不同渠道,保留 + } + match &account_id { + Some(acct) if !acct.is_empty() => { + // 仅移除匹配该 accountId 的 binding + m.get("accountId").and_then(|v| v.as_str()) != Some(acct.as_str()) + } + _ => false, // 整平台删除,移除该渠道所有 binding + } + }); } super::config::save_openclaw_json(&cfg)?; @@ -516,6 +565,7 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Result { let cfg = super::config::load_openclaw_json()?; @@ -524,9 +574,23 @@ pub async fn list_configured_platforms() -> Result { 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); + let mut accounts: Vec = vec![]; + + // 提取多账号信息(仅安全字段,不含 appSecret 等敏感数据) + if let Some(accts) = val.get("accounts").and_then(|a| a.as_object()) { + for (acct_id, acct_val) in accts { + let mut entry = json!({ "accountId": acct_id }); + if let Some(app_id) = acct_val.get("appId").and_then(|v| v.as_str()) { + entry["appId"] = Value::String(app_id.to_string()); + } + accounts.push(entry); + } + } + result.push(json!({ "id": platform_list_id(name), - "enabled": enabled + "enabled": enabled, + "accounts": accounts })); } } diff --git a/src/lib/channel-labels.js b/src/lib/channel-labels.js new file mode 100644 index 00000000..239ccc68 --- /dev/null +++ b/src/lib/channel-labels.js @@ -0,0 +1,8 @@ +/** 渠道 key → 中文显示名(供多页面复用) */ +export const CHANNEL_LABELS = { + qqbot: 'QQ 机器人', + telegram: 'Telegram', + feishu: '飞书', + dingtalk: '钉钉', + discord: 'Discord', +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 32ecdc12..31a9effa 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -213,9 +213,9 @@ export const api = { exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }), // 消息渠道管理 - readPlatformConfig: (platform) => invoke('read_platform_config', { platform }), + readPlatformConfig: (platform, accountId) => invoke('read_platform_config', { platform, accountId: accountId || null }), saveMessagingPlatform: (platform, form, accountId) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('save_messaging_platform', { platform, form, accountId: accountId || null }) }, - removeMessagingPlatform: (platform) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('remove_messaging_platform', { platform }) }, + removeMessagingPlatform: (platform, accountId) => { invalidate('list_configured_platforms', 'read_platform_config'); return invoke('remove_messaging_platform', { platform, accountId: accountId || null }) }, toggleMessagingPlatform: (platform, enabled) => { invalidate('list_configured_platforms', 'read_openclaw_config', 'read_platform_config'); return invoke('toggle_messaging_platform', { platform, enabled }) }, verifyBotToken: (platform, form) => invoke('verify_bot_token', { platform, form }), listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000), diff --git a/src/pages/agents.js b/src/pages/agents.js index 484a40d4..939e0b28 100644 --- a/src/pages/agents.js +++ b/src/pages/agents.js @@ -5,6 +5,7 @@ import { api, invalidate } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' +import { CHANNEL_LABELS } from '../lib/channel-labels.js' export async function render() { const page = document.createElement('div') @@ -25,7 +26,7 @@ export async function render() { ` - const state = { agents: [] } + const state = { agents: [], bindings: [] } // 非阻塞:先返回 DOM,后台加载数据 loadAgents(page, state) @@ -52,7 +53,12 @@ async function loadAgents(page, state) { const container = page.querySelector('#agents-list') renderSkeleton(container) try { - state.agents = await api.listAgents() + const [agents, config] = await Promise.all([ + api.listAgents(), + api.readOpenclawConfig().catch(() => null), + ]) + state.agents = agents + state.bindings = Array.isArray(config?.bindings) ? config.bindings : [] renderAgents(page, state) // 只在第一次加载时绑定事件(避免重复绑定) @@ -66,6 +72,21 @@ async function loadAgents(page, state) { } } +/** 为指定 agent 生成绑定渠道的 badge HTML */ +function renderBindingBadges(agentId, bindings) { + const matched = (bindings || []).filter(b => (b.agentId || 'main') === agentId) + if (!matched.length) { + return '未绑定渠道' + } + return matched.map(b => { + const channel = b.match?.channel || '' + const label = CHANNEL_LABELS[channel] || channel + const accountId = b.match?.accountId + const text = accountId ? `${label} · ${accountId}` : label + return `${text}` + }).join(' ') +} + function renderAgents(page, state) { const container = page.querySelector('#agents-list') if (!state.agents.length) { @@ -102,6 +123,10 @@ function renderAgents(page, state) { 工作区: ${a.workspace || '未设置'} +
+ 绑定渠道: + ${renderBindingBadges(a.id, state.bindings)} +
` diff --git a/src/pages/channels.js b/src/pages/channels.js index 65878fcc..a3107ab8 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -160,6 +160,9 @@ async function loadPlatforms(page, state) { renderAvailable(page, state) } +// ── 多账号支持的平台列表 ── +const MULTI_INSTANCE_PLATFORMS = ['feishu', 'dingtalk'] + // ── 已配置平台渲染 ── function renderConfigured(page, state) { @@ -178,12 +181,61 @@ function renderConfigured(page, state) { const label = reg?.label || p.id const ic = icon(reg?.iconName || 'radio', 22) const channelKey = getChannelBindingKey(p.id) + const accounts = Array.isArray(p.accounts) ? p.accounts : [] + const hasAccounts = accounts.length > 0 + const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(p.id) + + if (hasAccounts) { + // ── 多账号模式:每个账号显示为子项 ── + const accountsHtml = accounts.map(acc => { + const accId = acc.accountId || 'default' + // 找到该账号对应的 binding + const accBindings = (state.bindings || []).filter(b => + b.match?.channel === channelKey && (b.match?.accountId || '') === (acc.accountId || '') + ) + const accAgents = accBindings.map(b => b.agentId || 'main') + const showBadge = accAgents.length > 0 && !(accAgents.length === 1 && accAgents[0] === 'main') + const badgesHtml = showBadge ? accAgents.map(a => + `\u2192 ${escapeAttr(a)}` + ).join(' ') : '' + return ` + + ` + }).join('') + + return ` +
+
+ ${ic} + ${label} + + +
+
${accountsHtml}
+
+ ${supportsMulti ? `` : ''} + + + +
+
+ ` + } + + // ── 无账号模式(原有单卡片布局) ── const allBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey) const boundAgents = allBindings.map(b => b.agentId || 'main') - // 只有一个 main 绑定时不显示标签(默认行为),多绑定时全部显示 const showAll = boundAgents.length > 1 || (boundAgents.length === 1 && boundAgents[0] !== 'main') const agentBadges = showAll ? boundAgents.map(a => - `→ ${escapeAttr(a)}` + `\u2192 ${escapeAttr(a)}` ).join(' ') : '' return `
@@ -194,6 +246,7 @@ function renderConfigured(page, state) {
+ ${supportsMulti ? `` : ''} @@ -208,8 +261,34 @@ function renderConfigured(page, state) { // 绑定事件 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 () => { + card.querySelector('[data-action="edit"]')?.addEventListener('click', () => openConfigDialog(pid, page, state)) + + // 添加账号按钮 + card.querySelector('[data-action="add-account"]')?.addEventListener('click', () => openConfigDialog(pid, page, state)) + + // 每个账号的编辑/移除按钮 + card.querySelectorAll('[data-action="edit-account"]').forEach(btn => { + btn.addEventListener('click', () => { + const accountId = btn.dataset.accountId + openConfigDialog(pid, page, state, accountId) + }) + }) + card.querySelectorAll('[data-action="remove-account"]').forEach(btn => { + btn.addEventListener('click', async () => { + const accountId = btn.dataset.accountId + const reg = PLATFORM_REGISTRY[pid] + const displayName = accountId ? `${reg?.label || pid} 账号「${accountId}」` : `${reg?.label || pid} 默认账号` + const yes = await showConfirm(`确定移除 ${displayName}?该账号配置将被删除。`) + if (!yes) return + try { + await api.removeMessagingPlatform(pid, accountId || null) + toast('已移除', 'info') + await loadPlatforms(page, state) + } catch (e) { toast('移除失败: ' + e, 'error') } + }) + }) + + card.querySelector('[data-action="toggle"]')?.addEventListener('click', async () => { const cur = state.configured.find(p => p.id === pid) if (!cur) return try { @@ -217,8 +296,8 @@ function renderConfigured(page, state) { 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 () => { + }) + card.querySelector('[data-action="remove"]')?.addEventListener('click', async () => { const yes = await showConfirm(`确定移除 ${PLATFORM_REGISTRY[pid]?.label || pid}?配置将被删除。`) if (!yes) return try { @@ -226,7 +305,7 @@ function renderConfigured(page, state) { toast('已移除', 'info') await loadPlatforms(page, state) } catch (e) { toast('移除失败: ' + e, 'error') } - } + }) }) } @@ -311,17 +390,17 @@ async function openBindAgentDialog(pid, page, state) { // ── 配置弹窗(新增 / 编辑共用) ── -async function openConfigDialog(pid, page, state) { +async function openConfigDialog(pid, page, state, editAccountId) { const reg = PLATFORM_REGISTRY[pid] if (!reg) { toast('未知平台', 'error'); return } - // 尝试加载已有配置 + // 尝试加载已有配置(多账号时按 accountId 读取) let existing = {} let isEdit = false let agents = [] let currentBinding = '' try { - const res = await api.readPlatformConfig(pid) + const res = await api.readPlatformConfig(pid, editAccountId || null) if (res?.values) { existing = res.values } @@ -329,7 +408,7 @@ async function openConfigDialog(pid, page, state) { isEdit = true } } catch {} - // 加载 Agent 列表和当前 binding + // 加载 Agent 列表和当前 binding(多账号时匹配 accountId) try { agents = await api.listAgents() } catch {} @@ -337,7 +416,11 @@ async function openConfigDialog(pid, page, state) { const config = await api.readOpenclawConfig() const bindings = config?.bindings || [] const channelKey = getChannelBindingKey(pid) - const found = bindings.find(b => b.match?.channel === channelKey) + const found = bindings.find(b => { + if (b.match?.channel !== channelKey) return false + if (editAccountId != null) return (b.match?.accountId || '') === editAccountId + return !b.match?.accountId + }) if (found) currentBinding = found.agentId || '' } catch {} @@ -349,11 +432,12 @@ async function openConfigDialog(pid, page, state) { return `` }).join('') const supportsMultiAccount = ['feishu', 'dingtalk', 'dingtalk-connector'].includes(pid) + const editingAccount = editAccountId != null const accountIdHtml = supportsMultiAccount ? `
- -
为同一平台接入多个应用时,每个应用需要一个唯一的账号标识。不同账号可绑定不同 Agent
+ +
${editingAccount ? '编辑模式下账号标识不可修改' : '为同一平台接入多个应用时,每个应用需要一个唯一的账号标识。不同账号可绑定不同 Agent'}
` : '' const agentBindingHtml = ` diff --git a/src/style/pages.css b/src/style/pages.css index c467c633..d8111ece 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -1204,4 +1204,78 @@ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } +} + +/* ── 渠道多账号子项 ── */ + +.account-count { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + background: var(--bg-tertiary); + padding: 1px 8px; + border-radius: 999px; + white-space: nowrap; +} + +.platform-accounts { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); + padding: var(--space-sm) 0; + border-top: 1px solid var(--border-secondary); +} + +.account-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + flex-wrap: wrap; +} + +.account-id { + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.account-appid { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; +} + +.account-item .agent-badge { + font-size: var(--font-size-xs); + color: var(--accent); + background: var(--accent-muted); + padding: 1px 6px; + border-radius: 10px; + white-space: nowrap; +} + +.account-item .account-actions { + display: flex; + gap: var(--space-xs); + margin-left: auto; +} + +@media (max-width: 768px) { + .account-item { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .account-item .account-actions { + margin-left: 0; + align-self: flex-end; + } } \ No newline at end of file