@jesonliu/lark-claudecode-bridge 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +10 -10
  2. package/assets/web/css/main.css +509 -113
  3. package/assets/web/index.html +12 -2
  4. package/assets/web/js/main.js +16 -10
  5. package/assets/web/js/pages/apps.js +10 -5
  6. package/assets/web/js/pages/claude.js +15 -5
  7. package/assets/web/js/pages/mcp.js +56 -20
  8. package/assets/web/js/pages/overview.js +5 -4
  9. package/assets/web/js/pages/permissions.js +56 -30
  10. package/assets/web/js/pages/plugins.js +29 -6
  11. package/assets/web/js/pages/skills.js +184 -11
  12. package/assets/web/js/pages/slash.js +11 -5
  13. package/assets/web/js/pages/workspaces.js +15 -14
  14. package/assets/web/js/theme.js +23 -0
  15. package/assets/web/js/ui.js +75 -4
  16. package/config.example.yaml +2 -3
  17. package/dist/access/access-control.d.ts +17 -1
  18. package/dist/access/access-control.js +31 -2
  19. package/dist/access/access-control.js.map +1 -1
  20. package/dist/claude-config.d.ts +14 -2
  21. package/dist/claude-config.js +75 -4
  22. package/dist/claude-config.js.map +1 -1
  23. package/dist/cli/setup-wizard.d.ts +0 -1
  24. package/dist/cli/setup-wizard.js +2 -4
  25. package/dist/cli/setup-wizard.js.map +1 -1
  26. package/dist/config.js +6 -7
  27. package/dist/config.js.map +1 -1
  28. package/dist/executor/claude-executor.d.ts +1 -1
  29. package/dist/executor/claude-executor.js +4 -2
  30. package/dist/executor/claude-executor.js.map +1 -1
  31. package/dist/executor/notify-server.d.ts +9 -2
  32. package/dist/executor/notify-server.js +34 -9
  33. package/dist/executor/notify-server.js.map +1 -1
  34. package/dist/gateway/card-builder.d.ts +38 -0
  35. package/dist/gateway/card-builder.js +139 -18
  36. package/dist/gateway/card-builder.js.map +1 -1
  37. package/dist/gateway/diff-card.js +1 -1
  38. package/dist/gateway/diff-card.js.map +1 -1
  39. package/dist/gateway/feishu-gateway.d.ts +20 -3
  40. package/dist/gateway/feishu-gateway.js +58 -4
  41. package/dist/gateway/feishu-gateway.js.map +1 -1
  42. package/dist/gateway/progress-card.d.ts +51 -0
  43. package/dist/gateway/progress-card.js +166 -7
  44. package/dist/gateway/progress-card.js.map +1 -1
  45. package/dist/index.d.ts +9 -0
  46. package/dist/index.js +195 -42
  47. package/dist/index.js.map +1 -1
  48. package/dist/notify-sop.d.ts +1 -1
  49. package/dist/notify-sop.js +5 -2
  50. package/dist/notify-sop.js.map +1 -1
  51. package/dist/session/commands.d.ts +1 -1
  52. package/dist/session/commands.js +25 -2
  53. package/dist/session/commands.js.map +1 -1
  54. package/dist/session/session-store.d.ts +3 -0
  55. package/dist/session/session-store.js.map +1 -1
  56. package/dist/types.d.ts +11 -5
  57. package/dist/util/workspace-diff.js +2 -2
  58. package/dist/util/workspace-diff.js.map +1 -1
  59. package/dist/web/server.js +96 -3
  60. package/dist/web/server.js.map +1 -1
  61. package/dist/web/skills-mcp-api.d.ts +11 -4
  62. package/dist/web/skills-mcp-api.js +47 -11
  63. package/dist/web/skills-mcp-api.js.map +1 -1
  64. package/package.json +1 -1
@@ -3,14 +3,24 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>飞书ClaudeCode桥接器</title>
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>飞书 Claude Code 桥接器</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%233370ff'/%3E%3Cg stroke='%23fff' stroke-width='1.9' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 7 5.5 9.5 8 12'/%3E%3Cpath d='M5.5 9.5H15'/%3E%3Cpath d='M16 12 18.5 14.5 16 17'/%3E%3Cpath d='M18.5 14.5H9'/%3E%3C/g%3E%3C/svg%3E">
7
9
  <link rel="stylesheet" href="/css/main.css">
8
10
  </head>
9
11
  <body>
10
12
  <header>
11
- <h1><svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="#3370ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>飞书ClaudeCode桥接器</h1>
13
+ <div class="brand">
14
+ <span class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg></span>
15
+ <h1>飞书 Claude Code 桥接器</h1>
16
+ </div>
12
17
  <span class="meta" id="hMeta">加载中…</span>
13
18
  <span class="spacer"></span>
19
+ <div class="theme-switch" id="themeSwitch" role="group" aria-label="界面风格">
20
+ <button data-opt="auto" title="跟随系统"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg></button>
21
+ <button data-opt="light" title="亮色风格"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.3 11.3 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4m11.3-11.3 1.4-1.4"/></svg></button>
22
+ <button data-opt="dark" title="暗色风格"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></button>
23
+ </div>
14
24
  <span class="meta" id="hFirstRun"></span>
15
25
  </header>
16
26
  <div class="stale-bar" id="staleBar" hidden>⚠️ 配置页服务进程为旧版本(页面文件已更新):进程启停 / 重启 / 一键更新需重启桥接器(<code>lcb start</code> 或页面所在进程)后可用。</div>
@@ -2,7 +2,8 @@
2
2
  // 新增配置页:js/pages/ 下新建模块(export const page = { id, title, icon, render(el) })
3
3
  // 并在下方 import + PAGES 数组各加一行即可;后端 /js/* 静态服务自动覆盖新文件
4
4
  import { S, $, esc, hooks, refresh } from './core.js';
5
- import { initDrawer, isDrawerOpen, cancelDrawer } from './ui.js';
5
+ import { initDrawer, isDrawerOpen, cancelDrawer, confirmDialog } from './ui.js';
6
+ import { initTheme } from './theme.js';
6
7
  import { renderBootstrap } from './bootstrap.js';
7
8
  import { page as pOverview } from './pages/overview.js';
8
9
  import { page as pApps } from './pages/apps.js';
@@ -27,35 +28,39 @@ let activeTab = tabFromHash(); // 首载即定位(首次 render 前)
27
28
  let restoring = false; // 拒绝导航后回写 hash 触发的 hashchange 自抑制标志
28
29
 
29
30
  /** 切页保护:抽屉编辑中 / 页内表单卡有未保存改动 → 确认后才切换(切走即丢弃) */
30
- function confirmLeave() {
31
+ async function confirmLeave() {
31
32
  if (isDrawerOpen()) {
32
- if (!window.confirm('编辑抽屉尚未保存,确定放弃修改并切换页面?')) return false;
33
+ if (!(await confirmDialog({ title: '放弃未保存的修改?', message: '编辑抽屉尚未保存,切换页面将丢弃这些修改。', danger: true, confirmText: '放弃修改' }))) return false;
33
34
  cancelDrawer(); // 回滚抽屉编辑(内部 rerender 旧页),再由调用方 render 新页
34
35
  } else if (S.dirtyCards.size) {
35
- if (!window.confirm(`有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。确定切换?`)) return false;
36
+ if (!(await confirmDialog({ title: '放弃未保存的修改?', message: `有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。`, danger: true, confirmText: '放弃修改' }))) return false;
36
37
  S.dirtyCards.clear();
37
38
  }
38
39
  return true;
39
40
  }
40
41
 
42
+ /** 页头(图标徽章 + 标题):注入到 #main 顶部,页面本体渲染进 #pageBody */
43
+ const pageHeadHtml = (p) => `<div class="page-head"><span class="ico"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${p.icon}</svg></span><h2>${p.title}</h2></div><div id="pageBody"></div>`;
44
+
41
45
  function render() {
42
46
  renderNav();
43
47
  if (S.firstRun) return renderBootstrap($('#main')); // 首装向导:无论 hash 是什么都渲染向导
44
- $('#main').innerHTML = '';
45
- void pageById.get(activeTab).render($('#main')); // fire-and-forget(plugins 页为 async,同旧版)
48
+ const page = pageById.get(activeTab);
49
+ $('#main').innerHTML = pageHeadHtml(page);
50
+ void page.render($('#pageBody')); // fire-and-forget(plugins 页为 async,同旧版)
46
51
  }
47
52
 
48
53
  function renderNav() {
49
54
  $('#nav').innerHTML = PAGES.map((p) =>
50
55
  `<button data-k="${p.id}" class="${p.id === activeTab ? 'active' : ''}">${ico(p.icon)}<span>${p.title}</span></button>`).join('');
51
56
  $('#nav').querySelectorAll('button').forEach((b) =>
52
- b.onclick = () => tryActivate(b.dataset.k));
57
+ b.onclick = () => void tryActivate(b.dataset.k));
53
58
  }
54
59
 
55
60
  /** nav 点击与 hashchange 的唯一汇聚点:确认 → 更新 activeTab + 同步 hash → 渲染 */
56
- function tryActivate(tab) {
61
+ async function tryActivate(tab) {
57
62
  if (tab === activeTab) return; // nav 点击后写 hash 引发的 hashchange 在此早退(无双重渲染)
58
- if (!confirmLeave()) {
63
+ if (!(await confirmLeave())) {
59
64
  // 拒绝导航:把 hash 回滚到当前页;回写引发的 hashchange 用 restoring 吞掉
60
65
  if (location.hash.slice(1) !== activeTab) { restoring = true; location.hash = activeTab; }
61
66
  return;
@@ -67,11 +72,12 @@ function tryActivate(tab) {
67
72
 
68
73
  window.addEventListener('hashchange', () => {
69
74
  if (restoring) { restoring = false; return; }
70
- tryActivate(tabFromHash()); // 覆盖手改地址栏 / 分享链接 / 前进后退
75
+ void tryActivate(tabFromHash()); // 覆盖手改地址栏 / 分享链接 / 前进后退
71
76
  });
72
77
 
73
78
  // ---- 启动 ----
74
79
  window.__lcbBooted = true; // index.html 兜底脚本标志:module 加载成功
75
80
  hooks.rerender = render; // 破环注入:core 的 refresh/cancelDrawer 经 hooks 触发重渲染
81
+ initTheme(); // 主题切换(跟随系统 / 亮色 / 暗色)
76
82
  initDrawer(); // 抽屉全局监听(X / ESC / 点遮罩)一次性注册
77
83
  refresh().catch((e) => { $('#main').innerHTML = `<div class="card"><h3>加载失败</h3><div class="desc">${esc(e.message)}</div></div>`; });
@@ -1,6 +1,6 @@
1
1
  // ============ 应用(列表 + 抽屉编辑;输入实时写回 S.doc.apps,抽屉「保存」一次落盘) ============
2
2
  import { S, $, esc, toast, snapDoc, saveDoc } from '../core.js';
3
- import { openDrawer, closeDrawer, cancelDrawer } from '../ui.js';
3
+ import { openDrawer, closeDrawer, cancelDrawer, confirmDialog } from '../ui.js';
4
4
 
5
5
  function renderApps(el) {
6
6
  const apps = S.doc.apps || (S.doc.apps = []);
@@ -8,7 +8,7 @@ function renderApps(el) {
8
8
  <div class="card">
9
9
  <h3>飞书应用(机器人)</h3>
10
10
  <div class="desc">每个应用一条独立长连接与会话池。点击行或「编辑」在抽屉中配置;App Secret 已脱敏,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 环境变量改动需重启 lcb start;触发词与显式插件热生效。</div>
11
- <div style="display:flex;justify-content:flex-end;margin-bottom:10px">
11
+ <div class="list-toolbar">
12
12
  <button class="btn primary" id="addApp">+ 新增应用</button>
13
13
  </div>
14
14
  <table>
@@ -126,7 +126,7 @@ function openAppDrawer(apps, idx, el, snap) {
126
126
  tb.innerHTML = list.map((item, i) => `
127
127
  <tr>
128
128
  ${def.cols.map(([col, ph]) => `<td><input type="text" data-l="${i}" data-c="${col}" value="${esc(item?.[col] ?? '')}" placeholder="${esc(ph)}"></td>`).join('')}
129
- <td><button class="btn sm danger" data-rm="${i}">删</button></td>
129
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
130
130
  </tr>`).join('');
131
131
  tb.querySelectorAll('input[data-l]').forEach((input) => input.oninput = () => {
132
132
  app[def.key][Number(input.dataset.l)][input.dataset.c] = input.value;
@@ -151,7 +151,7 @@ function openAppDrawer(apps, idx, el, snap) {
151
151
  <tr>
152
152
  <td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
153
153
  <td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
154
- <td><button class="btn sm danger" data-rm="${i}">删</button></td>
154
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
155
155
  </tr>`).join('');
156
156
  tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
157
157
  envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
@@ -176,7 +176,12 @@ function openAppDrawer(apps, idx, el, snap) {
176
176
  // ---- 删除 / 保存 / 取消 ----
177
177
  foot.querySelector('#dwDel').onclick = async () => {
178
178
  if (apps.length <= 1) return toast('至少保留一个应用', true);
179
- if (!window.confirm(`确认删除应用「${app.name || app.app_id || '未命名'}」?删除将立即保存生效。`)) return;
179
+ if (!(await confirmDialog({
180
+ title: '删除应用',
181
+ message: `确认删除应用「${esc(app.name || app.app_id || '未命名')}」?删除将立即保存生效。`,
182
+ danger: true,
183
+ confirmText: '删除',
184
+ }))) return;
180
185
  apps.splice(idx, 1);
181
186
  if (await saveDoc()) closeDrawer();
182
187
  else cancelDrawer(); // 落盘失败:回滚删除,保留编辑现场让用户重试
@@ -1,6 +1,6 @@
1
1
  // ============ Claude 认证 ============
2
2
  import { S, $, esc, toast, api, snapDoc, saveDoc, cardDirty, applyDirty, refresh } from '../core.js';
3
- import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar } from '../ui.js';
3
+ import { openDrawer, closeDrawer, cancelDrawer, saveBarHtml, dirtyDotHtml, bindSaveBar, confirmDialog } from '../ui.js';
4
4
 
5
5
  /** 模型拉取公共逻辑:getBody() 组请求体(凭证仅携带本次输入的明文,空则不带给后端回落磁盘值) */
6
6
  async function pullModels(getBody, btn, datalistId) {
@@ -80,7 +80,7 @@ function renderClaude(el) {
80
80
  <div class="card" id="pfCard" style="${mode === 'managed' ? '' : 'display:none'}">
81
81
  <h3>厂商档案</h3>
82
82
  <div class="desc">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型(如 fable / opus / sonnet / haiku)。「设为当前」整套切换;点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>
83
- <div style="display:flex;justify-content:flex-end;margin-bottom:10px"><button class="btn sm primary" id="pfAdd">+ 新增档案</button></div>
83
+ <div class="list-toolbar"><button class="btn primary" id="pfAdd">+ 新增档案</button></div>
84
84
  <table><thead><tr><th style="width:120px">名称</th><th style="width:100px">凭证类型</th><th>BASE_URL</th><th>模型(点击切换)</th><th style="width:190px">操作</th></tr></thead>
85
85
  <tbody id="pfBody"></tbody></table>
86
86
  </div>`;
@@ -120,7 +120,7 @@ function renderClaude(el) {
120
120
  <tr>
121
121
  <td><input type="text" data-cei="${i}" data-col="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
122
122
  <td><input type="text" data-cei="${i}" data-col="value" value="${esc(r.value)}" placeholder="some-value"></td>
123
- <td><button class="btn sm danger" data-crm="${i}">删</button></td>
123
+ <td><button class="btn sm danger" data-crm="${i}">删除</button></td>
124
124
  </tr>`).join('') : '<tr><td colspan="3" class="hint">未配置(本机 ~/.claude 的非认证键自动继承)</td></tr>';
125
125
  tb.querySelectorAll('input[data-cei]').forEach((input) => input.oninput = () => {
126
126
  cEnvRows[Number(input.dataset.cei)][input.dataset.col] = input.value;
@@ -196,7 +196,12 @@ function renderClaude(el) {
196
196
  return;
197
197
  }
198
198
  if (b.dataset.op === 'del') {
199
- if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
199
+ if (!(await confirmDialog({
200
+ title: '删除档案',
201
+ message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
202
+ danger: true,
203
+ confirmText: '删除',
204
+ }))) return;
200
205
  c.profiles.splice(i, 1);
201
206
  await saveDoc();
202
207
  return;
@@ -301,7 +306,12 @@ function openProfileDrawer(c, i, el, snap) {
301
306
  }), document.getElementById('dwPfPull'), 'dwPfModelList');
302
307
  };
303
308
  document.getElementById('dwPfDel').onclick = async () => {
304
- if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
309
+ if (!(await confirmDialog({
310
+ title: '删除档案',
311
+ message: `确认删除档案「${esc(p.name || '未命名')}」?删除将立即保存生效。`,
312
+ danger: true,
313
+ confirmText: '删除',
314
+ }))) return;
305
315
  c.profiles.splice(i, 1);
306
316
  if (await saveDoc()) closeDrawer();
307
317
  else cancelDrawer();
@@ -3,7 +3,7 @@
3
3
  // 四来源:bridge 自管(mcp/servers.json,页面管理 + 注入生效)/ 用户级·本机(~/.claude.json,展示+可删)/
4
4
  // 项目级(工作区 .mcp.json,只读)/ 已装插件内(<plugin-installPath>/.mcp.json,只读;未启用时标「(未启用)」)。
5
5
  import { $, esc, toast, api } from '../core.js';
6
- import { openDrawer, closeDrawer } from '../ui.js';
6
+ import { openDrawer, closeDrawer, confirmDialog } from '../ui.js';
7
7
 
8
8
  const SOURCE_TAG = {
9
9
  bridge: 'bridge · 可管理',
@@ -23,11 +23,11 @@ function renderMcp(el) {
23
23
  <div class="card">
24
24
  <h3>MCP Servers</h3>
25
25
  <div class="desc">stdio 走 command + args(本地进程),http/sse 走 url(远程服务)。页面添加的配置存 bridge 自管目录(见下方路径),运行中的任务即时生效;本机 ~/.claude.json 与工作区 .mcp.json 为只读来源(前者可删);已装插件内 .mcp.json 或 plugin.json 内 mcpServers 字段只读展示。</div>
26
- <div style="display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px">
27
- <button class="btn sm" id="mcpRefresh">↻ 刷新</button>
28
- <button class="btn sm" id="mcpAdd">+ 添加 MCP</button>
26
+ <div class="list-toolbar">
27
+ <button class="btn" id="mcpRefresh">刷新</button>
28
+ <button class="btn primary" id="mcpAdd">+ 添加 MCP</button>
29
29
  </div>
30
- <input type="search" id="mcpSearch" placeholder="🔍 按名字 / 来源 / 类型 / URL 过滤…" style="width:100%;padding:7px 10px;margin-bottom:8px;border:1px solid var(--border);border-radius:7px">
30
+ <input type="search" class="list-search" id="mcpSearch" placeholder="按名字 / 来源 / 类型 / URL 过滤…">
31
31
  <table><thead><tr>
32
32
  <th style="width:150px">名字</th><th>类型 / 连接</th><th style="width:130px">来源</th><th style="width:130px">状态</th><th style="width:130px"></th>
33
33
  </tr></thead>
@@ -94,7 +94,7 @@ async function loadMcp() {
94
94
  <td class="mcp-status" data-status-for="${esc(s.name)}"><span class="desc">未检测</span></td>
95
95
  <td>
96
96
  <button class="btn sm" data-view="${esc(s.name)}">查看</button>
97
- ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删</button>` : '<span class="desc">只读</span>'}
97
+ ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : '<span class="desc">只读</span>'}
98
98
  </td>`;
99
99
  tr.querySelector('[data-view]').onclick = () => viewMcp(s);
100
100
  const delBtn = tr.querySelector('[data-del]');
@@ -115,25 +115,31 @@ async function checkMcp(s, cell) {
115
115
  try {
116
116
  const r = await api('POST', '/api/mcp/check', { name: s.name, source: s.source, workspaceName: s.workspaceName });
117
117
  const map = {
118
- ok: `<span style="color:#16a34a">✔ 正常</span>`,
119
- failed: `<span style="color:#dc2626">✘ 异常</span>`,
120
- unreachable: `<span style="color:#d97706">⚠ 不可达</span>`,
118
+ ok: `<span style="color:var(--ok-tx)">✔ 正常</span>`,
119
+ failed: `<span style="color:var(--err-tx)">✘ 异常</span>`,
120
+ unreachable: `<span style="color:var(--warn-tx)">⚠ 不可达</span>`,
121
121
  };
122
122
  cell.innerHTML = `${map[r.status] || esc(r.status)}<div class="desc" style="font-size:11px;white-space:normal">${esc(r.detail || '')}</div>`;
123
123
  } catch (e) {
124
- cell.innerHTML = `<span style="color:#dc2626">检测失败</span>`;
124
+ cell.innerHTML = `<span style="color:var(--err-tx)">检测失败</span>`;
125
125
  toast(e.message, true);
126
126
  }
127
127
  }
128
128
 
129
- function delMcp(s) {
130
- if (!confirm(`删除 MCP server "${s.name}"(${sourceTag(s)})?`)) return;
129
+ async function delMcp(s) {
130
+ if (!(await confirmDialog({
131
+ title: '删除 MCP server',
132
+ message: `确定删除 MCP server <b>${esc(s.name)}</b>(${sourceTag(s)})?删除后引用它的任务将无法启动该 server。`,
133
+ danger: true,
134
+ confirmText: '删除',
135
+ }))) return;
131
136
  api('POST', '/api/mcp/action', { op: 'remove', name: s.name, source: s.source })
132
137
  .then(() => { toast('已删除'); void loadMcp(); })
133
138
  .catch((e) => toast(e.message, true));
134
139
  }
135
140
 
136
- /** 抽屉查看:完整配置 JSON + env 表(${VAR} 引用已展开为当前值;未设置的变量标红) */
141
+ /** 抽屉查看:完整配置 JSON + env 表(${VAR}/${VAR:-default} 引用已按多来源合并展开;未设置的变量标红)。
142
+ * bridge 来源支持「编辑」:JSON 原文可改,保存走 op:update(任务级热生效,无须重启) */
137
143
  function viewMcp(s) {
138
144
  const envKeys = Object.keys(s.config.env || {});
139
145
  const envRows = envKeys.length
@@ -142,22 +148,52 @@ function viewMcp(s) {
142
148
  const raw = String((s.config.env || {})[k] ?? '');
143
149
  const cur = (s.resolvedEnv || {})[k] ?? raw;
144
150
  return `<tr><td><code>${esc(k)}</code></td><td><code class="desc">${esc(raw)}</code></td>
145
- <td>${missing ? `<span style="color:#dc2626">未设置(${esc(cur)})</span>` : `<code>${esc(cur)}</code>`}</td></tr>`;
151
+ <td>${missing ? `<span style="color:var(--err-tx)">未设置(${esc(cur)})</span>` : `<code>${esc(cur)}</code>`}</td></tr>`;
146
152
  }).join('')
147
153
  : `<tr><td colspan="3" class="desc">(无 env 配置)</td></tr>`;
148
154
  const onlyRead = !isDeletable(s);
155
+ const editable = s.source === 'bridge'; // 后端 op:update 只写 bridge servers.json;machine-user 只能删
149
156
  openDrawer({
150
157
  title: `MCP · ${s.name}`,
151
158
  bodyHtml: `
152
159
  <div class="hint" style="margin:0 0 8px">来源:${sourceTag(s)} · 配置文件:<code class="desc">${esc(s.path)}</code></div>
153
- ${onlyRead ? `<div class="hint" style="margin:0 0 8px;color:#d97706">只读来源——此 MCP 由 ${s.source === 'plugin' ? '插件' : '项目'}自带,编辑请前往 ${s.source === 'plugin' ? '插件目录' : '对应工作区目录'}手动修改。</div>` : ''}
154
- <label>配置(JSON 原文)</label>
155
- <textarea readonly rows="10" style="min-height:140px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
156
- <label>环境变量(引用值 → 当前值)</label>
160
+ ${onlyRead ? `<div class="hint" style="margin:0 0 8px;color:var(--warn-tx)">只读来源——此 MCP 由 ${s.source === 'plugin' ? '插件' : '项目'}自带,编辑请前往 ${s.source === 'plugin' ? '插件目录' : '对应工作区目录'}手动修改。</div>` : ''}
161
+ ${!editable && !onlyRead ? `<div class="hint" style="margin:0 0 8px;color:var(--warn-tx)">用户级·本机来源不支持页面编辑(可删除);如需修改请在 ~/.claude.json 手动改,或删除后到本页重新添加。</div>` : ''}
162
+ <div data-pane="view">
163
+ <label>配置(JSON 原文)</label>
164
+ <textarea readonly rows="10" style="min-height:140px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
165
+ </div>
166
+ <div data-pane="edit" style="display:none">
167
+ <label>配置(JSON,可编辑——env 也在其中改)</label>
168
+ <textarea id="emJson" rows="12" style="min-height:180px">${esc(JSON.stringify(s.config, null, 2))}</textarea>
169
+ <div class="hint" style="margin-top:6px">保存后立即对新任务生效(bridge 自管 servers.json 每任务现读)。</div>
170
+ </div>
171
+ <label>环境变量(引用值 → 当前值;当前值已合并 飞书应用 env › Claude 认证 env › settings.json env › 系统环境,支持 \${VAR:-默认值})</label>
157
172
  <table><thead><tr><th style="width:130px">变量</th><th>配置值</th><th>当前值</th></tr></thead><tbody>${envRows}</tbody></table>`,
158
- footHtml: `<button class="btn" data-act="close">关闭</button>`,
159
- onMount({ foot }) {
173
+ footHtml: `${editable ? '<button class="btn" data-act="edit">编辑</button><button class="btn primary" data-act="save" style="display:none">保存</button>' : ''}<button class="btn" data-act="close">关闭</button>`,
174
+ onMount({ body, foot }) {
160
175
  foot.querySelector('[data-act="close"]').onclick = () => closeDrawer();
176
+ const editBtn = foot.querySelector('[data-act="edit"]');
177
+ const saveBtn = foot.querySelector('[data-act="save"]');
178
+ if (editBtn) {
179
+ editBtn.onclick = () => {
180
+ body.querySelector('[data-pane="view"]').style.display = 'none';
181
+ body.querySelector('[data-pane="edit"]').style.display = '';
182
+ editBtn.style.display = 'none';
183
+ saveBtn.style.display = '';
184
+ };
185
+ saveBtn.onclick = async () => {
186
+ const raw = body.querySelector('#emJson').value.trim();
187
+ let config;
188
+ try { config = JSON.parse(raw); } catch (e) { return toast(`JSON 解析失败:${e.message}`, true); }
189
+ try {
190
+ await api('POST', '/api/mcp/action', { op: 'update', name: s.name, config });
191
+ toast('已保存(任务级热生效,无须重启)');
192
+ closeDrawer();
193
+ void loadMcp();
194
+ } catch (e) { toast(e.message, true); }
195
+ };
196
+ }
161
197
  },
162
198
  });
163
199
  }
@@ -1,6 +1,7 @@
1
1
  // ============ 概览 ============
2
2
  // 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
3
3
  import { S, $, esc, toast, api, refresh } from '../core.js';
4
+ import { confirmDialog } from '../ui.js';
4
5
 
5
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
7
  const UPD = { checkedAt: 0, data: null };
@@ -54,9 +55,9 @@ async function startFlow() {
54
55
  async function stopFlow() {
55
56
  const embedded = !!S.status?.embedded;
56
57
  const msg = embedded
57
- ? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。确定停止?'
58
- : '停止后机器人将不再响应消息。确定停止桥接器进程?';
59
- if (!confirm(msg)) return;
58
+ ? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。'
59
+ : '停止后机器人将不再响应消息。';
60
+ if (!(await confirmDialog({ title: '停止桥接器', message: msg, danger: true, confirmText: '停止' }))) return;
60
61
  try {
61
62
  const r = await bridgeActionSafe('stop');
62
63
  if (embedded) {
@@ -119,7 +120,7 @@ async function doCheckUpdate() {
119
120
  async function runUpdateFlow() {
120
121
  const running = S.status?.embedded || S.status?.bridge?.running;
121
122
  const extra = running ? ',完成后自动重启桥接器(页面将短暂失联后自动恢复)' : '(桥接器未在运行,下次启动生效)';
122
- if (!confirm(`将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。继续?`)) return;
123
+ if (!(await confirmDialog({ title: '一键更新', message: `将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。`, confirmText: '开始更新' }))) return;
123
124
  const btn = $('#btnUpdRun');
124
125
  if (btn) { btn.disabled = true; btn.textContent = '更新中…'; }
125
126
  try {
@@ -1,48 +1,56 @@
1
- // ============ 权限 ============
1
+ // ============ 权限(页内一级 tab:免确认工具 / 危险命令黑名单) ============
2
2
  import { S, $, esc, toast, saveDoc } from '../core.js';
3
+ import { confirmDialog } from '../ui.js';
3
4
 
4
- let permTab = 'allow'; // 页内子 tab 状态:免确认工具 / 危险命令黑名单(重渲染保持)
5
+ let permTab = 'allow'; // tab 状态:切换 / 重渲染保持
5
6
  function renderPermissions(el) {
6
7
  const p = S.doc.permissions || (S.doc.permissions = {});
7
8
  p.allow_tools = p.allow_tools || [];
8
9
  p.dangerous_commands = p.dangerous_commands || [];
9
10
  el.innerHTML = `
10
- <div class="card">
11
- <div class="subtabs">
12
- <button data-t="allow" class="${permTab === 'allow' ? 'active' : ''}">免确认工具</button>
13
- <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单</button>
14
- </div>
15
- <div id="permPanel"></div>
16
- </div>`;
17
- el.querySelectorAll('.subtabs button').forEach((b) =>
11
+ <div class="tabs">
12
+ <button data-t="allow" class="${permTab === 'allow' ? 'active' : ''}">免确认工具</button>
13
+ <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单</button>
14
+ </div>
15
+ <div id="permPanel"></div>`;
16
+ el.querySelectorAll('.tabs button').forEach((b) =>
18
17
  b.onclick = () => { permTab = b.dataset.t; renderPermissions(el); });
19
18
  const panel = $('#permPanel');
20
- if (permTab === 'danger') renderDangerTab(panel, p, el);
21
- else renderAllowTab(panel, p, el);
19
+ if (permTab === 'danger') renderDangerTab(panel, p);
20
+ else renderAllowTab(panel, p);
22
21
  }
23
22
 
24
- function renderAllowTab(panel, p, el) {
23
+ function renderAllowTab(panel, p) {
25
24
  const defaults = S.status?.permissionDefaults?.allowTools || [];
26
25
  const isDefault = defaults.length > 0 && JSON.stringify(p.allow_tools) === JSON.stringify(defaults);
27
26
  panel.innerHTML = `
27
+ <div class="card">
28
+ <h3>免确认工具</h3>
28
29
  <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
29
- <div class="row" style="max-width:480px;margin:8px 0 10px">
30
- <input type="text" id="newTool" placeholder="工具名,如 Write">
31
- <div class="btn-wrap"><button class="btn sm" id="addTool">添加</button></div>
32
- <div class="btn-wrap"><button class="btn sm" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
30
+ <div class="list-toolbar">
31
+ <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:320px">
32
+ <button class="btn" id="addTool">添加</button>
33
+ <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
33
34
  </div>
34
- ${p.allow_tools.length === 0 ? '<div style="background:#fff3e0;color:var(--warn);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
35
+ ${p.allow_tools.length === 0 ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
35
36
  ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
36
37
  <table><tbody id="toolBody"></tbody></table>
37
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
38
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
39
+ </div>`;
38
40
  $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
39
41
  `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
40
42
  || '<tr><td colspan="2" class="hint">(空)</td></tr>';
41
43
  // 即时保存:低风险高频操作,免去「改完再点保存」两步;失败回滚内存保持一致
42
44
  $('#toolBody').querySelectorAll('button').forEach((b) =>
43
45
  b.onclick = async () => {
46
+ const i = Number(b.dataset.i);
47
+ if (!(await confirmDialog({
48
+ title: '移出免确认名单',
49
+ message: `确定将工具 <code>${esc(p.allow_tools[i])}</code> 移出免确认名单?移出后该工具的调用将恢复弹确认卡。`,
50
+ confirmText: '移出',
51
+ }))) return;
44
52
  const old = [...p.allow_tools];
45
- p.allow_tools.splice(Number(b.dataset.i), 1);
53
+ p.allow_tools.splice(i, 1);
46
54
  if (!await saveDoc()) p.allow_tools = old;
47
55
  });
48
56
  $('#addTool').onclick = async () => {
@@ -54,34 +62,48 @@ function renderAllowTab(panel, p, el) {
54
62
  };
55
63
  $('#resetTools').onclick = async () => {
56
64
  if (!defaults.length) return;
57
- if (!window.confirm('恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。')) return;
65
+ if (!(await confirmDialog({
66
+ title: '恢复默认名单',
67
+ message: '恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。',
68
+ confirmText: '恢复默认',
69
+ }))) return;
58
70
  const old = [...p.allow_tools];
59
71
  p.allow_tools = [...defaults];
60
72
  if (!await saveDoc()) p.allow_tools = old;
61
73
  };
62
74
  }
63
75
 
64
- function renderDangerTab(panel, p, el) {
76
+ function renderDangerTab(panel, p) {
65
77
  const defaults = S.status?.permissionDefaults?.dangerousCommands || [];
66
78
  const isDefault = defaults.length > 0 && JSON.stringify(p.dangerous_commands) === JSON.stringify(defaults);
67
79
  panel.innerHTML = `
80
+ <div class="card">
81
+ <h3>危险命令黑名单</h3>
68
82
  <div class="desc">正则源串(不区分大小写),命中即弹确认卡。删除正则 = 放宽对应命令为直通,请谨慎。</div>
69
- <div class="row" style="max-width:680px;margin:8px 0 10px">
70
- <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force">
71
- <div class="btn-wrap"><button class="btn sm" id="addDc">添加</button></div>
72
- <div class="btn-wrap"><button class="btn sm" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
83
+ <div class="list-toolbar">
84
+ <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force" style="max-width:480px">
85
+ <button class="btn" id="addDc">添加</button>
86
+ <button class="btn" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
73
87
  </div>
74
- ${p.dangerous_commands.length === 0 ? '<div style="background:#fee;color:var(--danger);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
88
+ ${p.dangerous_commands.length === 0 ? '<div style="background:var(--err-bg);color:var(--err-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
75
89
  ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
76
90
  <table><tbody id="dcBody"></tbody></table>
77
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>`;
91
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
92
+ </div>`;
78
93
  $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
79
94
  `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
80
95
  || '<tr><td colspan="2" class="hint">(空)</td></tr>';
81
96
  $('#dcBody').querySelectorAll('button').forEach((b) =>
82
97
  b.onclick = async () => {
98
+ const i = Number(b.dataset.i);
99
+ if (!(await confirmDialog({
100
+ title: '删除黑名单正则',
101
+ message: `确定删除正则 <code>${esc(p.dangerous_commands[i])}</code>?删除后对应命令将免确认直通,请确认有意为之。`,
102
+ danger: true,
103
+ confirmText: '删除',
104
+ }))) return;
83
105
  const old = [...p.dangerous_commands];
84
- p.dangerous_commands.splice(Number(b.dataset.i), 1);
106
+ p.dangerous_commands.splice(i, 1);
85
107
  if (!await saveDoc()) p.dangerous_commands = old;
86
108
  });
87
109
  $('#addDc').onclick = async () => {
@@ -94,7 +116,11 @@ function renderDangerTab(panel, p, el) {
94
116
  };
95
117
  $('#resetDc').onclick = async () => {
96
118
  if (!defaults.length) return;
97
- if (!window.confirm('恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。')) return;
119
+ if (!(await confirmDialog({
120
+ title: '恢复默认黑名单',
121
+ message: '恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。',
122
+ confirmText: '恢复默认',
123
+ }))) return;
98
124
  const old = [...p.dangerous_commands];
99
125
  p.dangerous_commands = [...defaults];
100
126
  if (!await saveDoc()) p.dangerous_commands = old;
@@ -1,5 +1,6 @@
1
1
  // ============ 插件 ============
2
2
  import { $, esc, toast, api } from '../core.js';
3
+ import { confirmDialog } from '../ui.js';
3
4
 
4
5
  // 点击 combobox 之外收起面板的监听只注册一次(模块级标志,等价原 document.body 标记法)
5
6
  let comboHideBound = false;
@@ -76,12 +77,25 @@ async function renderPlugins(el) {
76
77
  <button class="btn sm danger" data-op="uninstall" data-arg="${esc(p.key)}" data-dir="${p.source}">卸载</button>
77
78
  </td>
78
79
  </tr>`).join('');
79
- body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
80
+ body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = async () => {
80
81
  const { op, arg, dir } = b.dataset;
81
- // 影响性操作先二次确认(启用/安装/市场管理为新增类,无需确认)
82
- if (op === 'disable' && !window.confirm(`确定要停用插件「${arg}」吗?\n停用后下一条消息不再加载该插件,可随时再启用。`)) return;
83
- if (op === 'update' && !window.confirm(`确定要更新插件「${arg}」吗?\n更新后下一条消息按新版本加载。`)) return;
84
- if (op === 'uninstall' && !window.confirm(`确定要卸载插件「${arg}」吗?\n卸载后需重新安装才能恢复,进行中的会话不受影响。`)) return;
82
+ // 影响性操作先二次确认(启用/安装/市场添加为新增类,无需确认)
83
+ if (op === 'disable' && !(await confirmDialog({
84
+ title: '停用插件',
85
+ message: `确定停用插件 <code>${esc(arg)}</code> 吗?停用后下一条消息不再加载该插件,可随时再启用。`,
86
+ confirmText: '停用',
87
+ }))) return;
88
+ if (op === 'update' && !(await confirmDialog({
89
+ title: '更新插件',
90
+ message: `确定更新插件 <code>${esc(arg)}</code> 吗?更新后下一条消息按新版本加载。`,
91
+ confirmText: '更新',
92
+ }))) return;
93
+ if (op === 'uninstall' && !(await confirmDialog({
94
+ title: '卸载插件',
95
+ message: `确定卸载插件 <code>${esc(arg)}</code> 吗?卸载后需重新安装才能恢复,进行中的会话不受影响。`,
96
+ danger: true,
97
+ confirmText: '卸载',
98
+ }))) return;
85
99
  // 慢操作走进度弹框;亚秒级启停走轻量 act(弹框一闪而过反而干扰)
86
100
  if (op === 'enable' || op === 'disable') act(op, arg, dir, b);
87
101
  else runOpDialog(op, arg, dir, b);
@@ -273,7 +287,16 @@ async function renderPlugins(el) {
273
287
  $('#plDir').value = $('#plMarketDir').value;
274
288
  runOpDialog('marketplace-add', v, $('#plMarketDir').value, $('#plDoMarketAdd'));
275
289
  };
276
- $('#plDoMarketUpdate').onclick = () => runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
290
+ $('#plDoMarketUpdate').onclick = async () => {
291
+ // 影响面大(该目录全部已装插件逐个更新):先二次确认
292
+ const dirLabel = $('#plMarketDir').value === 'bridge' ? 'bridge 托管目录' : '本机 ~/.claude';
293
+ if (!(await confirmDialog({
294
+ title: '全部更新',
295
+ message: `将刷新市场索引,并逐个更新「${dirLabel}」下的全部已装插件。确定继续?`,
296
+ confirmText: '全部更新',
297
+ }))) return;
298
+ runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
299
+ };
277
300
  await Promise.all([load(), loadAvailable()]);
278
301
  }
279
302