@jesonliu/lark-claudecode-bridge 0.11.0 → 0.14.2

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 (82) hide show
  1. package/README.md +89 -49
  2. package/assets/web/index.html +1769 -0
  3. package/config.example.yaml +22 -2
  4. package/dist/auth-precheck.d.ts +5 -2
  5. package/dist/auth-precheck.js +8 -2
  6. package/dist/auth-precheck.js.map +1 -1
  7. package/dist/bin/lcb.js +32 -3
  8. package/dist/bin/lcb.js.map +1 -1
  9. package/dist/claude-config.d.ts +24 -0
  10. package/dist/claude-config.js +143 -0
  11. package/dist/claude-config.js.map +1 -0
  12. package/dist/cli/setup-wizard.d.ts +1 -0
  13. package/dist/cli/setup-wizard.js +4 -0
  14. package/dist/cli/setup-wizard.js.map +1 -1
  15. package/dist/config-defaults.d.ts +4 -0
  16. package/dist/config-defaults.js +15 -0
  17. package/dist/config-defaults.js.map +1 -0
  18. package/dist/config.d.ts +7 -0
  19. package/dist/config.js +207 -6
  20. package/dist/config.js.map +1 -1
  21. package/dist/executor/claude-executor.d.ts +7 -2
  22. package/dist/executor/claude-executor.js +62 -17
  23. package/dist/executor/claude-executor.js.map +1 -1
  24. package/dist/executor/output-collector.d.ts +2 -0
  25. package/dist/executor/output-collector.js +15 -3
  26. package/dist/executor/output-collector.js.map +1 -1
  27. package/dist/executor/permission-gate.d.ts +37 -3
  28. package/dist/executor/permission-gate.js +86 -16
  29. package/dist/executor/permission-gate.js.map +1 -1
  30. package/dist/executor/plugin-discovery.d.ts +30 -0
  31. package/dist/executor/plugin-discovery.js +68 -1
  32. package/dist/executor/plugin-discovery.js.map +1 -1
  33. package/dist/executor/plugin-manager.d.ts +35 -0
  34. package/dist/executor/plugin-manager.js +109 -0
  35. package/dist/executor/plugin-manager.js.map +1 -0
  36. package/dist/feishu/slash-commands.d.ts +77 -0
  37. package/dist/feishu/slash-commands.js +188 -0
  38. package/dist/feishu/slash-commands.js.map +1 -0
  39. package/dist/gateway/card-builder.d.ts +18 -0
  40. package/dist/gateway/card-builder.js +61 -1
  41. package/dist/gateway/card-builder.js.map +1 -1
  42. package/dist/gateway/feishu-gateway.d.ts +10 -0
  43. package/dist/gateway/feishu-gateway.js +4 -0
  44. package/dist/gateway/feishu-gateway.js.map +1 -1
  45. package/dist/gateway/progress-card.d.ts +8 -0
  46. package/dist/gateway/progress-card.js +16 -0
  47. package/dist/gateway/progress-card.js.map +1 -1
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +210 -28
  50. package/dist/index.js.map +1 -1
  51. package/dist/session/commands.d.ts +16 -1
  52. package/dist/session/commands.js +77 -17
  53. package/dist/session/commands.js.map +1 -1
  54. package/dist/session/plugin-command.d.ts +14 -0
  55. package/dist/session/plugin-command.js +110 -0
  56. package/dist/session/plugin-command.js.map +1 -0
  57. package/dist/session/session-store.d.ts +14 -1
  58. package/dist/session/session-store.js +15 -0
  59. package/dist/session/session-store.js.map +1 -1
  60. package/dist/types.d.ts +69 -1
  61. package/dist/util/open-browser.d.ts +1 -0
  62. package/dist/util/open-browser.js +20 -0
  63. package/dist/util/open-browser.js.map +1 -0
  64. package/dist/util/server-url.d.ts +3 -0
  65. package/dist/util/server-url.js +10 -0
  66. package/dist/util/server-url.js.map +1 -0
  67. package/dist/web/config-api.d.ts +52 -0
  68. package/dist/web/config-api.js +186 -0
  69. package/dist/web/config-api.js.map +1 -0
  70. package/dist/web/lifecycle.d.ts +52 -0
  71. package/dist/web/lifecycle.js +167 -0
  72. package/dist/web/lifecycle.js.map +1 -0
  73. package/dist/web/model-list.d.ts +36 -0
  74. package/dist/web/model-list.js +101 -0
  75. package/dist/web/model-list.js.map +1 -0
  76. package/dist/web/server.d.ts +22 -0
  77. package/dist/web/server.js +618 -0
  78. package/dist/web/server.js.map +1 -0
  79. package/dist/web/update.d.ts +26 -0
  80. package/dist/web/update.js +64 -0
  81. package/dist/web/update.js.map +1 -0
  82. package/package.json +2 -1
@@ -0,0 +1,1769 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>飞书ClaudeCode桥接器</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f5f6f8; --card: #fff; --border: #e2e5ea; --text: #1f2329; --muted: #646a73;
10
+ --primary: #3370ff; --danger: #f54f4f; --ok: #34c724; --warn: #ff8800; --radius: 10px;
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body { margin: 0; font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
14
+ header { background: var(--card); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
15
+ header h1 { font-size: 17px; margin: 0; display: flex; align-items: center; gap: 9px; }
16
+ header .meta { color: var(--muted); font-size: 12px; }
17
+ header .spacer { flex: 1; }
18
+ .layout { display: flex; min-height: calc(100vh - 61px); }
19
+ nav { width: 170px; padding: 16px 8px; background: var(--card); border-right: 1px solid var(--border); }
20
+ nav button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 14px; margin: 2px 0; border: 0; background: none; border-radius: 8px; cursor: pointer; font-size: 14px; color: var(--text); }
21
+ nav button .ico { display: flex; align-items: center; }
22
+ nav button:hover { background: var(--bg); }
23
+ nav button.active { background: #eef3ff; color: var(--primary); font-weight: 600; }
24
+ main { flex: 1; padding: 20px 32px; }
25
+ @media (min-width: 1800px) { main { max-width: 1560px; } }
26
+ @media (max-width: 860px) {
27
+ .layout { flex-direction: column; }
28
+ nav { width: auto; display: flex; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); }
29
+ }
30
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; margin-bottom: 16px; }
31
+ .card h3 { margin: 0 0 4px; font-size: 15px; }
32
+ .card .desc { color: var(--muted); font-size: 12px; margin-bottom: 12px; }
33
+ label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
34
+ input[type=text], input[type=password], input[type=number], select, textarea {
35
+ width: 100%; padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; font: inherit; background: #fff; color: var(--text);
36
+ }
37
+ input:focus, select:focus, textarea:focus { outline: 2px solid #c9d8ff; border-color: var(--primary); }
38
+ textarea { font-family: Consolas, monospace; font-size: 12.5px; min-height: 90px; resize: vertical; }
39
+ .row { display: flex; gap: 12px; } .row > * { flex: 1; min-width: 0; }
40
+ /* .row 内按钮按内容宽:内联 flex:0 展开为 0 1 0% 会把按钮压到单字宽(一字一行),一律走类规则 */
41
+ .row > .btn, .row > .btn-wrap { flex: 0 0 auto; align-self: end; }
42
+ .btn-wrap { display: flex; }
43
+ .btn, nav button, .savebar .btn, .subtabs button, .chip { white-space: nowrap; }
44
+ .btn { padding: 7px 16px; border-radius: 7px; border: 1px solid var(--border); background: var(--card); cursor: pointer; font: inherit; }
45
+ .btn:hover { border-color: var(--primary); color: var(--primary); }
46
+ .btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
47
+ .btn.danger { color: var(--danger); } .btn.danger:hover { border-color: var(--danger); }
48
+ .btn.sm { padding: 3px 10px; font-size: 12px; }
49
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
50
+ .chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg); border: 1px solid var(--border); padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-family: Consolas, monospace; }
51
+ .chip button { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; font-size: 13px; }
52
+ .chip button:hover { color: var(--danger); }
53
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
54
+ th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
55
+ th { color: var(--muted); font-weight: 500; font-size: 12px; }
56
+ .tag { display: inline-block; padding: 1px 8px; border-radius: 5px; font-size: 11.5px; }
57
+ .tag.ok { background: #e8f7e6; color: var(--ok); } .tag.off { background: var(--bg); color: var(--muted); }
58
+ .tag.warn { background: #fff3e0; color: var(--warn); } .tag.err { background: #fee; color: var(--danger); }
59
+ .radio-row { display: flex; gap: 20px; margin: 8px 0; }
60
+ .radio-row label { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--text); font-size: 13.5px; cursor: pointer; }
61
+ #toast { position: fixed; top: 24px; left: 50%; transform: translateX(-50%); background: #2b2f36; color: #fff; padding: 10px 22px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .25s; pointer-events: none; max-width: 80vw; z-index: 100; }
62
+ #toast.show { opacity: .95; }
63
+ #toast.err { background: var(--danger); }
64
+ /* 桥接器重启/已离线全页遮罩 */
65
+ .br-mask { position: fixed; inset: 0; background: rgba(15,19,24,.5); z-index: 90; display: flex; align-items: center; justify-content: center; }
66
+ .br-mask-card { background: var(--card); border-radius: 12px; padding: 24px 32px; text-align: center; box-shadow: 0 8px 30px rgba(0,0,0,.18); max-width: 440px; font-size: 14px; }
67
+ .br-spin { width: 26px; height: 26px; margin: 0 auto 12px; border: 3px solid #d9e2f5; border-top-color: var(--primary); border-radius: 50%; animation: brspin .9s linear infinite; }
68
+ /* 表格内小号 spinner(斜杠命令远端拉取等异步加载占位) */
69
+ .br-spin-sm { width: 18px; height: 18px; margin: 0 auto 10px; border: 2px solid #d9e2f5; border-top-color: var(--primary); border-radius: 50%; animation: brspin .9s linear infinite; }
70
+ @keyframes brspin { to { transform: rotate(360deg); } }
71
+ .app-card { border: 1px solid var(--border); border-radius: 8px; padding: 4px 16px 14px; margin-bottom: 12px; position: relative; }
72
+ .app-card .del { position: absolute; top: 10px; right: 12px; }
73
+ .hint { font-size: 12px; color: var(--muted); }
74
+ .footer-note { color: var(--muted); font-size: 12px; margin-top: 8px; }
75
+ pre.report { background: #f7f8fa; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font-size: 12px; overflow: auto; white-space: pre-wrap; }
76
+ .savebar { display: flex; gap: 10px; margin-top: 12px; align-items: center; }
77
+ .savebar .state { font-size: 12px; color: var(--warn); }
78
+ .card h3 .dirty-dot { display: none; margin-left: 6px; font-size: 11.5px; color: var(--warn); font-weight: 500; }
79
+ .stale-bar { background: #fff3e0; color: var(--warn); padding: 8px 24px; font-size: 13px; border-bottom: 1px solid var(--border); }
80
+ .stale-bar[hidden] { display: none; }
81
+ /* 右侧抽屉:应用编辑 / 斜杆命令编辑 / 档案编辑共用 */
82
+ .drawer-mask { position: fixed; inset: 0; background: rgba(15,19,24,.36); z-index: 50; display: flex; justify-content: flex-end; }
83
+ .drawer-mask[hidden] { display: none; }
84
+ .drawer { width: min(980px, 94vw); height: 100%; background: var(--card); border-left: 1px solid var(--border); display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(0,0,0,.08); }
85
+ .drawer > header { display: flex; align-items: center; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid var(--border); }
86
+ .drawer > header h3 { margin: 0; font-size: 15px; }
87
+ .drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
88
+ .drawer-foot { padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 10px; align-items: center; }
89
+ .drawer-foot .spacer { flex: 1; }
90
+ .subtabs { display: flex; gap: 8px; margin-bottom: 14px; }
91
+ .subtabs button { padding: 6px 14px; border-radius: 999px; border: 1px solid var(--border); background: none; cursor: pointer; font: inherit; color: var(--text); }
92
+ .subtabs button.active { background: #eef3ff; color: var(--primary); border-color: var(--primary); font-weight: 600; }
93
+ /* 居中 modal:目录选择等轻量交互(z-index 低于全页断连遮罩 br-mask=90) */
94
+ .modal-mask { position: fixed; inset: 0; background: rgba(15,19,24,.42); z-index: 80; display: flex; align-items: center; justify-content: center; }
95
+ .modal { width: min(560px, 92vw); background: var(--card); border-radius: 12px; padding: 16px 20px; box-shadow: 0 8px 30px rgba(0,0,0,.18); }
96
+ .dir-list { border: 1px solid var(--border); border-radius: 8px; max-height: 46vh; overflow-y: auto; margin-top: 10px; }
97
+ .dir-list button { display: block; width: 100%; text-align: left; padding: 7px 12px; border: 0; border-bottom: 1px solid var(--border); background: none; cursor: pointer; font: inherit; }
98
+ .dir-list button:last-child { border-bottom: 0; }
99
+ .dir-list button:hover { background: #eef3ff; color: var(--primary); }
100
+ .dir-list .empty { padding: 14px 12px; color: var(--muted); font-size: 12.5px; }
101
+ /* 输入框 + 行内按钮组合(路径框带「浏览」) */
102
+ .input-btn { display: flex; gap: 6px; }
103
+ .input-btn > input { flex: 1; min-width: 0; }
104
+ .input-btn > .btn { flex: 0 0 auto; align-self: center; }
105
+ /* 可搜索 combobox(安装插件选择器):输入过滤 + 结果面板 */
106
+ .combo { position: relative; }
107
+ .combo-panel { position: absolute; top: calc(100% + 4px); left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.14); max-height: 320px; overflow-y: auto; z-index: 40; }
108
+ .combo-panel[hidden] { display: none; }
109
+ .combo-item { padding: 7px 12px; border-bottom: 1px solid var(--border); cursor: pointer; }
110
+ .combo-item:last-child { border-bottom: 0; }
111
+ .combo-item:hover { background: #eef3ff; }
112
+ .combo-item b { margin-right: 6px; }
113
+ .combo-empty { padding: 12px; color: var(--muted); font-size: 12.5px; }
114
+ /* 操作进度弹框:进度条(update-all 真实 i/N,单操作时间缓动模拟) */
115
+ .prog { height: 8px; border-radius: 999px; background: var(--bg); border: 1px solid var(--border); overflow: hidden; margin-top: 12px; }
116
+ .prog-bar { height: 100%; width: 0%; background: var(--primary); border-radius: 999px; transition: width .3s; }
117
+ .prog-bar.done { background: var(--ok); }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <header>
122
+ <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>
123
+ <span class="meta" id="hMeta">加载中…</span>
124
+ <span class="spacer"></span>
125
+ <span class="meta" id="hFirstRun"></span>
126
+ </header>
127
+ <div class="stale-bar" id="staleBar" hidden>⚠️ 配置页服务进程为旧版本(页面文件已更新):进程启停 / 重启 / 一键更新需重启桥接器(<code>lcb start</code> 或页面所在进程)后可用。</div>
128
+ <div class="layout">
129
+ <nav id="nav"></nav>
130
+ <main id="main"></main>
131
+ </div>
132
+ <div class="drawer-mask" id="drawerMask" hidden>
133
+ <aside class="drawer" role="dialog" aria-modal="true">
134
+ <header><h3 id="drawerTitle"></h3><button class="btn" id="drawerClose">✕ 关闭</button></header>
135
+ <div class="drawer-body" id="drawerBody"></div>
136
+ <footer class="drawer-foot" id="drawerFoot"></footer>
137
+ </aside>
138
+ </div>
139
+ <div id="toast"></div>
140
+ <script>
141
+ 'use strict';
142
+ // ============ 全局状态 ============
143
+ // 一步保存:无全局 savebar。抽屉编辑自带「保存/取消」(打开前拍快照);页内表单卡各自带
144
+ // 保存按钮(dirtyCards 跟踪未保存卡);行内低风险操作(权限增删)直接落盘
145
+ const S = { status: null, doc: null, firstRun: false, dirtyCards: new Set() };
146
+ const $ = (sel, el = document) => el.querySelector(sel);
147
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
148
+
149
+ function toast(msg, isErr) {
150
+ const t = $('#toast');
151
+ t.textContent = msg;
152
+ t.className = 'show' + (isErr ? ' err' : '');
153
+ clearTimeout(t._h);
154
+ t._h = setTimeout(() => { t.className = ''; }, isErr ? 6000 : 3200);
155
+ }
156
+ async function api(method, path, body) {
157
+ const res = await fetch(path, {
158
+ method,
159
+ headers: body !== undefined ? { 'Content-Type': 'application/json; charset=utf-8' } : undefined,
160
+ body: body !== undefined ? JSON.stringify(body) : undefined,
161
+ });
162
+ const data = await res.json().catch(() => ({}));
163
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
164
+ return data;
165
+ }
166
+ /** doc 深拷贝快照(抽屉取消回滚 / 打开前留存) */
167
+ const snapDoc = () => JSON.parse(JSON.stringify(S.doc));
168
+ /** 页内表单卡未保存标记:cardDirty('ws', true) 显示卡头「未保存」小标 + 卡底保存行,并记入集合 */
169
+ function cardDirty(id, on) {
170
+ if (on) S.dirtyCards.add(id); else S.dirtyCards.delete(id);
171
+ const dot = $(`#dirty-${id}`);
172
+ if (dot) dot.style.display = on ? 'inline' : 'none'; // '' 会回落到 CSS 默认 display:none,圆点永不显示
173
+ const bar = $(`#bar-${id}`);
174
+ if (bar) bar.style.display = on ? 'flex' : 'none';
175
+ }
176
+ /** 整卡重渲染(innerHTML 重建)后回放各卡 dirty 可见性:集合是真相源,新建的 bar/dot 默认隐藏 */
177
+ function applyDirty() { for (const id of S.dirtyCards) cardDirty(id, true); }
178
+ /** 卡级保存行(页内表单卡通用):保存 = saveDoc 落盘;还原 = 重拉磁盘配置 */
179
+ const saveBarHtml = (id) => `<div class="savebar" id="bar-${id}" style="display:none"><span class="state">有未保存的修改</span><span style="flex:1"></span><button class="btn" data-discard="${id}">还原</button><button class="btn primary" data-save="${id}">保存</button></div>`;
180
+ const dirtyDotHtml = (id) => `<span class="dirty-dot" id="dirty-${id}">· 未保存</span>`;
181
+ function bindSaveBar(el, id) {
182
+ el.querySelector(`[data-save="${id}"]`)?.addEventListener('click', () => void saveDoc());
183
+ el.querySelector(`[data-discard="${id}"]`)?.addEventListener('click', async () => {
184
+ await refresh();
185
+ toast('已还原为磁盘配置');
186
+ });
187
+ }
188
+ /** 全量 PUT 落盘(所有保存路径共用);返回是否成功(失败保持当前编辑态) */
189
+ async function saveDoc() {
190
+ if (S.firstRun) return false;
191
+ try {
192
+ const r = await api('PUT', '/api/config', S.doc);
193
+ if (r.restartRequired?.length) toast(`已保存。注意:${r.restartRequired.join('、')} 段改动需重启 lcb start 后生效`, true);
194
+ else toast('已保存(运行中的桥接器下一条消息自动生效)');
195
+ S.dirtyCards.clear();
196
+ await refresh();
197
+ return true;
198
+ } catch (e) { toast(`保存失败:${e.message}`, true); return false; }
199
+ }
200
+
201
+ // ============ 目录选择弹层(工作区路径等;数据源 GET /api/fs/dirs) ============
202
+ /** 后端返回的绝对路径 + 子目录名拼完整路径(Windows 反斜杠 / posix 正斜杠,按 base 自带分隔符判断) */
203
+ const joinPath = (base, name) => {
204
+ if (!base) return name;
205
+ const sepCh = base.includes('\\') ? '\\' : '/';
206
+ return base.replace(/[\\/]+$/, '') + sepCh + name;
207
+ };
208
+ /**
209
+ * 打开目录浏览弹层:盘符/根层 → 逐级进入 → 「选择此目录」确定。
210
+ * resolve(选中的绝对路径) / resolve(null)(取消)。手输路径可直接跳转,
211
+ * 路径不存在等错误在弹层内联提示(不关层、可改可回退)。
212
+ */
213
+ function pickDirectory(initialPath) {
214
+ return new Promise((resolveP) => {
215
+ const mask = document.createElement('div');
216
+ mask.className = 'modal-mask';
217
+ mask.innerHTML = `
218
+ <div class="modal" role="dialog" aria-modal="true">
219
+ <h3 style="margin:0 0 10px">选择目录</h3>
220
+ <div class="row">
221
+ <input type="text" class="pd-jump" placeholder="输入或粘贴路径,回车前往">
222
+ <button class="btn pd-go" title="跳转到输入的路径">前往</button>
223
+ <button class="btn pd-up" title="返回上一级">⬆ 上级</button>
224
+ </div>
225
+ <div class="hint pd-cur" style="margin:8px 0 0;word-break:break-all"></div>
226
+ <div class="dir-list"><div class="empty">加载中…</div></div>
227
+ <div class="savebar">
228
+ <span class="hint pd-note"></span><span style="flex:1"></span>
229
+ <button class="btn pd-cancel">取消</button>
230
+ <button class="btn primary pd-ok">选择此目录</button>
231
+ </div>
232
+ </div>`;
233
+ document.body.appendChild(mask);
234
+ const list = mask.querySelector('.dir-list');
235
+ const cur = mask.querySelector('.pd-cur');
236
+ const upBtn = mask.querySelector('.pd-up');
237
+ const okBtn = mask.querySelector('.pd-ok');
238
+ const note = mask.querySelector('.pd-note');
239
+ const jumpInput = mask.querySelector('.pd-jump');
240
+ let current = ''; // '' = 盘符/根层(此层不可作为选择结果)
241
+ let currentParent = null;
242
+ const close = (val) => { mask.remove(); resolveP(val); };
243
+ const render = (r) => {
244
+ current = r.path || '';
245
+ currentParent = r.parent;
246
+ cur.textContent = r.drives ? '此电脑(选择一个盘符)' : (r.path || '');
247
+ upBtn.hidden = !r.parent;
248
+ okBtn.disabled = !current;
249
+ note.textContent = r.error || '';
250
+ const items = r.drives
251
+ ? r.drives.map((d) => ({ label: d, next: d }))
252
+ : (r.dirs || []).map((d) => ({ label: d, next: joinPath(current, d) }));
253
+ list.innerHTML = items.length
254
+ ? items.map((it) => `<button data-next="${esc(it.next)}">📁 ${esc(it.label)}</button>`).join('')
255
+ : `<div class="empty">${r.error ? '(上述路径无法列出子目录)' : '(没有子目录)'}</div>`;
256
+ list.querySelectorAll('button[data-next]').forEach((b) => b.onclick = () => go(b.dataset.next));
257
+ };
258
+ const go = async (p) => {
259
+ list.innerHTML = '<div class="empty">加载中…</div>';
260
+ try { render(await api('GET', `/api/fs/dirs?p=${encodeURIComponent(p)}`)); }
261
+ catch (e) { list.innerHTML = `<div class="empty">加载失败:${esc(e.message)}</div>`; }
262
+ };
263
+ const jump = () => { const v = jumpInput.value.trim(); if (v) go(v); };
264
+ mask.querySelector('.pd-go').onclick = jump;
265
+ jumpInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); jump(); } };
266
+ upBtn.onclick = () => { if (currentParent) go(currentParent); };
267
+ okBtn.onclick = () => { if (current) close(current); };
268
+ mask.querySelector('.pd-cancel').onclick = () => close(null);
269
+ mask.onclick = (e) => { if (e.target === mask) close(null); };
270
+ // 初始位置:现值路径存在则从它开始,否则停在盘符/根层(跳转框预填现值方便改错)
271
+ jumpInput.value = initialPath || '';
272
+ go(initialPath || '');
273
+ });
274
+ }
275
+
276
+ // ============ Tab 注册 ============
277
+ // 菜单图标:内联 SVG 线性图标(Lucide 风格,stroke:currentColor 自动跟随选中态配色)
278
+ const ico = (inner) => `<span class="ico"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg></span>`;
279
+ const ICONS = {
280
+ overview: '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
281
+ apps: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
282
+ workspaces: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
283
+ claude: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
284
+ permissions: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1 1 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
285
+ slash: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
286
+ plugins: '<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z"/>',
287
+ };
288
+ const TABS = [
289
+ { key: 'overview', title: '概览', icon: 'overview' },
290
+ { key: 'apps', title: '飞书应用', icon: 'apps' },
291
+ { key: 'workspaces', title: '工作区', icon: 'workspaces' },
292
+ { key: 'claude', title: 'Claude 认证', icon: 'claude' },
293
+ { key: 'permissions', title: '权限', icon: 'permissions' },
294
+ { key: 'slash', title: '斜杠命令', icon: 'slash' },
295
+ { key: 'plugins', title: '插件', icon: 'plugins' },
296
+ ];
297
+ let activeTab = 'overview';
298
+ function renderNav() {
299
+ $('#nav').innerHTML = TABS.map((t) =>
300
+ `<button data-k="${t.key}" class="${t.key === activeTab ? 'active' : ''}">${ico(ICONS[t.icon])}<span>${t.title}</span></button>`).join('');
301
+ $('#nav').querySelectorAll('button').forEach((b) =>
302
+ b.onclick = () => {
303
+ // 未保存保护:抽屉编辑中 / 页内表单卡有未保存改动 → 确认后才切换(切走即丢弃)
304
+ if (!$('#drawerMask').hidden) {
305
+ if (!window.confirm('编辑抽屉尚未保存,确定放弃修改并切换页面?')) return;
306
+ cancelDrawer();
307
+ } else if (S.dirtyCards.size) {
308
+ if (!window.confirm(`有 ${S.dirtyCards.size} 处未保存的修改,切换页面将丢弃。确定切换?`)) return;
309
+ S.dirtyCards.clear();
310
+ }
311
+ activeTab = b.dataset.k;
312
+ render();
313
+ });
314
+ }
315
+
316
+ // ============ 通用右侧抽屉 ============
317
+ let drawerOnMount = null;
318
+ let drawerSnap = null; // 抽屉打开前的 doc 快照(取消回滚用;远端 CRUD 抽屉不传 snap 则不回滚)
319
+ /** 打开抽屉:snap = 调用方在 mutate doc 之前拍的快照;onMount({body, foot}) 里绑定事件 */
320
+ function openDrawer({ title, bodyHtml, footHtml, onMount, snap }) {
321
+ $('#drawerTitle').textContent = title;
322
+ $('#drawerBody').innerHTML = bodyHtml;
323
+ $('#drawerFoot').innerHTML = footHtml || '';
324
+ $('#drawerMask').hidden = false;
325
+ drawerSnap = snap ?? null;
326
+ drawerOnMount = onMount || null;
327
+ drawerOnMount?.({ body: $('#drawerBody'), foot: $('#drawerFoot') });
328
+ }
329
+ function closeDrawer() {
330
+ if ($('#drawerMask').hidden) return;
331
+ $('#drawerMask').hidden = true;
332
+ drawerOnMount = null;
333
+ drawerSnap = null;
334
+ }
335
+ /** 抽屉取消:回滚到打开前快照(含「新增未保存」行一并撤销)并重渲染 */
336
+ function cancelDrawer() {
337
+ if (drawerSnap) S.doc = JSON.parse(JSON.stringify(drawerSnap));
338
+ closeDrawer();
339
+ render();
340
+ }
341
+ // X / ESC / 点遮罩 = 取消:本地编辑抽屉(有快照)回滚未保存修改;远端 CRUD 抽屉直接关闭
342
+ const dismissDrawer = () => (drawerSnap ? cancelDrawer() : closeDrawer());
343
+ $('#drawerClose').onclick = dismissDrawer;
344
+ $('#drawerMask').addEventListener('mousedown', (e) => { if (e.target === e.currentTarget) dismissDrawer(); });
345
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') dismissDrawer(); });
346
+ function render() {
347
+ renderNav();
348
+ if (S.firstRun) return renderBootstrap();
349
+ const fn = { overview: renderOverview, apps: renderApps, workspaces: renderWorkspaces,
350
+ claude: renderClaude, permissions: renderPermissions, slash: renderSlash, plugins: renderPlugins }[activeTab];
351
+ $('#main').innerHTML = '';
352
+ fn($('#main'));
353
+ }
354
+
355
+ // ============ 首装向导(bootstrap) ============
356
+ function renderBootstrap() {
357
+ $('#main').innerHTML = `
358
+ <div class="card">
359
+ <h3>👋 欢迎使用 lark-claudecode-bridge</h3>
360
+ <div class="desc">首次安装:完成下面三步即可开始使用。需要先在 <a href="https://open.feishu.cn/app" target="_blank">飞书开放平台</a> 创建应用并启用机器人能力(详见 README)。建议为每个应用填写名字——概览页与飞书 /status 将显示该名字,缺省显示 App ID。</div>
361
+ <h3 style="margin-top:16px">① 飞书应用凭证</h3>
362
+ <div id="bsApps"></div>
363
+ <button class="btn sm" id="bsAddApp">+ 再添一个应用(多机器人)</button>
364
+ <h3 style="margin-top:20px">② 本机工作区(Claude 的工作目录)</h3>
365
+ <div class="row">
366
+ <div><label>名字(如 demo)</label><input type="text" id="bsWsName" placeholder="demo"></div>
367
+ <div><label>本机绝对路径</label>
368
+ <div class="input-btn">
369
+ <input type="text" id="bsWsPath" placeholder="F:\\workspace\\demo">
370
+ <button class="btn sm" id="bsWsBrowse" title="浏览选择目录">浏览</button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ <h3 style="margin-top:20px">③ Claude 认证(可稍后在「Claude 认证」页配置)</h3>
375
+ <div class="radio-row">
376
+ <label><input type="radio" name="bsMode" value="skip" checked> 使用本机已有 ~/.claude 登录</label>
377
+ <label><input type="radio" name="bsMode" value="managed"> 填 API Key / Token(无需本机登录)</label>
378
+ </div>
379
+ <div id="bsManaged" style="display:none">
380
+ <div class="radio-row">
381
+ <label><input type="radio" name="bsCred" value="auth_token" checked> AUTH_TOKEN(中转站常用)</label>
382
+ <label><input type="radio" name="bsCred" value="api_key"> API_KEY(官方)</label>
383
+ </div>
384
+ <div class="row">
385
+ <div><label id="bsCredLabel">Auth Token</label><input type="password" id="bsCred" placeholder="sk-…"></div>
386
+ <div><label>BASE_URL(中转站端点,官方留空)</label><input type="text" id="bsBaseUrl" placeholder="https://relay.example.com"></div>
387
+ </div>
388
+ <label>模型(如 claude-sonnet-5,可留空)</label><input type="text" id="bsModel" placeholder="claude-sonnet-5">
389
+ </div>
390
+ <div style="margin-top:20px">
391
+ <button class="btn primary" id="bsSubmit" style="padding:9px 28px">完成配置</button>
392
+ <span class="hint">提交后运行 <code>lcb start</code> 启动桥接器</span>
393
+ </div>
394
+ </div>`;
395
+ const appsBox = $('#bsApps');
396
+ const addAppRow = () => {
397
+ const div = document.createElement('div');
398
+ div.className = 'row';
399
+ div.style.marginBottom = '8px';
400
+ div.innerHTML = `
401
+ <div><label>App ID(cli_ 开头)</label><input type="text" class="bs-appid" placeholder="cli_aabbccddeeff0011"></div>
402
+ <div><label>应用名字(建议填写)</label><input type="text" class="bs-name" placeholder="如:素材收集(概览/状态页显示用)"></div>
403
+ <div><label>App Secret</label><input type="password" class="bs-secret" placeholder=""></div>
404
+ <div class="btn-wrap"><button class="btn sm danger bs-del">删除</button></div>`;
405
+ div.querySelector('.bs-del').onclick = () => div.remove();
406
+ appsBox.appendChild(div);
407
+ };
408
+ addAppRow();
409
+ $('#bsAddApp').onclick = addAppRow;
410
+ $('#bsWsBrowse').onclick = async () => {
411
+ const picked = await pickDirectory($('#bsWsPath').value.trim());
412
+ if (picked != null) $('#bsWsPath').value = picked;
413
+ };
414
+ document.querySelectorAll('input[name=bsMode]').forEach((r) => r.onchange = () => {
415
+ $('#bsManaged').style.display = $('[name=bsMode]:checked').value === 'managed' ? '' : 'none';
416
+ });
417
+ document.querySelectorAll('input[name=bsCred]').forEach((r) => r.onchange = () => {
418
+ $('#bsCredLabel').textContent = $('[name=bsCred]:checked').value === 'api_key' ? 'API Key' : 'Auth Token';
419
+ });
420
+ $('#bsSubmit').onclick = async () => {
421
+ const apps = [...appsBox.querySelectorAll('.row')].map((row) => {
422
+ const appId = row.querySelector('.bs-appid').value.trim();
423
+ const name = row.querySelector('.bs-name').value.trim();
424
+ const secret = row.querySelector('.bs-secret').value.trim();
425
+ return { app_id: appId, ...(name ? { name } : {}), app_secret: secret };
426
+ }).filter((a) => a.app_id && a.app_secret);
427
+ const ws = { name: $('#bsWsName').value.trim(), path: $('#bsWsPath').value.trim() };
428
+ if (!apps.length) return toast('至少需要一个有效的应用(App ID + App Secret)', true);
429
+ if (!ws.name || !ws.path) return toast('工作区名字与路径不能为空', true);
430
+ const body = { apps, workspace: ws };
431
+ if ($('[name=bsMode]:checked').value === 'managed') {
432
+ const cred = $('[name=bsCred]:checked').value;
433
+ const val = $('#bsCred').value.trim();
434
+ if (!val) return toast('请填写 Auth Token / API Key', true);
435
+ body.claude = { mode: 'managed', [cred]: val };
436
+ const baseUrl = $('#bsBaseUrl').value.trim();
437
+ const model = $('#bsModel').value.trim();
438
+ if (baseUrl) body.claude.base_url = baseUrl;
439
+ if (model) body.claude.model = model;
440
+ }
441
+ try {
442
+ const r = await api('POST', '/api/bootstrap', body);
443
+ toast(r.message || '配置完成');
444
+ await refresh();
445
+ toast('✅ 配置已写入,请运行 lcb start(若正在运行请重启)');
446
+ } catch (e) { toast(e.message, true); }
447
+ };
448
+ }
449
+
450
+ // ============ 概览 ============
451
+ // 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
452
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
453
+ const UPD = { checkedAt: 0, data: null };
454
+
455
+ function showMask(inner) {
456
+ let m = $('#brMask');
457
+ if (!m) {
458
+ m = document.createElement('div');
459
+ m.id = 'brMask'; m.className = 'br-mask';
460
+ document.body.appendChild(m);
461
+ }
462
+ m.innerHTML = `<div class="br-mask-card">${inner}</div>`;
463
+ m.style.display = 'flex';
464
+ }
465
+ function hideMask() { const m = $('#brMask'); if (m) m.style.display = 'none'; }
466
+
467
+ /** 启停请求的容错:embedded 停止/重启时进程退出可能先于响应到达,网络断开视为已受理 */
468
+ async function bridgeActionSafe(op) {
469
+ try { return await api('POST', '/api/bridge/action', { op }); }
470
+ catch (e) {
471
+ if (/fetch|network|load failed/i.test(e.message)) return { ok: true, message: '已提交(连接关闭属预期)' };
472
+ throw e;
473
+ }
474
+ }
475
+
476
+ /** 等配置页服务恢复(重启轮询):连续两次成功才确认,排除旧进程残影 */
477
+ async function waitServerBack(timeoutMs = 45000) {
478
+ const t0 = Date.now();
479
+ await sleep(2500); // 留给旧进程退场:立刻探测可能命中尚未退出的旧 server
480
+ while (Date.now() - t0 < timeoutMs) {
481
+ try {
482
+ await api('GET', '/api/status');
483
+ await sleep(1000);
484
+ await api('GET', '/api/status');
485
+ return;
486
+ } catch {}
487
+ await sleep(1500);
488
+ }
489
+ throw new Error('重启超时:请手动刷新页面查看状态');
490
+ }
491
+
492
+ async function startFlow() {
493
+ try {
494
+ const r = await bridgeActionSafe('start');
495
+ toast(r.message || '✅ 已在后台启动');
496
+ setTimeout(() => refresh(), 2500); // 后台进程写 PID / 起长连接需数秒
497
+ setTimeout(() => refresh(), 7000);
498
+ } catch (e) { toast(e.message, true); }
499
+ }
500
+
501
+ async function stopFlow() {
502
+ const embedded = !!S.status?.embedded;
503
+ const msg = embedded
504
+ ? '停止后本配置页将随桥接器进程一同关闭。\n之后可运行 lcb ui 重开配置页(并在其中重新启动桥接器)。确定停止?'
505
+ : '停止后机器人将不再响应消息。确定停止桥接器进程?';
506
+ if (!confirm(msg)) return;
507
+ try {
508
+ const r = await bridgeActionSafe('stop');
509
+ if (embedded) {
510
+ toast(r.message || '正在停止…');
511
+ await sleep(2500);
512
+ try { await api('GET', '/api/status'); await refresh(); } // 进程仍在(异常场景):正常刷新
513
+ catch { showMask('<b>桥接器已停止</b><div class="hint" style="margin-top:8px">本配置页已随进程离线。重新打开请运行 lcb ui;启动桥接器请运行 lcb start。</div>'); }
514
+ } else {
515
+ toast(r.message || '已发送停止信号');
516
+ setTimeout(() => refresh(), 1500); // 硬终止无 PID 清理:陈旧文件由探活兜底删除
517
+ setTimeout(() => refresh(), 4000);
518
+ }
519
+ } catch (e) { toast(e.message, true); }
520
+ }
521
+
522
+ async function restartFlow() {
523
+ if (S.status?.embedded) {
524
+ showMask('<div class="br-spin"></div>正在重启桥接器,页面将自动恢复…');
525
+ try { await bridgeActionSafe('restart'); } catch (e) { hideMask(); toast(e.message, true); return; }
526
+ try { await waitServerBack(); hideMask(); toast('✅ 桥接器已重启'); await refresh(); }
527
+ catch (e) { hideMask(); toast(e.message, true); }
528
+ } else {
529
+ try { const r = await bridgeActionSafe('restart'); toast(r.message || '正在重启…'); }
530
+ catch (e) { toast(e.message, true); return; }
531
+ setTimeout(() => refresh(), 4000);
532
+ setTimeout(() => refresh(), 10000);
533
+ }
534
+ }
535
+
536
+ function renderUpdateResult() {
537
+ const el = $('#updLatest');
538
+ const r = UPD.data;
539
+ if (!el) return;
540
+ if (!r) { el.innerHTML = '<span class="hint">未检查</span>'; return; }
541
+ el.innerHTML = r.hasUpdate
542
+ ? `<span class="tag warn">有新版本</span> <span class="chip">v${esc(r.latest)}</span>`
543
+ : `<span class="tag ok">已是最新</span> <span class="chip">v${esc(r.latest)}</span>`;
544
+ const area = $('#updArea');
545
+ if (!area) return;
546
+ if (r.hasUpdate && r.mode === 'global') {
547
+ area.innerHTML = '<button class="btn primary" id="btnUpdRun">一键更新</button>';
548
+ $('#btnUpdRun').onclick = runUpdateFlow;
549
+ } else if (r.hasUpdate) {
550
+ area.innerHTML = '<span class="hint">当前非 npm 全局安装运行,请手动更新</span>';
551
+ } else area.innerHTML = '';
552
+ }
553
+
554
+ async function doCheckUpdate() {
555
+ const el = $('#updLatest'); if (!el) return;
556
+ el.innerHTML = '<span class="hint">检查中…</span>';
557
+ try {
558
+ UPD.data = await api('GET', '/api/update/check');
559
+ UPD.checkedAt = Date.now();
560
+ renderUpdateResult();
561
+ } catch (e) {
562
+ el.innerHTML = `<span class="hint">检查失败:${esc(e.message)}</span>`;
563
+ }
564
+ }
565
+
566
+ async function runUpdateFlow() {
567
+ const running = S.status?.embedded || S.status?.bridge?.running;
568
+ const extra = running ? ',完成后自动重启桥接器(页面将短暂失联后自动恢复)' : '(桥接器未在运行,下次启动生效)';
569
+ if (!confirm(`将通过 npm 安装最新版本${extra},可能需要 1-2 分钟。继续?`)) return;
570
+ const btn = $('#btnUpdRun');
571
+ if (btn) { btn.disabled = true; btn.textContent = '更新中…'; }
572
+ try {
573
+ await api('POST', '/api/update/run');
574
+ UPD.checkedAt = 0;
575
+ if (running) await restartFlow();
576
+ else toast('✅ 更新完成,下次启动生效');
577
+ await refresh();
578
+ } catch (e) {
579
+ toast(`更新失败:${e.message}`, true);
580
+ renderUpdateResult(); // 恢复按钮
581
+ }
582
+ }
583
+
584
+ async function renderOverview(el) {
585
+ const st = S.status || {};
586
+ const claudeTag = st.claude?.hasAuth === null ? '<span class="tag off">未知</span>'
587
+ : st.claude?.hasAuth ? '<span class="tag ok">已就绪</span>' : '<span class="tag err">未认证</span>';
588
+ const stale = !st.bridge; // 旧版本进程(API 无 bridge 字段):进程托管功能整体不可用
589
+ const br = stale ? { running: st.embedded === true, pid: null, spawnable: false } : st.bridge;
590
+ const ctl = stale
591
+ ? '<div class="hint" style="margin-top:10px">⚠️ 配置页服务进程为旧版本,页面启停 / 重启 / 更新不可用:请重启桥接器(lcb start)后刷新本页。</div>'
592
+ : br.spawnable === false
593
+ ? '<div class="hint" style="margin-top:10px">当前运行方式不支持页面托管启停(如源码运行),请在服务器上手动运行 lcb start。</div>'
594
+ : `<div style="display:flex; gap:10px; margin-top:12px">${
595
+ br.running
596
+ ? '<button class="btn" id="btnBrRestart">重启</button><button class="btn danger" id="btnBrStop">停止</button>'
597
+ : '<button class="btn primary" id="btnBrStart">启动</button>'
598
+ }</div>
599
+ <div class="hint" style="margin-top:8px">${st.embedded
600
+ ? '本页随桥接器进程运行:停止后页面将离线(可运行 lcb ui 重开);重启会短暂失联后自动恢复。'
601
+ : '本页独立运行(lcb ui):在此启动的桥接器为后台进程,运行日志见 ~/.lark-claudecode-bridge/bridge.log。'}</div>`;
602
+ el.innerHTML = `
603
+ <div class="card">
604
+ <h3>运行状态</h3>
605
+ <table>
606
+ <tr><td>版本</td><td>v${esc(st.version)}</td></tr>
607
+ <tr><td>模式</td><td>${st.embedded ? '随 lcb start 常驻' : 'lcb ui 独立(桥接器未随本页启动)'}</td></tr>
608
+ <tr><td>桥接器进程</td><td>${stale
609
+ ? (st.embedded ? '<span class="tag warn">运行中(旧版本进程)</span>' : '<span class="tag off">状态未知(旧版本进程)</span>')
610
+ : br.running
611
+ ? `<span class="tag ok">运行中</span> <span class="hint">PID ${esc(br.pid)}</span>`
612
+ : '<span class="tag off">未运行</span>'}</td></tr>
613
+ <tr><td>配置文件</td><td><code>${esc(st.configPath)}</code></td></tr>
614
+ <tr><td>配置页</td><td><code>${esc(location.host)}</code></td></tr>
615
+ <tr><td>Claude 认证</td><td>${claudeTag}(模式:${esc(st.claude?.mode || 'inherit')})</td></tr>
616
+ </table>
617
+ ${ctl}
618
+ <div class="desc" style="margin-top:10px">${st.claude?.hasAuth === false ? '⚠️ 未检测到认证:到「Claude 认证」页填写,或在配置页所在机器执行 claude login。' : ''}</div>
619
+ </div>
620
+ <div class="card">
621
+ <h3>版本与更新</h3>
622
+ <table>
623
+ <tr><td>当前版本</td><td><span class="chip">v${esc(st.version)}</span></td></tr>
624
+ <tr><td>最新版本</td><td id="updLatest"><span class="hint">未检查</span></td></tr>
625
+ </table>
626
+ <div style="display:flex; gap:10px; margin-top:12px; align-items:center">
627
+ <button class="btn" id="btnUpdCheck">检查更新</button>
628
+ <span id="updArea"></span>
629
+ </div>
630
+ </div>
631
+ <div class="card">
632
+ <h3>机器人应用</h3>
633
+ <table><thead><tr><th>名称</th><th>App ID</th><th>状态</th></tr></thead><tbody>
634
+ ${(st.apps || []).map((a) => `<tr><td>${esc(a.name)}</td><td><code>${esc(a.appId)}</code></td><td>${
635
+ a.started === true ? '<span class="tag ok">运行中</span>' : a.started === false ? '<span class="tag err">启动失败</span>' : '<span class="tag off">未随本页启动</span>'
636
+ }</td></tr>`).join('') || '<tr><td colspan="3" class="hint">无</td></tr>'}
637
+ </tbody></table>
638
+ </div>
639
+ <div class="card">
640
+ <h3>常用命令</h3>
641
+ <div class="desc">在飞书聊天框输入 <code>/</code> 触发;其它 / 开头消息原文透传给 Claude Code。斜杠面板需先到「斜杠命令」页注册(补齐内置命令)。</div>
642
+ <table><thead><tr><th style="width:150px">命令</th><th>说明</th></tr></thead><tbody>
643
+ ${(st.commands || []).map((c) => `<tr><td><code>/${esc(c.command)}</code></td><td>${esc(c.description)}</td></tr>`).join('') || '<tr><td colspan="2" class="hint">无</td></tr>'}
644
+ </tbody></table>
645
+ </div>
646
+ <div class="card">
647
+ <h3>常用指引</h3>
648
+ <div class="desc">飞书端与机器人对话即可使用;<code>/help</code> 查看全部命令。配置修改后:<b>应用 / 认证 / server 段需重启 lcb start</b>,工作区 / 权限 / 触发词下一条消息自动生效。</div>
649
+ </div>`;
650
+ // 启停按钮 + 版本更新;自动检查 30s 节流(切回概览 tab 不重复打 npm)
651
+ const bind = (id, fn) => { const b = $(id); if (b) b.onclick = fn; };
652
+ bind('#btnBrStart', startFlow); bind('#btnBrStop', stopFlow); bind('#btnBrRestart', restartFlow);
653
+ bind('#btnUpdCheck', doCheckUpdate);
654
+ if (UPD.data && Date.now() - UPD.checkedAt < 30000) renderUpdateResult();
655
+ else doCheckUpdate();
656
+ }
657
+
658
+ // ============ 应用(列表 + 抽屉编辑;输入实时写回 S.doc.apps,抽屉「保存」一次落盘) ============
659
+ function renderApps(el) {
660
+ const apps = S.doc.apps || (S.doc.apps = []);
661
+ el.innerHTML = `
662
+ <div class="card">
663
+ <h3>飞书应用(机器人)</h3>
664
+ <div class="desc">每个应用一条独立长连接与会话池。点击行或「编辑」在抽屉中配置;App Secret 已脱敏,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 环境变量改动需重启 lcb start;触发词与显式插件热生效。</div>
665
+ <div style="display:flex;justify-content:flex-end;margin-bottom:10px">
666
+ <button class="btn primary" id="addApp">+ 新增应用</button>
667
+ </div>
668
+ <table>
669
+ <thead><tr><th>名称</th><th>App ID</th><th style="width:80px">域名</th><th>默认工作区</th><th style="width:110px">状态</th><th style="width:64px"></th></tr></thead>
670
+ <tbody id="appsBody"></tbody>
671
+ </table>
672
+ <div class="footer-note">⚠️ 应用段改动需重启 lcb start 后生效(触发词 / 显式插件除外,下一条消息自动生效)。</div>
673
+ </div>`;
674
+ const statusTag = (app) => {
675
+ const st = S.status?.apps?.find((s) => s.name === (app.name || app.app_id))?.started;
676
+ return st === true ? '<span class="tag ok">运行中</span>'
677
+ : st === false ? '<span class="tag err">启动失败</span>'
678
+ : '<span class="tag off">未随本页启动</span>';
679
+ };
680
+ $('#appsBody').innerHTML = apps.map((app, i) => `
681
+ <tr data-i="${i}" style="cursor:pointer">
682
+ <td><b>${esc(app.name || app.app_id || '(未命名)')}</b></td>
683
+ <td><code>${esc(app.app_id || '')}</code></td>
684
+ <td>${esc(app.domain === 'lark' ? 'lark' : 'feishu')}</td>
685
+ <td>${esc(app.default_workspace || '(全局默认)')}</td>
686
+ <td>${statusTag(app)}</td>
687
+ <td><button class="btn sm">编辑</button></td>
688
+ </tr>`).join('');
689
+ $('#appsBody').querySelectorAll('tr').forEach((tr) =>
690
+ tr.onclick = () => openAppDrawer(apps, Number(tr.dataset.i), el));
691
+ $('#addApp').onclick = () => {
692
+ const snap = snapDoc(); // 快照先于 push:取消时连同新增空行一起回滚
693
+ apps.push({ app_id: '', app_secret: '' });
694
+ renderApps(el);
695
+ openAppDrawer(apps, apps.length - 1, el, snap);
696
+ };
697
+ }
698
+
699
+ /**
700
+ * 应用编辑抽屉:输入实时 mutate app 对象,「保存」PUT 落盘(applySecrets / 注文保护链路零改动)。
701
+ * secret 语义逐字保留:非空 = 明文写入;空 = 保持脱敏对象(PUT 端按 app_id 回填磁盘现值)。
702
+ * 「取消」/ X / ESC = 回滚到打开前快照(新增未保存的应用行一并撤销)。
703
+ */
704
+ function openAppDrawer(apps, idx, el, snap) {
705
+ const app = apps[idx];
706
+ const secret = app.app_secret || {};
707
+ const wsNames = (S.doc.workspaces || []).map((w) => w.name || '').filter(Boolean);
708
+ const globalDef = S.doc.defaults?.workspace || '';
709
+ openDrawer({
710
+ title: `应用:${app.name || app.app_id || '(新应用)'}`,
711
+ snap: snap ?? snapDoc(),
712
+ bodyHtml: `
713
+ <div class="row">
714
+ <div><label>应用名字(概览页与飞书 /status 显示用,缺省取 App ID)</label>
715
+ <input type="text" data-f="name" value="${esc(app.name || '')}" placeholder="素材收集"></div>
716
+ <div><label>App ID</label>
717
+ <input type="text" data-f="app_id" value="${esc(app.app_id || '')}" placeholder="cli_…"></div>
718
+ </div>
719
+ <label>App Secret ${secret.secretSet ? `(已设置 ${esc(secret.secretHint)},留空不改)` : ''}</label>
720
+ <input type="password" data-f="app_secret" placeholder="${secret.secretSet ? '••••••••(留空保持不变)' : '未设置'}">
721
+ <div class="row">
722
+ <div><label>域名</label><select data-f="domain">
723
+ <option value="" ${!app.domain || app.domain === 'feishu' ? 'selected' : ''}>feishu(国内)</option>
724
+ <option value="lark" ${app.domain === 'lark' ? 'selected' : ''}>lark(国际)</option>
725
+ </select></div>
726
+ <div><label>默认工作区${globalDef ? `(缺省用全局默认:${esc(globalDef)})` : '(缺省用全局默认)'}</label>
727
+ <select data-f="default_workspace">
728
+ <option value="" ${!app.default_workspace ? 'selected' : ''}>(全局默认)</option>
729
+ ${wsNames.map((n) => `<option value="${esc(n)}" ${app.default_workspace === n ? 'selected' : ''}>${esc(n)}</option>`).join('')}
730
+ </select></div>
731
+ <div><label>并发上限(1-100)</label>
732
+ <input type="number" data-f="concurrency" min="1" max="100" value="${esc(app.concurrency ?? '')}" placeholder="缺省用全局"></div>
733
+ </div>
734
+ <label>人格补充(追加到该机器人每个会话的 system prompt,多机器人差异化定位;改动需重启生效)</label>
735
+ <textarea data-f="append_system_prompt" placeholder="你是一个素材收集助手…">${esc(app.append_system_prompt || '')}</textarea>
736
+ <h4 style="margin:18px 0 2px;font-size:13.5px">触发词(命中即改写消息后再发给 Claude;下一条消息热生效)</h4>
737
+ <div class="hint" style="margin-bottom:6px">match 以 <code>/</code> 开头 = 消息首词精确匹配,否则 = 关键词包含;rewrite 可用 <code>{text}</code>=原文全文、<code>{args}</code>=首词后的参数;本地命令(/stop 等)不受影响;按序首个命中生效。</div>
738
+ <table><thead><tr><th style="width:42%">match</th><th>rewrite</th><th style="width:44px"></th></tr></thead><tbody data-list="triggers"></tbody></table>
739
+ <button class="btn sm" data-add="triggers" style="margin-top:6px">+ 添加规则</button>
740
+ <h4 style="margin:18px 0 2px;font-size:13.5px">显式插件(本地插件源码目录;同名优先于 ~/.claude 自动发现;热生效)</h4>
741
+ <div class="hint" style="margin-bottom:6px">须为包含 <code>.claude-plugin/plugin.json</code> 的目录;一般用户无须配置,多用于开发期直指源码。</div>
742
+ <table><thead><tr><th style="width:30%">name</th><th>path</th><th style="width:44px"></th></tr></thead><tbody data-list="plugins"></tbody></table>
743
+ <button class="btn sm" data-add="plugins" style="margin-top:6px">+ 添加插件</button>
744
+ <h4 style="margin:18px 0 2px;font-size:13.5px">环境变量(注入该机器人 Claude 子进程,覆盖本机同名值;改动需重启生效)</h4>
745
+ <div class="hint" style="margin-bottom:6px">~/.claude/settings.json 已配置的键会被 CLI 自身应用且优先,无须在此重复。</div>
746
+ <table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody data-list="env"></tbody></table>
747
+ <button class="btn sm" data-add="env" style="margin-top:6px">+ 添加变量</button>`,
748
+ footHtml: `<button class="btn danger" id="dwDel">删除应用</button><span class="spacer"></span><button class="btn" id="dwCancel">取消</button><button class="btn primary" id="dwSave">保存</button>`,
749
+ onMount: ({ body, foot }) => {
750
+ // ---- 普通字段(语义与旧卡片版逐字一致;改动只进内存,抽屉「保存」落盘) ----
751
+ body.querySelectorAll('[data-f]').forEach((input) => {
752
+ input.oninput = input.onchange = () => {
753
+ const f = input.dataset.f;
754
+ if (f === 'default_workspace') {
755
+ if (input.value) app.default_workspace = input.value; else delete app.default_workspace;
756
+ return;
757
+ }
758
+ if (f === 'app_secret') {
759
+ if (input.value.trim()) { app.app_secret = input.value; }
760
+ else if (typeof app.app_secret === 'string') { app.app_secret = { secretSet: secret.secretSet }; }
761
+ return;
762
+ }
763
+ if (f === 'concurrency') {
764
+ if (input.value.trim()) app.concurrency = Number(input.value); else delete app.concurrency;
765
+ return;
766
+ }
767
+ if (input.value.trim() || f === 'name' || f === 'app_id') { app[f] = input.value; }
768
+ else { delete app[f]; }
769
+ };
770
+ });
771
+ // ---- 触发词 / 显式插件:数组行编辑,实时写回 ----
772
+ const LIST_DEFS = {
773
+ triggers: { key: 'triggers', cols: [['match', '/produce 或 关键词'], ['rewrite', '请执行内容生产流程:{args}']] },
774
+ plugins: { key: 'plugins', cols: [['name', 'my-plugin'], ['path', 'F:/dev/my-plugin']] },
775
+ };
776
+ const renderPairList = (name) => {
777
+ const def = LIST_DEFS[name];
778
+ const list = app[def.key] || [];
779
+ const tb = body.querySelector(`[data-list="${name}"]`);
780
+ tb.innerHTML = list.map((item, i) => `
781
+ <tr>
782
+ ${def.cols.map(([col, ph]) => `<td><input type="text" data-l="${i}" data-c="${col}" value="${esc(item?.[col] ?? '')}" placeholder="${esc(ph)}"></td>`).join('')}
783
+ <td><button class="btn sm danger" data-rm="${i}">删</button></td>
784
+ </tr>`).join('');
785
+ tb.querySelectorAll('input[data-l]').forEach((input) => input.oninput = () => {
786
+ app[def.key][Number(input.dataset.l)][input.dataset.c] = input.value;
787
+ });
788
+ tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
789
+ app[def.key].splice(Number(b.dataset.rm), 1);
790
+ if (!app[def.key].length) delete app[def.key];
791
+ renderPairList(name);
792
+ });
793
+ };
794
+ for (const name of Object.keys(LIST_DEFS)) renderPairList(name);
795
+ // ---- 环境变量:对象 ↔ 行数组同步(key 改名 / 删除即时生效到 app.env) ----
796
+ const envRows = Object.entries(app.env || {}).map(([key, value]) => ({ key, value: String(value) }));
797
+ const syncEnv = () => {
798
+ const obj = {};
799
+ for (const r of envRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
800
+ if (Object.keys(obj).length) app.env = obj; else delete app.env;
801
+ };
802
+ const renderEnv = () => {
803
+ const tb = body.querySelector('[data-list="env"]');
804
+ tb.innerHTML = envRows.map((r, i) => `
805
+ <tr>
806
+ <td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
807
+ <td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
808
+ <td><button class="btn sm danger" data-rm="${i}">删</button></td>
809
+ </tr>`).join('');
810
+ tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
811
+ envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
812
+ syncEnv();
813
+ });
814
+ tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
815
+ envRows.splice(Number(b.dataset.rm), 1);
816
+ syncEnv();
817
+ renderEnv();
818
+ });
819
+ };
820
+ renderEnv();
821
+ // ---- 添加行 ----
822
+ body.querySelectorAll('[data-add]').forEach((b) => b.onclick = () => {
823
+ const name = b.dataset.add;
824
+ if (name === 'env') { envRows.push({ key: '', value: '' }); renderEnv(); return; }
825
+ const def = LIST_DEFS[name];
826
+ (app[def.key] = app[def.key] || []).push({ [def.cols[0][0]]: '', [def.cols[1][0]]: '' });
827
+ renderPairList(name);
828
+ body.querySelector(`[data-list="${name}"] tbody input`)?.focus();
829
+ });
830
+ // ---- 删除 / 保存 / 取消 ----
831
+ foot.querySelector('#dwDel').onclick = async () => {
832
+ if (apps.length <= 1) return toast('至少保留一个应用', true);
833
+ if (!window.confirm(`确认删除应用「${app.name || app.app_id || '未命名'}」?删除将立即保存生效。`)) return;
834
+ apps.splice(idx, 1);
835
+ if (await saveDoc()) closeDrawer();
836
+ else cancelDrawer(); // 落盘失败:回滚删除,保留编辑现场让用户重试
837
+ };
838
+ foot.querySelector('#dwCancel').onclick = cancelDrawer;
839
+ foot.querySelector('#dwSave').onclick = async () => {
840
+ // 清理半填的空行:triggers/plugins 行两列全空剔除;env 以 key 非空为准(syncEnv 已过滤)。数组空则整键删除
841
+ for (const name of Object.keys(LIST_DEFS)) {
842
+ const def = LIST_DEFS[name];
843
+ const list = app[def.key];
844
+ if (!Array.isArray(list)) continue;
845
+ const filtered = list.filter((item) => def.cols.some(([col]) => String(item?.[col] ?? '').trim()));
846
+ if (filtered.length) app[def.key] = filtered;
847
+ else delete app[def.key];
848
+ }
849
+ if (await saveDoc()) closeDrawer();
850
+ };
851
+ },
852
+ });
853
+ }
854
+
855
+ // ============ 工作区(行内编辑 + 卡级保存;改动落盘后热生效) ============
856
+ function renderWorkspaces(el) {
857
+ const doc = S.doc;
858
+ doc.workspaces = doc.workspaces || [];
859
+ el.innerHTML = `
860
+ <div class="card">
861
+ <h3>工作区${dirtyDotHtml('ws')}</h3>
862
+ <div class="desc">Claude 的工作目录白名单。「开发工作流(code-dev)」类型:先出计划(飞书批准)+ 收尾汇总 diff。</div>
863
+ <div style="display:flex;justify-content:flex-end;margin-bottom:10px">
864
+ <button class="btn sm" id="wsAdd">+ 添加工作区</button>
865
+ </div>
866
+ <table><thead><tr><th style="width:180px">名字</th><th>路径</th><th style="width:170px">类型</th><th style="width:50px"></th></tr></thead>
867
+ <tbody id="wsBody"></tbody></table>
868
+ ${saveBarHtml('ws')}
869
+ <div class="footer-note">运行中的桥接器下一条消息自动生效,无需重启。</div>
870
+ </div>
871
+ <div class="card">
872
+ <h3>全局默认${dirtyDotHtml('def')}</h3>
873
+ <div class="row">
874
+ <div><label>默认工作区(defaults.workspace)</label><select id="defWs"></select></div>
875
+ <div><label>全局并发(concurrency)</label><input type="number" id="globalCc" min="1" max="100"></div>
876
+ </div>
877
+ ${saveBarHtml('def')}
878
+ </div>`;
879
+ bindSaveBar(el, 'ws');
880
+ bindSaveBar(el, 'def');
881
+ /** 重建默认工作区下拉选项:保留当前选中;现值不在工作区列表(手工编辑过的脏配置)时注入该值本身,防静默丢数据 */
882
+ const syncDefWsOptions = () => {
883
+ const sel = $('#defWs');
884
+ if (!sel) return;
885
+ const cur = doc.defaults?.workspace || '';
886
+ const names = doc.workspaces.map((w) => w.name || '').filter(Boolean);
887
+ const extra = cur && !names.includes(cur) ? [cur] : [];
888
+ sel.innerHTML = ['', ...names, ...extra].map((n) =>
889
+ `<option value="${esc(n)}"${n === cur ? ' selected' : ''}>${n === '' ? '(未设置)' : esc(n)}${extra.includes(n) ? '(配置中不存在)' : ''}</option>`).join('');
890
+ };
891
+ const body = $('#wsBody');
892
+ const renderRow = (ws, idx) => {
893
+ const tr = document.createElement('tr');
894
+ tr.innerHTML = `
895
+ <td><input type="text" data-f="name" value="${esc(ws.name || '')}"></td>
896
+ <td><div class="input-btn">
897
+ <input type="text" data-f="path" value="${esc(ws.path || '')}">
898
+ <button class="btn sm" data-browse="path" title="浏览选择目录">浏览</button>
899
+ </div></td>
900
+ <td><select data-f="type">
901
+ <option value="generic" ${ws.type !== 'code-dev' ? 'selected' : ''}>通用(generic)</option>
902
+ <option value="code-dev" ${ws.type === 'code-dev' ? 'selected' : ''}>开发工作流(code-dev)</option>
903
+ </select></td>
904
+ <td><button class="btn sm danger">删</button></td>`;
905
+ tr.querySelectorAll('[data-f]').forEach((input) => {
906
+ input.oninput = input.onchange = () => {
907
+ const f = input.dataset.f;
908
+ if (f === 'type') { if (input.value === 'code-dev') ws.type = 'code-dev'; else delete ws.type; }
909
+ else if (f === 'name') {
910
+ // 改名联动:全局默认指向旧名时同步,否则保存会因 defaults.workspace 不在工作区列表报错
911
+ if (doc.defaults?.workspace && doc.defaults.workspace === ws.name) {
912
+ doc.defaults = { ...(doc.defaults || {}), workspace: input.value };
913
+ }
914
+ ws.name = input.value;
915
+ syncDefWsOptions();
916
+ }
917
+ else ws[f] = input.value;
918
+ cardDirty('ws', true);
919
+ };
920
+ });
921
+ tr.querySelector('.btn').onclick = () => {
922
+ if (doc.workspaces.length <= 1) return toast('至少保留一个工作区', true);
923
+ doc.workspaces.splice(idx, 1);
924
+ cardDirty('ws', true);
925
+ renderWorkspaces(el);
926
+ };
927
+ // 浏览选目录:回填后手动同步 ws.path(直接改 input.value 不触发 oninput)
928
+ tr.querySelector('[data-browse]').onclick = async () => {
929
+ const picked = await pickDirectory(ws.path || '');
930
+ if (picked == null) return;
931
+ ws.path = picked;
932
+ tr.querySelector('input[data-f=path]').value = picked;
933
+ cardDirty('ws', true);
934
+ };
935
+ body.appendChild(tr);
936
+ };
937
+ doc.workspaces.forEach(renderRow);
938
+ $('#wsAdd').onclick = () => { doc.workspaces.push({ name: '', path: '' }); cardDirty('ws', true); renderWorkspaces(el); };
939
+ syncDefWsOptions();
940
+ $('#defWs').onchange = () => { doc.defaults = { ...(doc.defaults || {}), workspace: $('#defWs').value }; cardDirty('def', true); };
941
+ $('#globalCc').value = doc.concurrency ?? 3;
942
+ $('#globalCc').oninput = () => { doc.concurrency = Number($('#globalCc').value) || 3; cardDirty('def', true); };
943
+ applyDirty(); // 增删行等操作先 cardDirty 再整卡重渲染:回放防止保存条/圆点随重建丢失
944
+ }
945
+
946
+ // ============ Claude 认证 ============
947
+ /** 模型拉取公共逻辑:getBody() 组请求体(凭证仅携带本次输入的明文,空则不带给后端回落磁盘值) */
948
+ async function pullModels(getBody, btn, datalistId) {
949
+ btn.disabled = true;
950
+ const old = btn.textContent;
951
+ btn.textContent = '拉取中…';
952
+ try {
953
+ const r = await api('POST', '/api/models/fetch', getBody());
954
+ const dl = document.getElementById(datalistId);
955
+ if (dl) dl.innerHTML = (r.models || []).map((m) => `<option value="${esc(m)}"></option>`).join('');
956
+ toast(`获取到 ${(r.models || []).length} 个模型:在模型框下拉选择,或继续手动输入`);
957
+ } catch (e) { toast(e.message, true); }
958
+ btn.disabled = false;
959
+ btn.textContent = old;
960
+ }
961
+
962
+ function renderClaude(el) {
963
+ const c = S.doc.claude || (S.doc.claude = {});
964
+ c.profiles = c.profiles || [];
965
+ const mode = c.mode || 'inherit';
966
+ const hasToken = c.auth_token?.secretSet, hasKey = c.api_key?.secretSet;
967
+ const credType = hasToken ? 'auth_token' : 'api_key';
968
+ const cur = S.status?.claude?.current || {};
969
+ const hint = (h) => h?.secretSet ? `已设置(${esc(h.secretHint)})` : '未设置';
970
+ el.innerHTML = `
971
+ <div class="card">
972
+ <h3>当前生效配置</h3>
973
+ <div class="desc">认证模式与实际生效的值(凭证脱敏显示)。managed 模式改动保存后对后续任务即生效;模式切换需重启。</div>
974
+ <table>
975
+ <tr><td style="width:150px">认证模式</td><td>${mode === 'managed' ? 'bridge 托管(managed)' : '继承本机 ~/.claude(inherit)'}</td></tr>
976
+ <tr><td>BASE_URL</td><td>${cur.baseUrl ? `<code>${esc(cur.baseUrl)}</code>` : '<span class="hint">官方默认(api.anthropic.com)</span>'}</td></tr>
977
+ <tr><td>模型</td><td>${cur.model ? `<code>${esc(cur.model)}</code>` : '<span class="hint">未设置(CLI 默认)</span>'}</td></tr>
978
+ <tr><td>Auth Token</td><td>${hint(cur.authToken)}</td></tr>
979
+ <tr><td>API Key</td><td>${hint(cur.apiKey)}</td></tr>
980
+ <tr><td>自定义环境变量</td><td>${cur.envCount ? `${cur.envCount} 个(写入托管 settings.json env 块,MCP 工具可用)` : '<span class="hint">无</span>'}</td></tr>
981
+ <tr><td>settings.json</td><td><code>${esc(S.status?.claude?.settingsPath || '—')}</code></td></tr>
982
+ </table>
983
+ ${mode !== 'managed' ? '<div class="desc" style="margin-top:8px">inherit 模式上表为本机 ~/.claude 实际状态,本页不修改本机登录。要管理多厂商档案并一键切换,请切到 bridge 托管。</div>' : ''}
984
+ </div>
985
+ <div class="card">
986
+ <h3>认证模式${dirtyDotHtml('claude')}</h3>
987
+ <div class="radio-row">
988
+ <label><input type="radio" name="mode" value="inherit" ${mode === 'inherit' ? 'checked' : ''}> 继承本机 ~/.claude(已在本机 claude login)</label>
989
+ <label><input type="radio" name="mode" value="managed" ${mode === 'managed' ? 'checked' : ''}> bridge 托管(填 API Key / Token,无需本机登录)</label>
990
+ </div>
991
+ <div id="managedBox" style="${mode === 'managed' ? '' : 'display:none'}">
992
+ <div class="desc" style="margin:6px 0 2px">常规用法:在下方「厂商档案」保存凭证后点「设为当前」即可,无须手动填写。</div>
993
+ <details style="margin-top:4px">
994
+ <summary style="cursor:pointer;font-size:13px;color:var(--muted)">高级:手动编辑当前生效凭证</summary>
995
+ <div style="margin-top:10px">
996
+ <div class="radio-row">
997
+ <label><input type="radio" name="cred" value="auth_token" ${credType === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(中转站常用,Bearer)</label>
998
+ <label><input type="radio" name="cred" value="api_key" ${credType === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
999
+ </div>
1000
+ <div class="row">
1001
+ <div><label id="credLabel">Auth Token ${hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : ''}</label>
1002
+ <input type="password" id="credVal" placeholder="${hasToken || hasKey ? '留空保持不变' : 'sk-…'}"></div>
1003
+ <div><label>BASE_URL(中转站端点,官方留空)</label><input type="text" id="baseUrl" value="${esc(c.base_url || '')}" placeholder="https://relay.example.com"></div>
1004
+ </div>
1005
+ <div class="row" style="align-items:end">
1006
+ <div style="flex:1"><label>模型(写入 settings.json,可留空)</label>
1007
+ <input type="text" id="model" list="modelList" value="${esc(c.model || '')}" placeholder="claude-sonnet-5 或任意厂商模型名">
1008
+ <datalist id="modelList"></datalist></div>
1009
+ <button class="btn" id="btnModels">拉取模型</button>
1010
+ </div>
1011
+ <div class="hint" style="margin-top:6px">「拉取模型」用当前表单的 BASE_URL 与凭证(未填则用已保存值)请求模型列表:自动兼容带 / 不带 /v1 的地址与 Bearer / x-api-key 认证;失败可手动填写。</div>
1012
+ </div>
1013
+ </details>
1014
+ <h4 style="margin:14px 0 2px;font-size:13.5px">环境变量(写入托管 settings.json,MCP 工具依赖的自定义变量在此配置)</h4>
1015
+ <div class="hint" style="margin-bottom:6px">本机 ~/.claude/settings.json 已有的非认证键会自动继承;此处配置的键<b>优先级更高</b>(覆盖继承值)。认证相关(ANTHROPIC_AUTH_TOKEN / API_KEY / BASE_URL / MODEL)不在此生效,请用上方认证表单。</div>
1016
+ <table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody id="claudeEnvBody"></tbody></table>
1017
+ <button class="btn sm" id="claudeEnvAdd" style="margin-top:6px">+ 添加变量</button>
1018
+ ${saveBarHtml('claude')}
1019
+ </div>
1020
+ <div class="footer-note">managed 模式把认证写入 <code>~/.lark-claudecode-bridge/claude/settings.json</code>,与本机 ~/.claude 完全隔离;认证/模型改动保存后对后续任务即生效,模式切换需重启。</div>
1021
+ </div>
1022
+ <div class="card" id="pfCard" style="${mode === 'managed' ? '' : 'display:none'}">
1023
+ <h3>厂商档案</h3>
1024
+ <div class="desc">保存多套厂商 / 中转站凭证,每个档案可维护多个候选模型(如 fable / opus / sonnet / haiku)。「设为当前」整套切换;点候选模型即时切换当前模型。档案在抽屉「保存」后即可切换。</div>
1025
+ <div style="display:flex;justify-content:flex-end;margin-bottom:10px"><button class="btn sm primary" id="pfAdd">+ 新增档案</button></div>
1026
+ <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>
1027
+ <tbody id="pfBody"></tbody></table>
1028
+ </div>`;
1029
+ document.querySelectorAll('input[name=mode]').forEach((r) => r.onchange = () => {
1030
+ c.mode = document.querySelector('input[name=mode]:checked').value;
1031
+ cardDirty('claude', c.mode === 'managed'); // inherit 无 managed 表单语义:不标记未保存
1032
+ renderClaude(el); // 模式切换影响多卡显隐,整页重渲染(radio 状态由 c.mode 驱动)
1033
+ });
1034
+ bindSaveBar(el, 'claude');
1035
+ const updCredLabel = () => {
1036
+ const v = document.querySelector('input[name=cred]:checked').value;
1037
+ $('#credLabel').innerHTML = (v === 'api_key' ? 'API Key' : 'Auth Token')
1038
+ + (v === 'auth_token' && hasToken ? `(已设置 ${esc(c.auth_token.secretHint)},留空不改)` : '')
1039
+ + (v === 'api_key' && hasKey ? `(已设置 ${esc(c.api_key.secretHint)},留空不改)` : '');
1040
+ };
1041
+ document.querySelectorAll('input[name=cred]').forEach((r) => r.onchange = () => { updCredLabel(); cardDirty('claude', true); });
1042
+ $('#credVal').oninput = () => {
1043
+ const v = $('#credVal').value.trim();
1044
+ const key = document.querySelector('input[name=cred]:checked').value;
1045
+ const other = key === 'auth_token' ? 'api_key' : 'auth_token';
1046
+ if (v) { c[key] = v; c[other] = ''; } else { c[key] = { secretSet: key === 'auth_token' ? hasToken : hasKey }; }
1047
+ cardDirty('claude', true);
1048
+ };
1049
+ $('#baseUrl').oninput = () => { c.base_url = $('#baseUrl').value.trim() || undefined; cardDirty('claude', true); };
1050
+ $('#model').oninput = () => { c.model = $('#model').value.trim() || undefined; cardDirty('claude', true); };
1051
+ // ---- 环境变量行编辑(claude.env):对象 ↔ 行数组,KEY/VALUE 实时写回(同 apps 抽屉 env 模式) ----
1052
+ const cEnvRows = Object.entries(c.env || {}).map(([key, value]) => ({ key, value: String(value) }));
1053
+ const syncClaudeEnv = () => {
1054
+ const obj = {};
1055
+ for (const r of cEnvRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
1056
+ if (Object.keys(obj).length) c.env = obj; else delete c.env;
1057
+ };
1058
+ const renderClaudeEnv = () => {
1059
+ const tb = $('#claudeEnvBody');
1060
+ if (!tb) return;
1061
+ tb.innerHTML = cEnvRows.length ? cEnvRows.map((r, i) => `
1062
+ <tr>
1063
+ <td><input type="text" data-cei="${i}" data-col="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
1064
+ <td><input type="text" data-cei="${i}" data-col="value" value="${esc(r.value)}" placeholder="some-value"></td>
1065
+ <td><button class="btn sm danger" data-crm="${i}">删</button></td>
1066
+ </tr>`).join('') : '<tr><td colspan="3" class="hint">未配置(本机 ~/.claude 的非认证键自动继承)</td></tr>';
1067
+ tb.querySelectorAll('input[data-cei]').forEach((input) => input.oninput = () => {
1068
+ cEnvRows[Number(input.dataset.cei)][input.dataset.col] = input.value;
1069
+ syncClaudeEnv();
1070
+ cardDirty('claude', true);
1071
+ });
1072
+ tb.querySelectorAll('button[data-crm]').forEach((b) => b.onclick = () => {
1073
+ cEnvRows.splice(Number(b.dataset.crm), 1);
1074
+ syncClaudeEnv();
1075
+ cardDirty('claude', true);
1076
+ renderClaudeEnv();
1077
+ });
1078
+ };
1079
+ const envAdd = $('#claudeEnvAdd');
1080
+ if (envAdd) envAdd.onclick = () => { cEnvRows.push({ key: '', value: '' }); renderClaudeEnv(); tbFocusLastKey(); };
1081
+ const tbFocusLastKey = () => {
1082
+ const inputs = document.querySelectorAll('#claudeEnvBody input[data-col=key]');
1083
+ const last = inputs[inputs.length - 1];
1084
+ if (last) last.focus();
1085
+ };
1086
+ renderClaudeEnv();
1087
+ $('#btnModels').onclick = () => {
1088
+ const typed = $('#credVal').value.trim();
1089
+ const key = document.querySelector('input[name=cred]:checked').value;
1090
+ pullModels(() => ({ base_url: $('#baseUrl').value.trim(), ...(typed ? { [key]: typed } : {}) }), $('#btnModels'), 'modelList');
1091
+ };
1092
+ // ---- 厂商档案列表 ----
1093
+ const renderProfiles = () => {
1094
+ const body = $('#pfBody');
1095
+ if (!body) return;
1096
+ if (!c.profiles.length) { body.innerHTML = '<tr><td colspan="5" class="hint">尚无档案,点右上「+ 新增档案」创建</td></tr>'; return; }
1097
+ const inUse = (p) => {
1098
+ // 前端无凭证明文,用尾 4 位提示 + baseUrl 近似判断「使用中」(当前模型在 chips 上单独高亮)
1099
+ const credSame = (p.auth_token?.secretHint && cur.authToken?.secretHint === p.auth_token.secretHint)
1100
+ || (p.api_key?.secretHint && cur.apiKey?.secretHint === p.api_key.secretHint);
1101
+ return credSame && (p.base_url || '') === (cur.baseUrl || '');
1102
+ };
1103
+ const modelChips = (p, active, i) => {
1104
+ const list = [...(p.models || []), ...(p.model && !(p.models || []).includes(p.model) ? [p.model] : [])];
1105
+ return list.map((m) => `<button class="btn sm${active && cur.model === m ? ' primary' : ''}" data-op="usemodel" data-i="${i}" data-m="${esc(m)}" title="点击切换为当前模型">${esc(m)}${active && cur.model === m ? ' ✓' : ''}</button>`).join(' ')
1106
+ || '<span class="hint">—</span>';
1107
+ };
1108
+ body.innerHTML = c.profiles.map((p, i) => {
1109
+ const active = inUse(p);
1110
+ return `
1111
+ <tr>
1112
+ <td><b>${esc(p.name)}</b>${active ? '<br><span class="tag ok">使用中</span>' : ''}</td>
1113
+ <td>${p.auth_token?.secretSet ? 'AUTH_TOKEN' : p.api_key?.secretSet ? 'API_KEY' : '<span class="hint">未设凭证</span>'}</td>
1114
+ <td>${p.base_url ? `<code>${esc(p.base_url)}</code>` : '<span class="hint">官方</span>'}</td>
1115
+ <td>${modelChips(p, active, i)}</td>
1116
+ <td>
1117
+ <button class="btn sm" data-op="use" data-i="${i}">设为当前</button>
1118
+ <button class="btn sm" data-op="edit" data-i="${i}">编辑</button>
1119
+ <button class="btn sm danger" data-op="del" data-i="${i}">删除</button>
1120
+ </td>
1121
+ </tr>`;
1122
+ }).join('');
1123
+ body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = async () => {
1124
+ const i = Number(b.dataset.i);
1125
+ const p = c.profiles[i];
1126
+ if (b.dataset.op === 'use' || b.dataset.op === 'usemodel') {
1127
+ // 切换走后端端点(档案凭证明文不出进程);编辑中先收尾,未保存改动自动落盘再切(如认证模式切换)
1128
+ if (!$('#drawerMask').hidden) return toast('请先完成档案编辑(保存或取消)', true);
1129
+ if (S.dirtyCards.size && !(await saveDoc())) return; // 保存失败已 toast 原因,保持编辑态不切换
1130
+ try {
1131
+ const reqBody = { name: p.name };
1132
+ if (b.dataset.op === 'usemodel') reqBody.model = b.dataset.m;
1133
+ else if (p.model || (p.models || [])[0]) reqBody.model = p.model || p.models[0];
1134
+ const r = await api('POST', '/api/claude/use-profile', reqBody);
1135
+ toast(r.message || '已切换');
1136
+ await refresh();
1137
+ } catch (e) { toast(e.message, true); }
1138
+ return;
1139
+ }
1140
+ if (b.dataset.op === 'del') {
1141
+ if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
1142
+ c.profiles.splice(i, 1);
1143
+ await saveDoc();
1144
+ return;
1145
+ }
1146
+ openProfileDrawer(c, i, el);
1147
+ });
1148
+ };
1149
+ renderProfiles();
1150
+ $('#pfAdd').onclick = () => {
1151
+ const snap = snapDoc(); // 快照先于 push:取消时连同新增空档案一并回滚
1152
+ c.profiles.push({ name: '' });
1153
+ renderClaude(el);
1154
+ openProfileDrawer(c, c.profiles.length - 1, el, snap);
1155
+ };
1156
+ applyDirty(); // 模式切换等 handler 先 cardDirty 再整卡重渲染:回放防止保存条/圆点随重建丢失
1157
+ }
1158
+
1159
+ /** 档案编辑抽屉:实时 mutate profile 对象,「保存」一次落盘;凭证留空 = 保持不变(PUT 按 name 对齐回填)。
1160
+ * 模型候选 chips:手输 / 「拉取模型」下拉添加,点 × 删除;默认模型(「设为当前」用)从候选中选 */
1161
+ function openProfileDrawer(c, i, el, snap) {
1162
+ const p = c.profiles[i];
1163
+ const hasT = p.auth_token?.secretSet, hasK = p.api_key?.secretSet;
1164
+ const ctype = hasT ? 'auth_token' : 'api_key';
1165
+ openDrawer({
1166
+ title: `厂商档案${p.name ? `:${p.name}` : '(新档案)'}`,
1167
+ snap: snap ?? snapDoc(),
1168
+ bodyHtml: `
1169
+ <label>档案名(显示用,如「官方」「中转站A」)</label>
1170
+ <input type="text" id="dwPfName" value="${esc(p.name || '')}" placeholder="中转站A">
1171
+ <div class="radio-row">
1172
+ <label><input type="radio" name="dwCred" value="auth_token" ${ctype === 'auth_token' ? 'checked' : ''}> AUTH_TOKEN(Bearer,中转站常用)</label>
1173
+ <label><input type="radio" name="dwCred" value="api_key" ${ctype === 'api_key' ? 'checked' : ''}> API_KEY(官方,x-api-key)</label>
1174
+ </div>
1175
+ <label id="dwPfCredLabel">${ctype === 'api_key' ? 'API Key' : 'Auth Token'} ${hasT || hasK ? '(已设置,留空不改)' : ''}</label>
1176
+ <input type="password" id="dwPfCred" placeholder="${hasT || hasK ? '留空保持不变' : 'sk-…'}">
1177
+ <label>BASE_URL(中转站端点,官方留空)</label>
1178
+ <input type="text" id="dwPfBase" value="${esc(p.base_url || '')}" placeholder="https://relay.example.com">
1179
+ <h4 style="margin:18px 0 4px;font-size:13.5px">候选模型(fable / opus / sonnet / haiku 等,各存一条;列表页点击即切换当前模型)</h4>
1180
+ <div class="chips" id="dwPfModels"></div>
1181
+ <div class="row" style="align-items:end;max-width:600px">
1182
+ <div style="flex:1"><input type="text" id="dwPfNewModel" list="dwPfModelList" placeholder="输入或下拉选择后点「添加」">
1183
+ <datalist id="dwPfModelList"></datalist></div>
1184
+ <div class="btn-wrap"><button class="btn" id="dwPfAddModel">添加</button></div>
1185
+ <div class="btn-wrap"><button class="btn" id="dwPfPull">拉取模型</button></div>
1186
+ </div>
1187
+ <div class="row" style="align-items:end;margin-top:12px;max-width:600px">
1188
+ <div style="flex:1"><label>默认模型(点「设为当前」时使用,通常为候选之一)</label>
1189
+ <input type="text" id="dwPfModel" list="dwPfModelsList" value="${esc(p.model || '')}" placeholder="留空 = 切换时不指定">
1190
+ <datalist id="dwPfModelsList"></datalist></div>
1191
+ </div>`,
1192
+ footHtml: `<button class="btn danger" id="dwPfDel">删除档案</button><span class="spacer"></span><button class="btn" id="dwPfCancel">取消</button><button class="btn primary" id="dwPfSave">保存</button>`,
1193
+ onMount: () => {
1194
+ document.querySelectorAll('input[name=dwCred]').forEach((r) => r.onchange = () => {
1195
+ const v = document.querySelector('input[name=dwCred]:checked').value;
1196
+ const t = v === 'auth_token' && hasT ? `(已设置 ${esc(p.auth_token.secretHint)},留空不改)` : '';
1197
+ const k = v === 'api_key' && hasK ? `(已设置 ${esc(p.api_key.secretHint)},留空不改)` : '';
1198
+ document.getElementById('dwPfCredLabel').textContent = `${v === 'api_key' ? 'API Key' : 'Auth Token'}${t || k}`;
1199
+ });
1200
+ document.getElementById('dwPfName').oninput = (e) => { p.name = e.target.value.trim(); };
1201
+ document.getElementById('dwPfCred').oninput = (e) => {
1202
+ const v = e.target.value.trim();
1203
+ const key = document.querySelector('input[name=dwCred]:checked').value;
1204
+ const other = key === 'auth_token' ? 'api_key' : 'auth_token';
1205
+ if (v) { p[key] = v; delete p[other]; }
1206
+ else if (!(p[key] && typeof p[key] === 'object')) delete p[key]; // 空输入:保留原脱敏对象(未修改语义)
1207
+ };
1208
+ document.getElementById('dwPfBase').oninput = (e) => { p.base_url = e.target.value.trim() || undefined; };
1209
+ // ---- 候选模型 chips:本地数组编辑,同步 p.models ----
1210
+ const models = [...(p.models || [])];
1211
+ const syncModels = () => {
1212
+ if (models.length) p.models = [...models];
1213
+ else delete p.models;
1214
+ const box = document.getElementById('dwPfModels');
1215
+ box.innerHTML = models.map((m, mi) =>
1216
+ `<span class="chip">${esc(m)}<button type="button" data-rm="${mi}" title="移除">×</button></span>`).join('')
1217
+ || '<span class="hint">尚无候选模型(不影响使用,可随时添加)</span>';
1218
+ box.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
1219
+ models.splice(Number(b.dataset.rm), 1);
1220
+ syncModels();
1221
+ });
1222
+ document.getElementById('dwPfModelsList').innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join('');
1223
+ };
1224
+ syncModels();
1225
+ const addModel = () => {
1226
+ const inp = document.getElementById('dwPfNewModel');
1227
+ const v = inp.value.trim();
1228
+ if (!v) return;
1229
+ if (!models.includes(v)) models.push(v);
1230
+ inp.value = '';
1231
+ syncModels();
1232
+ };
1233
+ document.getElementById('dwPfAddModel').onclick = addModel;
1234
+ document.getElementById('dwPfNewModel').onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); addModel(); } };
1235
+ document.getElementById('dwPfModel').oninput = (e) => { p.model = e.target.value.trim() || undefined; };
1236
+ document.getElementById('dwPfPull').onclick = () => {
1237
+ const typed = document.getElementById('dwPfCred').value.trim();
1238
+ const key = document.querySelector('input[name=dwCred]:checked').value;
1239
+ pullModels(() => ({
1240
+ base_url: document.getElementById('dwPfBase').value.trim(),
1241
+ ...(p.name ? { profile_name: p.name } : {}),
1242
+ ...(typed ? { [key]: typed } : {}),
1243
+ }), document.getElementById('dwPfPull'), 'dwPfModelList');
1244
+ };
1245
+ document.getElementById('dwPfDel').onclick = async () => {
1246
+ if (!window.confirm(`确认删除档案「${p.name || '未命名'}」?删除将立即保存生效。`)) return;
1247
+ c.profiles.splice(i, 1);
1248
+ if (await saveDoc()) closeDrawer();
1249
+ else cancelDrawer();
1250
+ };
1251
+ document.getElementById('dwPfCancel').onclick = cancelDrawer;
1252
+ document.getElementById('dwPfSave').onclick = async () => {
1253
+ if (await saveDoc()) closeDrawer();
1254
+ };
1255
+ },
1256
+ });
1257
+ }
1258
+
1259
+ // ============ 权限 ============
1260
+ let permTab = 'allow'; // 页内子 tab 状态:免确认工具 / 危险命令黑名单(重渲染保持)
1261
+ function renderPermissions(el) {
1262
+ const p = S.doc.permissions || (S.doc.permissions = {});
1263
+ p.allow_tools = p.allow_tools || [];
1264
+ p.dangerous_commands = p.dangerous_commands || [];
1265
+ el.innerHTML = `
1266
+ <div class="card">
1267
+ <div class="subtabs">
1268
+ <button data-t="allow" class="${permTab === 'allow' ? 'active' : ''}">免确认工具</button>
1269
+ <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单</button>
1270
+ </div>
1271
+ <div id="permPanel"></div>
1272
+ </div>`;
1273
+ el.querySelectorAll('.subtabs button').forEach((b) =>
1274
+ b.onclick = () => { permTab = b.dataset.t; renderPermissions(el); });
1275
+ const panel = $('#permPanel');
1276
+ if (permTab === 'danger') renderDangerTab(panel, p, el);
1277
+ else renderAllowTab(panel, p, el);
1278
+ }
1279
+
1280
+ function renderAllowTab(panel, p, el) {
1281
+ const defaults = S.status?.permissionDefaults?.allowTools || [];
1282
+ const isDefault = defaults.length > 0 && JSON.stringify(p.allow_tools) === JSON.stringify(defaults);
1283
+ panel.innerHTML = `
1284
+ <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
1285
+ <div class="row" style="max-width:480px;margin:8px 0 10px">
1286
+ <input type="text" id="newTool" placeholder="工具名,如 Write">
1287
+ <div class="btn-wrap"><button class="btn sm" id="addTool">添加</button></div>
1288
+ <div class="btn-wrap"><button class="btn sm" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
1289
+ </div>
1290
+ ${p.allow_tools.length === 0 ? '<div style="background:#fff3e0;color:var(--warn);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
1291
+ ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
1292
+ <table><tbody id="toolBody"></tbody></table>
1293
+ <div class="footer-note">权限改动即时保存并经热重载对下一条消息生效(新通道/重启后全量生效)。</div>`;
1294
+ $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
1295
+ `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
1296
+ || '<tr><td colspan="2" class="hint">(空)</td></tr>';
1297
+ // 即时保存:低风险高频操作,免去「改完再点保存」两步;失败回滚内存保持一致
1298
+ $('#toolBody').querySelectorAll('button').forEach((b) =>
1299
+ b.onclick = async () => {
1300
+ const old = [...p.allow_tools];
1301
+ p.allow_tools.splice(Number(b.dataset.i), 1);
1302
+ if (!await saveDoc()) p.allow_tools = old;
1303
+ });
1304
+ $('#addTool').onclick = async () => {
1305
+ const v = $('#newTool').value.trim();
1306
+ if (!v || p.allow_tools.includes(v)) return;
1307
+ p.allow_tools.push(v);
1308
+ if (!await saveDoc()) p.allow_tools.splice(p.allow_tools.indexOf(v), 1);
1309
+ else if ($('#newTool')) $('#newTool').value = '';
1310
+ };
1311
+ $('#resetTools').onclick = async () => {
1312
+ if (!defaults.length) return;
1313
+ if (!window.confirm('恢复为内置默认名单?当前自定义项将被覆盖(立即保存生效)。')) return;
1314
+ const old = [...p.allow_tools];
1315
+ p.allow_tools = [...defaults];
1316
+ if (!await saveDoc()) p.allow_tools = old;
1317
+ };
1318
+ }
1319
+
1320
+ function renderDangerTab(panel, p, el) {
1321
+ const defaults = S.status?.permissionDefaults?.dangerousCommands || [];
1322
+ const isDefault = defaults.length > 0 && JSON.stringify(p.dangerous_commands) === JSON.stringify(defaults);
1323
+ panel.innerHTML = `
1324
+ <div class="desc">正则源串(不区分大小写),命中即弹确认卡。删除正则 = 放宽对应命令为直通,请谨慎。</div>
1325
+ <div class="row" style="max-width:680px;margin:8px 0 10px">
1326
+ <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force">
1327
+ <div class="btn-wrap"><button class="btn sm" id="addDc">添加</button></div>
1328
+ <div class="btn-wrap"><button class="btn sm" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button></div>
1329
+ </div>
1330
+ ${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>' : ''}
1331
+ ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
1332
+ <table><tbody id="dcBody"></tbody></table>
1333
+ <div class="footer-note">权限改动即时保存并经热重载对下一条消息生效(新通道/重启后全量生效)。</div>`;
1334
+ $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
1335
+ `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
1336
+ || '<tr><td colspan="2" class="hint">(空)</td></tr>';
1337
+ $('#dcBody').querySelectorAll('button').forEach((b) =>
1338
+ b.onclick = async () => {
1339
+ const old = [...p.dangerous_commands];
1340
+ p.dangerous_commands.splice(Number(b.dataset.i), 1);
1341
+ if (!await saveDoc()) p.dangerous_commands = old;
1342
+ });
1343
+ $('#addDc').onclick = async () => {
1344
+ const v = $('#newDc').value.trim();
1345
+ if (!v) return;
1346
+ try { new RegExp(v, 'i'); } catch (e) { return toast(`正则无效:${e.message}`, true); }
1347
+ p.dangerous_commands.push(v);
1348
+ if (!await saveDoc()) p.dangerous_commands.splice(p.dangerous_commands.indexOf(v), 1);
1349
+ else if ($('#newDc')) $('#newDc').value = '';
1350
+ };
1351
+ $('#resetDc').onclick = async () => {
1352
+ if (!defaults.length) return;
1353
+ if (!window.confirm('恢复为内置默认黑名单?当前自定义项将被覆盖(立即保存生效)。')) return;
1354
+ const old = [...p.dangerous_commands];
1355
+ p.dangerous_commands = [...defaults];
1356
+ if (!await saveDoc()) p.dangerous_commands = old;
1357
+ };
1358
+ }
1359
+
1360
+ // ============ 斜杠命令(远端直操作飞书 API;本页与 config.yaml / 保存按钮无关) ============
1361
+ function renderSlash(el) {
1362
+ el.innerHTML = `
1363
+ <div class="card">
1364
+ <h3>飞书斜杠命令(远端管理)</h3>
1365
+ <div class="desc">直接读写飞书开放平台已注册的斜杠命令;聊天输入 <code>/</code> 弹出指令面板,选中后可继续输入描述再发送。需给应用开通 <code>application:app_slash_command</code> 读写权限并发布版本;改动约 5 分钟后生效(PC 端 7.70+)。<b>本页操作即时提交飞书,与底部「保存」按钮无关。</b></div>
1366
+ <div class="row" style="align-items:end">
1367
+ <div style="max-width:320px"><label>目标应用</label><select id="scApp"></select></div>
1368
+ <button class="btn" id="scReload">刷新列表</button>
1369
+ <button class="btn primary" id="scAdd">+ 新增命令</button>
1370
+ <button class="btn" id="scBuiltins">补齐内置命令</button>
1371
+ </div>
1372
+ <table style="margin-top:12px"><thead><tr><th style="width:170px">命令</th><th>描述</th><th style="width:150px">图标</th><th style="width:130px">操作</th></tr></thead>
1373
+ <tbody id="scBody"><tr><td colspan="4" style="padding:36px 0"><div class="br-spin-sm"></div><div class="hint" style="text-align:center">正在从飞书拉取斜杆命令…</div></td></tr></tbody></table>
1374
+ <pre class="report" id="scReport" style="display:none;margin-top:12px"></pre>
1375
+ </div>`;
1376
+ const appSel = $('#scApp');
1377
+ (S.status?.apps || []).forEach((a) => {
1378
+ const opt = document.createElement('option');
1379
+ opt.value = a.name;
1380
+ opt.textContent = `${a.name}(${a.appId})`;
1381
+ appSel.appendChild(opt);
1382
+ });
1383
+ const showReport = (title, content) => {
1384
+ $('#scReport').style.display = '';
1385
+ $('#scReport').textContent = `${title}\n${content}`;
1386
+ };
1387
+ const loadRemote = async () => {
1388
+ if (!appSel.value) { $('#scBody').innerHTML = '<tr><td colspan="4" class="hint">无可用应用(先到「飞书应用」页配置并保存)</td></tr>'; return; }
1389
+ $('#scBody').innerHTML = '<tr><td colspan="4" style="padding:36px 0"><div class="br-spin-sm"></div><div class="hint" style="text-align:center">正在从飞书拉取斜杆命令…</div></td></tr>';
1390
+ try {
1391
+ const r = await api('GET', `/api/slash-commands/remote?app=${encodeURIComponent(appSel.value)}`);
1392
+ $('#scBody').innerHTML = r.remote.map((x) => `
1393
+ <tr data-cid="${esc(x.command_id)}" data-cmd="${esc(x.command)}">
1394
+ <td><code>/${esc(x.command)}</code></td>
1395
+ <td>${esc(x.description?.default_value || '')}</td>
1396
+ <td class="hint">${esc(x.description?.icon?.icon_key || '—')}</td>
1397
+ <td>
1398
+ <button class="btn sm" data-op="edit">编辑</button>
1399
+ <button class="btn sm danger" data-op="del">删除</button>
1400
+ </td>
1401
+ </tr>`).join('') || '<tr><td colspan="4" class="hint">远端暂无命令;点「补齐内置命令」一键注册 bridge 内置指令</td></tr>';
1402
+ $('#scBody').querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
1403
+ const tr = b.closest('tr');
1404
+ if (b.dataset.op === 'del') return delRemote(tr.dataset.cid, tr.dataset.cmd);
1405
+ openSlashDrawer({
1406
+ commandId: tr.dataset.cid,
1407
+ command: tr.dataset.cmd,
1408
+ description: tr.children[1].textContent,
1409
+ icon: tr.children[2].textContent === '—' ? '' : tr.children[2].textContent,
1410
+ });
1411
+ });
1412
+ } catch (e) {
1413
+ $('#scBody').innerHTML = `<tr><td colspan="4" class="hint">加载失败:${esc(e.message)}(确认已开通斜杠命令权限后点「刷新列表」重试)</td></tr>`;
1414
+ }
1415
+ };
1416
+ const delRemote = async (cid, cmd) => {
1417
+ if (!window.confirm(`确认删除远端命令 /${cmd}?(约 5 分钟后从飞书面板消失,不可恢复)`)) return;
1418
+ try {
1419
+ await api('POST', '/api/slash-commands/action', { op: 'delete', app: appSel.value, commandId: cid });
1420
+ toast(`已删除 /${cmd}`);
1421
+ loadRemote();
1422
+ } catch (e) { toast(e.message, true); }
1423
+ };
1424
+ const openSlashDrawer = ({ commandId, command, description, icon }) => {
1425
+ const isCreate = !commandId;
1426
+ openDrawer({
1427
+ title: isCreate ? '新增斜杠命令' : `编辑 /${command}`,
1428
+ bodyHtml: `
1429
+ <label>命令(不带 /)</label>
1430
+ <input type="text" id="dcCmd" ${isCreate ? '' : 'disabled title="飞书侧命令名不可修改(如需改名请删除后重建)"'} value="${esc(command || '')}" placeholder="produce">
1431
+ <label>描述(指令面板展示)</label>
1432
+ <input type="text" id="dcDesc" value="${esc(description || '')}" placeholder="内容生产流程">
1433
+ <label>图标 icon_key(可空,取值见飞书开放平台图标列表)</label>
1434
+ <input type="text" id="dcIcon" value="${esc(icon || '')}" placeholder="skill_outlined">`,
1435
+ footHtml: `<button class="btn primary" id="dcSave">${isCreate ? '创建(即时提交飞书)' : '保存(即时提交飞书)'}</button>`,
1436
+ onMount: () => {
1437
+ document.getElementById('dcSave').onclick = async () => {
1438
+ const cmd = document.getElementById('dcCmd').value.trim();
1439
+ const desc = document.getElementById('dcDesc').value.trim();
1440
+ const ic = document.getElementById('dcIcon').value.trim();
1441
+ if (!desc) return toast('描述不能为空', true);
1442
+ try {
1443
+ if (isCreate) {
1444
+ if (!cmd) return toast('命令名不能为空', true);
1445
+ await api('POST', '/api/slash-commands/action', { op: 'create', app: appSel.value, command: cmd, description: desc, icon: ic });
1446
+ } else {
1447
+ await api('POST', '/api/slash-commands/action', { op: 'update', app: appSel.value, commandId, description: desc, icon: ic });
1448
+ }
1449
+ toast(isCreate ? `已创建 /${cmd}` : '已保存');
1450
+ closeDrawer();
1451
+ loadRemote();
1452
+ } catch (e) { toast(e.message, true); }
1453
+ };
1454
+ },
1455
+ });
1456
+ };
1457
+ appSel.onchange = loadRemote;
1458
+ $('#scReload').onclick = loadRemote;
1459
+ $('#scAdd').onclick = () => openSlashDrawer({ command: '', description: '', icon: '' });
1460
+ $('#scBuiltins').onclick = async () => {
1461
+ if (!appSel.value) return;
1462
+ $('#scBuiltins').disabled = true;
1463
+ try {
1464
+ const r = await api('POST', '/api/slash-commands/sync', { app: appSel.value, mode: 'builtins-only' });
1465
+ const lines = [
1466
+ r.created.length ? `✅ 新建 ${r.created.length}:${r.created.join('、')}` : '· 内置命令已齐全(无新建)',
1467
+ ...(r.errors.length ? [`⚠️ 失败 ${r.errors.length}:`, ...r.errors.map((e) => ` ${e.command}: ${e.error}`)] : []),
1468
+ '(只补缺失的内置命令,不会删除或修改远端已有命令)',
1469
+ ];
1470
+ showReport('补齐内置命令完成(约 5 分钟后飞书生效)', lines.join('\n'));
1471
+ toast(r.errors.length ? `补齐完成,但 ${r.errors.length} 条失败(详见报告)` : '✅ 内置命令已补齐,约 5 分钟后生效');
1472
+ loadRemote();
1473
+ } catch (e) { showReport('补齐失败', e.message); toast(e.message, true); }
1474
+ $('#scBuiltins').disabled = false;
1475
+ };
1476
+ loadRemote();
1477
+ }
1478
+
1479
+ // ============ 插件 ============
1480
+ async function renderPlugins(el) {
1481
+ el.innerHTML = `
1482
+ <div class="card">
1483
+ <h3>Claude Code 插件</h3>
1484
+ <div class="desc">两处目录的已启用插件都会自动加载(managed 托管目录优先):本机 <code>~/.claude</code>(来源「本机」,与本机 claude CLI 共用)与 bridge 托管目录 <code id="pluginDir">…</code>。装好后下一条消息自动加载。飞书端同样可用 <code>/plugin install xxx@marketplace</code>(仅管理员)。</div>
1485
+ <table><thead><tr><th>插件</th><th>市场</th><th>来源</th><th>版本</th><th style="width:90px">状态</th><th style="width:250px">操作</th></tr></thead>
1486
+ <tbody id="plBody"><tr><td colspan="6" class="hint">加载中…</td></tr></tbody></table>
1487
+ </div>
1488
+ <div class="card">
1489
+ <h3>安装 / 市场</h3>
1490
+ <div class="desc">安装来源为已添加的市场:新插件先「添加市场」,再从下拉选择安装。</div>
1491
+ <label>从市场安装插件</label>
1492
+ <div class="row" style="max-width:760px">
1493
+ <div class="combo">
1494
+ <input type="text" id="plInstall" placeholder="(加载中…)" autocomplete="off">
1495
+ <div class="combo-panel" id="plInstallPanel" hidden></div>
1496
+ </div>
1497
+ <select id="plDir" style="max-width:170px">
1498
+ <option value="user" selected>装到本机 ~/.claude</option>
1499
+ <option value="bridge">装到 bridge 托管目录</option>
1500
+ </select>
1501
+ <button class="btn primary" id="plDoInstall">安装</button>
1502
+ </div>
1503
+ <div class="hint" style="margin:6px 0">默认装本机 ~/.claude:本机 claude CLI 与桥接器两处共用一份,无须安装两次。装好后下一条消息自动加载。</div>
1504
+ <label style="margin-top:14px">管理市场</label>
1505
+ <div class="row" style="max-width:760px">
1506
+ <input type="text" id="plMarket" placeholder="marketplace git 地址或本机路径">
1507
+ <select id="plMarketDir" style="max-width:170px">
1508
+ <option value="user" selected>本机 ~/.claude</option>
1509
+ <option value="bridge">bridge 托管目录</option>
1510
+ </select>
1511
+ <button class="btn" id="plDoMarketAdd">添加市场</button>
1512
+ <button class="btn" id="plDoMarketUpdate">全部更新</button>
1513
+ </div>
1514
+ <div class="hint" style="margin:6px 0">「全部更新」= 刷新市场索引 + 逐个更新该目录所有已装插件。</div>
1515
+ <pre class="report" id="plReport" style="display:none;margin-top:12px"></pre>
1516
+ </div>`;
1517
+ const report = (title, text) => {
1518
+ $('#plReport').style.display = '';
1519
+ $('#plReport').textContent = `${title}\n${text}`;
1520
+ };
1521
+ let available = []; // 当前目录可装清单(扁平化,combobox 数据源)
1522
+ let selectedInstall = ''; // 已选中安装参数 name@marketplace(输入改动即失效)
1523
+ let installed = []; // 已装清单缓存(update-all 前端编排取该目录步骤用)
1524
+ const busyText = {
1525
+ install: '安装中…', uninstall: '卸载中…', update: '更新中…', 'update-all': '更新中…',
1526
+ 'marketplace-add': '添加中…', 'marketplace-remove': '移除中…', enable: '处理中…', disable: '处理中…',
1527
+ };
1528
+ const opNames = { install: '安装', uninstall: '卸载', update: '更新', 'update-all': '全部更新', 'marketplace-add': '添加市场', 'marketplace-remove': '移除市场', enable: '启用', disable: '停用' };
1529
+ const load = async () => {
1530
+ try {
1531
+ const r = await api('GET', '/api/plugins');
1532
+ installed = r.plugins || [];
1533
+ $('#pluginDir').textContent = r.configDir;
1534
+ const body = $('#plBody');
1535
+ if (!installed.length) {
1536
+ body.innerHTML = '<tr><td colspan="6" class="hint">尚未安装任何插件</td></tr>';
1537
+ return;
1538
+ }
1539
+ body.innerHTML = installed.map((p) => `
1540
+ <tr>
1541
+ <td><b>${esc(p.name)}</b></td>
1542
+ <td>${esc(p.marketplace || '-')}</td>
1543
+ <td>${p.source === 'bridge' ? '<span class="tag warn">bridge 托管</span>' : '<span class="tag off">本机 ~/.claude</span>'}</td>
1544
+ <td class="hint">${esc(p.version || '-')}${p.latestVersion ? ` <span class="tag warn">→ ${esc(p.latestVersion)}</span>` : ''}</td>
1545
+ <td>${p.enabled ? '<span class="tag ok">已启用</span>' : '<span class="tag off">未启用</span>'}</td>
1546
+ <td>
1547
+ <button class="btn sm" data-op="update" data-arg="${esc(p.key)}" data-dir="${p.source}">更新</button>
1548
+ <button class="btn sm" data-op="${p.enabled ? 'disable' : 'enable'}" data-arg="${esc(p.key)}" data-dir="${p.source}">${p.enabled ? '停用' : '启用'}</button>
1549
+ <button class="btn sm danger" data-op="uninstall" data-arg="${esc(p.key)}" data-dir="${p.source}">卸载</button>
1550
+ </td>
1551
+ </tr>`).join('');
1552
+ body.querySelectorAll('button[data-op]').forEach((b) => b.onclick = () => {
1553
+ // 慢操作走进度弹框;亚秒级启停走轻量 act(弹框一闪而过反而干扰)
1554
+ if (b.dataset.op === 'enable' || b.dataset.op === 'disable') act(b.dataset.op, b.dataset.arg, b.dataset.dir, b);
1555
+ else runOpDialog(b.dataset.op, b.dataset.arg, b.dataset.dir, b);
1556
+ });
1557
+ } catch (e) {
1558
+ installed = [];
1559
+ $('#plBody').innerHTML = `<tr><td colspan="6" class="hint">加载失败:${esc(e.message)}</td></tr>`;
1560
+ }
1561
+ };
1562
+ /** 轻量操作(enable/disable 亚秒级):按钮瞬时 loading + toast,不弹框 */
1563
+ const act = async (op, arg, dir, btn) => {
1564
+ const pageBtns = el.querySelectorAll('button');
1565
+ const old = btn ? btn.textContent : '';
1566
+ pageBtns.forEach((b) => { b.disabled = true; });
1567
+ if (btn) btn.textContent = busyText[op] || '处理中…';
1568
+ try {
1569
+ const r = await api('POST', '/api/plugins/action', { op, arg, ...(dir ? { dir } : {}) });
1570
+ report(r.ok ? `✅ ${op} 完成` : `❌ ${op} 失败`, r.text);
1571
+ await load();
1572
+ toast(r.ok ? `${opNames[op] || op}完成,下一条消息生效` : `${opNames[op] || op}失败:${r.text.slice(0, 120)}`, !r.ok);
1573
+ } catch (e) {
1574
+ report(`❌ ${op} 失败`, e.message);
1575
+ toast(e.message, true);
1576
+ } finally {
1577
+ pageBtns.forEach((b) => { b.disabled = false; });
1578
+ if (btn) btn.textContent = old;
1579
+ }
1580
+ };
1581
+ /**
1582
+ * 慢操作进度弹框(install/update/uninstall/marketplace-add/update-all):
1583
+ * spinner + 进度条 + 逐步日志。update-all 走前端编排(marketplace-update + 逐插件
1584
+ * update,每步一请求)→ 进度真实(i/N);单操作是单请求无内部进度 → 时间缓动模拟
1585
+ * (前 2s 直线推进,之后渐缓封顶 90%,完成跳 100%)。进行中不可关闭;完成后列表刷新。
1586
+ */
1587
+ const runOpDialog = async (op, arg, dir, btn) => {
1588
+ const dirLabel = dir === 'bridge' ? 'bridge 托管目录' : '本机 ~/.claude';
1589
+ const title = op === 'update-all'
1590
+ ? `全部更新(${dirLabel})`
1591
+ : `${opNames[op] || op} ${arg}${dir ? `(${dirLabel})` : ''}`;
1592
+ // update-all 编排步骤:刷新市场索引 + 该目录逐个已装插件(失败不中断,与后端聚合语义一致)
1593
+ const steps = op === 'update-all'
1594
+ ? [{ op: 'marketplace-update', arg: '', label: '刷新市场索引' },
1595
+ ...installed.filter((p) => p.source === dir).map((p) => ({ op: 'update', arg: p.key, label: `更新 ${p.key}` }))]
1596
+ : [{ op, arg, label: `${opNames[op] || op} ${arg}` }];
1597
+ // 弹框 DOM(遮罩不可点击关闭;关闭按钮完成前不存在)
1598
+ const mask = document.createElement('div');
1599
+ mask.className = 'modal-mask';
1600
+ mask.innerHTML = `
1601
+ <div class="modal" role="dialog" aria-modal="true" style="width:min(640px,94vw)">
1602
+ <div style="display:flex;align-items:center;gap:10px">
1603
+ <div class="br-spin" style="margin:0"></div>
1604
+ <b id="podTitle">${esc(title)}</b>
1605
+ </div>
1606
+ <div class="prog"><div class="prog-bar" id="podBar"></div></div>
1607
+ <pre class="report" id="podLog" style="margin-top:12px;max-height:220px">正在执行 claude plugin …(安装/更新可能需要拉取仓库,请稍候)</pre>
1608
+ <div class="savebar" style="justify-content:flex-end">
1609
+ <button class="btn primary" id="podClose" hidden>关闭</button>
1610
+ </div>
1611
+ </div>`;
1612
+ document.body.appendChild(mask);
1613
+ const bar = mask.querySelector('#podBar');
1614
+ const log = mask.querySelector('#podLog');
1615
+ const spin = mask.querySelector('.br-spin');
1616
+ const titleEl = mask.querySelector('#podTitle');
1617
+ const closeBtn = mask.querySelector('#podClose');
1618
+ const setBar = (pct) => { bar.style.width = `${pct}%`; };
1619
+ const appendLog = (line) => { log.textContent += `\n${line}`; log.scrollTop = log.scrollHeight; };
1620
+ // 单请求无内部进度:缓动模拟;update-all 用真实 i/N 不走此定时器
1621
+ let fakePct = 0;
1622
+ const startedAt = Date.now();
1623
+ const timer = op === 'update-all' ? null : setInterval(() => {
1624
+ const elapsed = Date.now() - startedAt;
1625
+ fakePct = elapsed < 2000 ? Math.min(70, (elapsed / 2000) * 70) : Math.min(90, fakePct + 1);
1626
+ setBar(fakePct);
1627
+ }, 300);
1628
+ // 操作互斥 + 被点按钮进行时文案(遮罩之外的双保险)
1629
+ const pageBtns = el.querySelectorAll('button');
1630
+ const old = btn ? btn.textContent : '';
1631
+ pageBtns.forEach((b) => { b.disabled = true; });
1632
+ if (btn) btn.textContent = busyText[op] || '处理中…';
1633
+ report(`执行中:${op} ${arg}${dir ? `(${dirLabel})` : ''}`, '…(安装/更新可能需要拉取仓库,请稍候)');
1634
+ const close = async () => { mask.remove(); await Promise.all([load(), loadAvailable()]); };
1635
+ try {
1636
+ let allOk = true;
1637
+ const lines = [];
1638
+ for (let i = 0; i < steps.length; i++) {
1639
+ const s = steps[i];
1640
+ if (op === 'update-all') setBar(Math.round(((i + 1) / steps.length) * 100));
1641
+ const r = await api('POST', '/api/plugins/action', { op: s.op, arg: s.arg, ...(dir ? { dir } : {}) });
1642
+ const line = `${r.ok ? '✅' : '❌'} ${s.label}${r.ok ? '' : `:${(r.text || '').split('\n').slice(0, 3).join(' ⏎ ').slice(0, 300)}`}`;
1643
+ lines.push(line);
1644
+ appendLog(line);
1645
+ if (!r.ok) allOk = false;
1646
+ }
1647
+ if (timer) clearInterval(timer);
1648
+ setBar(100);
1649
+ bar.classList.add('done');
1650
+ spin.style.display = 'none';
1651
+ titleEl.textContent = `${allOk ? '✅' : '❌'} ${title} · ${allOk ? '完成' : '部分失败(详见日志与底部全文)'}`;
1652
+ report(allOk ? `✅ ${op} 完成` : `❌ ${op} 部分失败`, lines.join('\n'));
1653
+ closeBtn.hidden = false;
1654
+ closeBtn.onclick = () => void close();
1655
+ } catch (e) {
1656
+ if (timer) clearInterval(timer);
1657
+ setBar(100);
1658
+ spin.style.display = 'none';
1659
+ titleEl.textContent = `❌ ${title} · 失败`;
1660
+ appendLog(`❌ 请求失败:${e.message}`);
1661
+ report(`❌ ${op} 失败`, e.message);
1662
+ closeBtn.hidden = false;
1663
+ closeBtn.onclick = () => void close();
1664
+ } finally {
1665
+ pageBtns.forEach((b) => { b.disabled = false; });
1666
+ if (btn) btn.textContent = old;
1667
+ }
1668
+ };
1669
+ /** 安装 combobox 数据:该目录已添加市场里的可安装插件(扁平化 + 关键词过滤面板) */
1670
+ const loadAvailable = async () => {
1671
+ const input = $('#plInstall');
1672
+ if (!input) return;
1673
+ try {
1674
+ const r = await api('GET', `/api/plugins/available?dir=${$('#plDir').value}`);
1675
+ available = [];
1676
+ for (const m of r.marketplaces || []) {
1677
+ for (const p of m.plugins || []) {
1678
+ available.push({ key: `${p.name}@${m.name}`, name: p.name, market: m.name, version: p.version || '', description: p.description || '' });
1679
+ }
1680
+ }
1681
+ $('#plDoInstall').disabled = !available.length;
1682
+ input.placeholder = available.length ? '输入关键词搜索插件,从结果中选择' : '(请先在下方添加市场)';
1683
+ } catch (e) {
1684
+ available = [];
1685
+ $('#plDoInstall').disabled = true;
1686
+ input.placeholder = `(加载失败:${e.message})`;
1687
+ }
1688
+ input.value = '';
1689
+ selectedInstall = '';
1690
+ hidePanel();
1691
+ };
1692
+ const hidePanel = () => { const p = $('#plInstallPanel'); if (p) p.hidden = true; };
1693
+ const pickInstall = (key) => {
1694
+ selectedInstall = key;
1695
+ $('#plInstall').value = key;
1696
+ hidePanel();
1697
+ };
1698
+ const showMatches = () => {
1699
+ const panel = $('#plInstallPanel');
1700
+ if (!panel) return;
1701
+ const q = $('#plInstall').value.trim().toLowerCase();
1702
+ const hits = q
1703
+ ? available.filter((it) => `${it.name} ${it.description} ${it.market}`.toLowerCase().includes(q))
1704
+ : available;
1705
+ const shown = hits.slice(0, 50);
1706
+ panel.hidden = false;
1707
+ panel.innerHTML = shown.length
1708
+ ? shown.map((it) => `<div class="combo-item" data-key="${esc(it.key)}"><b>${esc(it.name)}</b><span class="tag off">${esc(it.market)}</span>${it.version ? ` <span class="hint">v${esc(it.version)}</span>` : ''}${it.description ? `<div class="hint">${esc(it.description.slice(0, 80))}</div>` : ''}</div>`).join('')
1709
+ + (hits.length > shown.length ? `<div class="combo-empty">仅显示前 ${shown.length} 项,继续输入关键词缩小范围</div>` : '')
1710
+ : `<div class="combo-empty">${q ? '无匹配插件' : '(该目录还没有可安装插件)'}</div>`;
1711
+ panel.querySelectorAll('.combo-item').forEach((item) => {
1712
+ item.onmousedown = (e) => { e.preventDefault(); pickInstall(item.dataset.key); }; // mousedown+preventDefault 防输入框 blur 抢先收起面板
1713
+ });
1714
+ };
1715
+ $('#plInstall').oninput = () => { selectedInstall = ''; showMatches(); };
1716
+ $('#plInstall').onfocus = showMatches;
1717
+ $('#plInstall').onkeydown = (e) => {
1718
+ if (e.key === 'Enter') {
1719
+ e.preventDefault();
1720
+ const first = $('#plInstallPanel')?.querySelector('.combo-item');
1721
+ if (first) pickInstall(first.dataset.key);
1722
+ } else if (e.key === 'Escape') hidePanel();
1723
+ };
1724
+ // 点击 combobox 之外收起面板(只注册一次:$ 按 id 查询当前 DOM,重渲染后仍指向新面板)
1725
+ if (!document.body._plComboHideBound) {
1726
+ document.body._plComboHideBound = true;
1727
+ document.addEventListener('click', (e) => {
1728
+ if (!(e.target instanceof Element) || !e.target.closest('.combo')) hidePanel();
1729
+ });
1730
+ }
1731
+ $('#plDoInstall').onclick = () => {
1732
+ if (!selectedInstall) return toast('请从搜索结果中选择要安装的插件', true);
1733
+ runOpDialog('install', selectedInstall, $('#plDir').value, $('#plDoInstall'));
1734
+ };
1735
+ $('#plDir').onchange = loadAvailable; // 安装目标目录切换 → 可装清单随之变化
1736
+ $('#plDoMarketAdd').onclick = () => {
1737
+ const v = $('#plMarket').value.trim();
1738
+ if (!v) return toast('请填写 marketplace git 地址或本机路径', true);
1739
+ runOpDialog('marketplace-add', v, $('#plMarketDir').value, $('#plDoMarketAdd'));
1740
+ };
1741
+ $('#plDoMarketUpdate').onclick = () => runOpDialog('update-all', '', $('#plMarketDir').value, $('#plDoMarketUpdate'));
1742
+ await Promise.all([load(), loadAvailable()]);
1743
+ }
1744
+
1745
+ // ============ 初始化 ============
1746
+ async function refresh() {
1747
+ S.status = await api('GET', '/api/status').catch(() => null);
1748
+ try {
1749
+ S.doc = await api('GET', '/api/config');
1750
+ S.firstRun = false;
1751
+ $('#hFirstRun').textContent = '';
1752
+ } catch (e) {
1753
+ if (String(e.message).includes('bootstrap')) {
1754
+ S.firstRun = true;
1755
+ S.doc = null;
1756
+ $('#hFirstRun').textContent = '首次安装向导';
1757
+ } else { throw e; }
1758
+ }
1759
+ const st = S.status || {};
1760
+ $('#hMeta').textContent = `v${st.version || '?'} · ${location.host}`;
1761
+ // 版本错位检测:静态页每次现读磁盘(新),API 属于运行中进程(旧)——status 无 bridge
1762
+ // 字段说明进程早于「页面托管启停」功能,提示重启而非让用户撞「未知端点」
1763
+ $('#staleBar').hidden = !(S.status && S.status.bridge === undefined);
1764
+ render();
1765
+ }
1766
+ refresh().catch((e) => { $('#main').innerHTML = `<div class="card"><h3>加载失败</h3><div class="desc">${esc(e.message)}</div></div>`; });
1767
+ </script>
1768
+ </body>
1769
+ </html>