@jesonliu/lark-claudecode-bridge 1.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/assets/web/css/main.css +766 -574
  2. package/assets/web/index.html +37 -17
  3. package/assets/web/js/auth.js +217 -0
  4. package/assets/web/js/bootstrap.js +36 -34
  5. package/assets/web/js/core.js +4 -3
  6. package/assets/web/js/main.js +79 -20
  7. package/assets/web/js/markdown.js +64 -0
  8. package/assets/web/js/pages/apps.js +338 -251
  9. package/assets/web/js/pages/chats.js +446 -0
  10. package/assets/web/js/pages/claude.js +243 -180
  11. package/assets/web/js/pages/mcp.js +59 -43
  12. package/assets/web/js/pages/memory.js +39 -43
  13. package/assets/web/js/pages/overview.js +344 -76
  14. package/assets/web/js/pages/permissions.js +40 -32
  15. package/assets/web/js/pages/plugins.js +51 -37
  16. package/assets/web/js/pages/skills.js +68 -92
  17. package/assets/web/js/pages/slash.js +31 -23
  18. package/assets/web/js/pages/workspaces.js +114 -73
  19. package/assets/web/js/theme.js +21 -18
  20. package/assets/web/js/ui.js +75 -32
  21. package/dist/bin/lcb.js +15 -0
  22. package/dist/bin/lcb.js.map +1 -1
  23. package/dist/gateway/feishu-gateway.d.ts +11 -0
  24. package/dist/gateway/feishu-gateway.js +5 -0
  25. package/dist/gateway/feishu-gateway.js.map +1 -1
  26. package/dist/gateway/gateway-registry.d.ts +16 -0
  27. package/dist/gateway/gateway-registry.js +10 -0
  28. package/dist/gateway/gateway-registry.js.map +1 -0
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/web/chats-api.d.ts +77 -0
  32. package/dist/web/chats-api.js +475 -0
  33. package/dist/web/chats-api.js.map +1 -0
  34. package/dist/web/logs-api.d.ts +37 -0
  35. package/dist/web/logs-api.js +141 -0
  36. package/dist/web/logs-api.js.map +1 -0
  37. package/dist/web/server.js +125 -6
  38. package/dist/web/server.js.map +1 -1
  39. package/dist/web/webauth.d.ts +44 -0
  40. package/dist/web/webauth.js +177 -0
  41. package/dist/web/webauth.js.map +1 -0
  42. package/package.json +1 -1
@@ -1,7 +1,11 @@
1
1
  // ============ 权限(页内一级 tab:免确认工具 / 危险命令黑名单) ============
2
+ // 改动即时保存并热生效;「全部工具免确认」缺省开启(显式 false 才算关闭),开启时黑名单是唯一安全网。
2
3
  import { S, $, esc, toast, saveDoc } from '../core.js';
3
4
  import { confirmDialog } from '../ui.js';
4
5
 
6
+ const svg = (inner, s = 14) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
7
+ const IC_INFO = '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/><path d="M12 12v4"/>';
8
+
5
9
  let permTab = 'allow'; // tab 状态:切换 / 重渲染保持
6
10
  function renderPermissions(el) {
7
11
  const p = S.doc.permissions || (S.doc.permissions = {});
@@ -10,7 +14,7 @@ function renderPermissions(el) {
10
14
  el.innerHTML = `
11
15
  <div class="tabs">
12
16
  <button data-t="allow" class="${permTab === 'allow' ? 'active' : ''}">免确认工具</button>
13
- <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单</button>
17
+ <button data-t="danger" class="${permTab === 'danger' ? 'active' : ''}">危险命令黑名单 <span class="tag off" style="margin-left:4px">${p.dangerous_commands.length}</span></button>
14
18
  </div>
15
19
  <div id="permPanel"></div>`;
16
20
  el.querySelectorAll('.tabs button').forEach((b) =>
@@ -26,28 +30,30 @@ function renderAllowTab(panel, p) {
26
30
  // 缺省即开(与后端 DEFAULT_ALLOW_ALL_TOOLS 一致):只有显式 false 才算用户主动关闭
27
31
  const allOn = p.allow_all_tools !== false;
28
32
  panel.innerHTML = `
29
- <div class="card">
30
- <h3>免确认工具</h3>
31
- <div class="switch-row">
32
- <label class="switch">
33
- <input type="checkbox" id="allowAll" ${allOn ? 'checked' : ''}>
34
- <span class="track"></span>
35
- </label>
36
- <span class="switch-label">全部工具免确认(除危险命令黑名单外)</span>
37
- </div>
38
- ${allOn ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 已开启全部放行:除命中危险命令黑名单的 Bash 外,所有工具(含 Write/Edit)都不再弹确认卡,下方名单暂不生效</div>' : ''}
39
- <div class="${allOn ? 'block-disabled' : ''}">
40
- <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
41
- <div class="list-toolbar">
42
- <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:320px">
43
- <button class="btn" id="addTool">添加</button>
44
- <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
33
+ <div class="panel">
34
+ <header class="panel-head"><h3>免确认工具</h3>
35
+ <div class="panel-actions">
36
+ ${isDefault ? '<span class="tag off">与内置默认一致</span>' : ''}
37
+ <span class="tip" data-tip="开启后除命中黑名单的 Bash 外,所有工具(含写入)都不再弹确认卡">${svg(IC_INFO)}</span>
38
+ </div></header>
39
+ <div class="panel-body">
40
+ <div class="switch-row" style="margin-top:0">
41
+ <label class="switch"><input type="checkbox" id="allowAll" ${allOn ? 'checked' : ''}><span class="track"></span></label>
42
+ <span class="switch-label">全部工具免确认(除危险命令黑名单外)</span>
43
+ </div>
44
+ ${allOn ? '<div class="banner warn" style="margin:8px 0"><span>已开启全部放行——下方名单暂不生效,黑名单是唯一安全网。</span></div>' : ''}
45
+ <div style="${allOn ? 'opacity:.45;pointer-events:none' : ''}">
46
+ <div class="desc" style="margin:8px 0 4px">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
47
+ <table><tbody id="toolBody"></tbody></table>
48
+ ${p.allow_tools.length === 0 ? '<div class="banner warn" style="margin:8px 0"><span>⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</span></div>' : ''}
49
+ <div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
50
+ <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:300px">
51
+ <button class="btn" id="addTool">添加</button>
52
+ <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
53
+ </div>
45
54
  </div>
46
- ${p.allow_tools.length === 0 ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
47
- ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
48
- <table><tbody id="toolBody"></tbody></table>
49
55
  </div>
50
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:${allOn ? '全部免确认已开启,除危险命令外一律直通' : '白名单命中直通,未命中弹确认卡'}。</div>
56
+ <footer class="panel-foot">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:${allOn ? '全部免确认已开启,除危险命令外一律直通' : '白名单命中直通,未命中弹确认卡'}。</footer>
51
57
  </div>`;
52
58
  $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
53
59
  `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
@@ -112,18 +118,19 @@ function renderDangerTab(panel, p) {
112
118
  const defaults = S.status?.permissionDefaults?.dangerousCommands || [];
113
119
  const isDefault = defaults.length > 0 && JSON.stringify(p.dangerous_commands) === JSON.stringify(defaults);
114
120
  panel.innerHTML = `
115
- <div class="card">
116
- <h3>危险命令黑名单</h3>
117
- <div class="desc">正则源串(不区分大小写),命中即弹确认卡。删除正则 = 放宽对应命令为直通,请谨慎。</div>
118
- <div class="list-toolbar">
119
- <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force" style="max-width:480px">
120
- <button class="btn" id="addDc">添加</button>
121
- <button class="btn" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
121
+ <div class="panel">
122
+ <header class="panel-head"><h3>危险命令黑名单</h3>
123
+ <div class="panel-actions"><span class="tip" data-tip="正则(不区分大小写),命中的 Bash 命令弹确认卡">${svg(IC_INFO)}</span></div></header>
124
+ <div class="panel-body">
125
+ ${p.dangerous_commands.length === 0 ? '<div class="banner err" style="margin-bottom:10px"><span>黑名单为空——所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之。</span></div>' : ''}
126
+ <table><tbody id="dcBody"></tbody></table>
127
+ <div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
128
+ <input type="text" id="newDc" placeholder="正则源串,如 \\bgit\\s+push\\b.*--force" style="max-width:420px">
129
+ <button class="btn" id="addDc">添加</button>
130
+ <button class="btn" id="resetDc" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
131
+ </div>
122
132
  </div>
123
- ${p.dangerous_commands.length === 0 ? '<div style="background:var(--err-bg);color:var(--err-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
124
- ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
125
- <table><tbody id="dcBody"></tbody></table>
126
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。「全部工具免确认」开启时本黑名单是唯一的安全网——删除正则等于放行对应命令。</div>
133
+ <footer class="panel-foot">正则源串不区分大小写,命中即弹确认卡;删除正则 = 放宽对应命令为直通,请谨慎。改动即时保存并热生效。「全部工具免确认」开启时本黑名单是唯一的安全网。</footer>
127
134
  </div>`;
128
135
  $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
129
136
  `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
@@ -165,6 +172,7 @@ function renderDangerTab(panel, p) {
165
172
  export const page = {
166
173
  id: 'permissions',
167
174
  title: '权限',
175
+ desc: '工具免确认名单与危险命令黑名单,改动即时生效',
168
176
  icon: '<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"/>',
169
177
  render: renderPermissions,
170
178
  };
@@ -1,48 +1,58 @@
1
- // ============ 插件 ============
1
+ // ============ 插件(已装列表 + 管理市场 / 从市场安装 双卡;市场卡在左:先添加市场再安装) ============
2
2
  import { $, esc, toast, api } from '../core.js';
3
3
  import { confirmDialog } from '../ui.js';
4
4
 
5
5
  // 点击 combobox 之外收起面板的监听只注册一次(模块级标志,等价原 document.body 标记法)
6
6
  let comboHideBound = false;
7
+ let badgeCount = 0; // 有新版本的插件数(导航徽标;进过本页后可见)
7
8
 
8
9
  async function renderPlugins(el) {
9
10
  el.innerHTML = `
10
- <div class="card">
11
- <h3>Claude Code 插件 <button class="btn sm" id="plCheckUpdate" style="margin-left:8px">检查更新</button></h3>
12
- <div class="desc">两处目录的已启用插件都会自动加载(managed 托管目录优先):本机 <code>~/.claude</code>(来源「本机」,与本机 claude CLI 共用)与 bridge 托管目录 <code id="pluginDir">…</code>。装好后下一条消息自动加载。飞书端同样可用 <code>/plugin install xxx@marketplace</code>(仅管理员)。打开页面在后台自动检查远程市场新版本(不阻塞列表,5 分钟内不重复;只检测不升级,升级点单行「更新」)。</div>
13
- <div class="hint" id="plSyncNote" hidden style="margin:6px 0"></div>
14
- <table><thead><tr><th>插件</th><th>市场</th><th>来源</th><th>版本</th><th style="width:90px">状态</th><th style="width:250px">操作</th></tr></thead>
15
- <tbody id="plBody"><tr><td colspan="6" class="hint">加载中…</td></tr></tbody></table>
11
+ <div class="panel">
12
+ <header class="panel-head"><h3>已安装插件</h3>
13
+ <div class="panel-actions"><button class="btn sm" id="plCheckUpdate">检查更新</button></div></header>
14
+ <div class="panel-body">
15
+ <div class="hint" id="plSyncNote" hidden style="margin:0 0 8px"></div>
16
+ <table><thead><tr><th>插件</th><th style="width:120px">市场</th><th style="width:130px">来源</th><th style="width:170px">版本</th><th style="width:90px">状态</th><th style="width:230px">操作</th></tr></thead>
17
+ <tbody id="plBody"><tr><td colspan="6" class="hint">加载中…</td></tr></tbody></table>
18
+ </div>
19
+ <footer class="panel-foot">两处目录的已启用插件都会自动加载(managed 托管目录优先):本机 <code>~/.claude</code> 与 bridge 托管目录 <code id="pluginDir">…</code>。启停 / 更新后<b>下一条消息生效</b>,无须重启;飞书端亦可用 <code>/plugin install xxx@marketplace</code>(仅管理员)。</footer>
16
20
  </div>
17
- <div class="card">
18
- <h3>安装 / 市场</h3>
19
- <div class="desc">安装来源为已添加的市场:新插件先「添加市场」,再从下拉选择安装。</div>
20
- <label>从市场安装插件</label>
21
- <div class="row" style="max-width:760px">
22
- <div class="combo">
23
- <input type="text" id="plInstall" placeholder="(加载中…)" autocomplete="off">
24
- <div class="combo-panel" id="plInstallPanel" hidden></div>
21
+ <div class="grid2" style="gap:16px;align-items:start">
22
+ <div class="panel" style="margin:0">
23
+ <header class="panel-head"><h3>管理市场</h3></header>
24
+ <div class="panel-body">
25
+ <div class="mini-note" style="margin-bottom:10px">安装来源为已添加的市场:新插件先在这里「添加市场」,再到右侧安装。</div>
26
+ <label class="fld">添加市场(git 地址或本机路径)</label>
27
+ <div class="input-btn"><input type="text" id="plMarket" placeholder="https://github.com/…/marketplace"><button class="btn" id="plDoMarketAdd">添加</button></div>
28
+ <label class="fld" style="margin-top:12px">目标目录</label>
29
+ <div class="input-btn"><select id="plMarketDir">
30
+ <option value="user" selected>本机 ~/.claude</option>
31
+ <option value="bridge">bridge 托管目录</option>
32
+ </select><button class="btn" id="plDoMarketUpdate">全部更新</button></div>
33
+ <div class="mini-note" style="margin-top:8px">「全部更新」= 刷新市场索引 + 逐个更新该目录所有已装插件。</div>
25
34
  </div>
26
- <select id="plDir" style="max-width:170px">
27
- <option value="user" selected>装到本机 ~/.claude</option>
28
- <option value="bridge">装到 bridge 托管目录</option>
29
- </select>
30
- <button class="btn primary" id="plDoInstall">安装</button>
31
35
  </div>
32
- <div class="hint" style="margin:6px 0">默认装本机 ~/.claude:本机 claude CLI 与桥接器两处共用一份,无须安装两次。装好后下一条消息自动加载。</div>
33
- <label style="margin-top:14px">管理市场</label>
34
- <div class="row" style="max-width:760px">
35
- <input type="text" id="plMarket" placeholder="marketplace git 地址或本机路径">
36
- <select id="plMarketDir" style="max-width:170px">
37
- <option value="user" selected>本机 ~/.claude</option>
38
- <option value="bridge">bridge 托管目录</option>
39
- </select>
40
- <button class="btn" id="plDoMarketAdd">添加市场</button>
41
- <button class="btn" id="plDoMarketUpdate">全部更新</button>
36
+ <div class="panel" style="margin:0">
37
+ <header class="panel-head"><h3>从市场安装</h3></header>
38
+ <div class="panel-body">
39
+ <label class="fld">搜索插件 <span class="tip" data-tip="候选来自已添加市场的全部插件;也可在飞书端 /plugin install xxx@marketplace(仅管理员)">ⓘ</span></label>
40
+ <div class="input-btn" style="align-items:flex-start">
41
+ <div class="combo" style="flex:1;min-width:0">
42
+ <input type="text" id="plInstall" placeholder="(加载中…)" autocomplete="off">
43
+ <div class="combo-panel" id="plInstallPanel" hidden></div>
44
+ </div>
45
+ </div>
46
+ <label class="fld" style="margin-top:12px">安装到</label>
47
+ <select id="plDir">
48
+ <option value="user" selected>本机 ~/.claude(推荐:本机 CLI 与桥接器共用一份)</option>
49
+ <option value="bridge">bridge 托管目录</option>
50
+ </select>
51
+ <div style="margin-top:12px"><button class="btn primary" id="plDoInstall">安装</button></div>
52
+ </div>
42
53
  </div>
43
- <div class="hint" style="margin:6px 0">「全部更新」= 刷新市场索引 + 逐个更新该目录所有已装插件。</div>
44
- <pre class="report" id="plReport" style="display:none;margin-top:12px"></pre>
45
- </div>`;
54
+ </div>
55
+ <pre class="report" id="plReport" style="display:none;margin-top:0"></pre>`;
46
56
  const report = (title, text) => {
47
57
  $('#plReport').style.display = '';
48
58
  $('#plReport').textContent = `${title}\n${text}`;
@@ -65,11 +75,12 @@ async function renderPlugins(el) {
65
75
  /** 渲染已装插件表(本地秒出与后台刷新完成两条路径共用;入参 /api/plugins 响应) */
66
76
  const renderList = (r) => {
67
77
  installed = r.plugins || [];
78
+ badgeCount = installed.filter((p) => p.latestVersion).length;
68
79
  $('#pluginDir').textContent = r.configDir;
69
80
  const body = $('#plBody');
70
81
  if (!body) return; // 页面已切走(后台刷新回调晚于切页)
71
82
  if (!installed.length) {
72
- body.innerHTML = '<tr><td colspan="6" class="hint">尚未安装任何插件</td></tr>';
83
+ body.innerHTML = '<tr class="empty"><td colspan="6">尚未安装任何插件</td></tr>';
73
84
  return;
74
85
  }
75
86
  body.innerHTML = installed.map((p) => `
@@ -77,9 +88,9 @@ async function renderPlugins(el) {
77
88
  <td><b>${esc(p.name)}</b></td>
78
89
  <td>${esc(p.marketplace || '-')}</td>
79
90
  <td>${p.source === 'bridge' ? '<span class="tag warn">bridge 托管</span>' : '<span class="tag off">本机 ~/.claude</span>'}</td>
80
- <td class="hint">${esc(p.version || '-')}${p.latestVersion ? ` <span class="tag warn">→ ${esc(p.latestVersion)}</span>` : ''}</td>
91
+ <td><span class="mono">${esc(p.version || '-')}</span>${p.latestVersion ? ` <span class="tag warn">→ ${esc(p.latestVersion)}</span>` : ''}</td>
81
92
  <td>${p.enabled ? '<span class="tag ok">已启用</span>' : '<span class="tag off">未启用</span>'}</td>
82
- <td>
93
+ <td class="ops">
83
94
  <button class="btn sm" data-op="update" data-arg="${esc(p.key)}" data-dir="${p.source}">更新</button>
84
95
  <button class="btn sm" data-op="${p.enabled ? 'disable' : 'enable'}" data-arg="${esc(p.key)}" data-dir="${p.source}">${p.enabled ? '停用' : '启用'}</button>
85
96
  <button class="btn sm danger" data-op="uninstall" data-arg="${esc(p.key)}" data-dir="${p.source}">卸载</button>
@@ -263,7 +274,7 @@ async function renderPlugins(el) {
263
274
  }
264
275
  }
265
276
  $('#plDoInstall').disabled = !available.length;
266
- input.placeholder = available.length ? '输入关键词搜索插件,从结果中选择' : '(请先在下方添加市场)';
277
+ input.placeholder = available.length ? '输入关键词搜索插件,从结果中选择' : '(请先在左侧添加市场)';
267
278
  } catch (e) {
268
279
  available = [];
269
280
  $('#plDoInstall').disabled = true;
@@ -360,6 +371,9 @@ async function renderPlugins(el) {
360
371
  export const page = {
361
372
  id: 'plugins',
362
373
  title: '插件',
374
+ desc: '两处目录的 Claude Code 插件统一管理,改动下一条消息自动生效',
375
+ legend: false,
376
+ badge: () => (badgeCount ? `<span class="nav-badge">${badgeCount}</span>` : ''),
363
377
  icon: '<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"/>',
364
378
  render: renderPlugins,
365
379
  };
@@ -1,10 +1,12 @@
1
- // ============ Skills 管理页(#12)============
1
+ // ============ Skills 管理 ============
2
2
  // 后端:GET /api/skills · POST /api/skills/action(create/delete)· POST /api/skills/import(zip)
3
3
  // 四来源:用户级·本机(~/.claude/skills)/ 用户级·bridge(managed 生效目录)/ 项目级(工作区 .claude/skills)/
4
4
  // 已装插件内(<plugin-installPath>/skills;plugin 只读,未启用时标「(未启用)」)。
5
- // 远端 CRUD:改动即时落盘,无页内保存条
5
+ // 搜索只匹配「名称 + 说明」两列;过滤只切换行显隐不重建 DOM(兼容中文输入法组合输入)。
6
+ // 远端 CRUD:改动即时落盘,无页内保存条。
6
7
  import { $, esc, toast, api } from '../core.js';
7
8
  import { openDrawer, closeDrawer, confirmDialog, promptDialog } from '../ui.js';
9
+ import { renderMarkdown } from '../markdown.js';
8
10
 
9
11
  /** 来源标签(function:plugin 来源需要拼插件名与启用状态) */
10
12
  const SOURCE_TAG = {
@@ -17,34 +19,46 @@ const sourceTag = (s) => {
17
19
  if (s.source === 'plugin') return `插件 · ${esc(s.pluginName || '?')}${s.pluginEnabled ? '' : '(未启用)'}`;
18
20
  return SOURCE_TAG[s.source] || s.source;
19
21
  };
22
+ const sourceTagClass = (s) => (s.source === 'bridge' ? 'info' : s.source === 'plugin' ? (s.pluginEnabled ? 'off' : 'warn') : 'ghost');
20
23
  /** 来源是否可删(project / plugin 只读;其他用户级可删) */
21
24
  const isDeletable = (s) => s.source !== 'project' && s.source !== 'plugin';
22
25
 
23
26
  function renderSkills(el) {
24
27
  el.innerHTML = `
25
- <div class="card">
26
- <h3>Skills</h3>
27
- <div class="desc">Claude Code 技能(每项一个目录,含 SKILL.md)。列表聚合四个来源:本机用户级、bridge 自管用户级、各工作区项目级、已装插件内的 skills(只读展示)。新增(含 zip 导入)统一进用户级生效目录。</div>
28
- <div class="list-toolbar">
29
- <button class="btn" id="skillImport">导入 zip</button>
30
- <button class="btn primary" id="skillAdd">+ 新建</button>
28
+ <div class="panel">
29
+ <header class="panel-head"><h3>技能列表</h3>
30
+ <div class="panel-actions">
31
+ <input type="search" id="skillSearch" placeholder="过滤:名称 / 说明…" style="width:210px;padding:4px 10px;font-size:12.5px">
32
+ <button class="btn sm" id="skillImport">导入 zip</button>
33
+ <button class="btn primary sm" id="skillAdd">+ 新建</button>
34
+ </div></header>
35
+ <div class="panel-body">
36
+ <input type="file" id="skillZipFile" accept=".zip" style="display:none">
37
+ <table><thead><tr>
38
+ <th style="width:220px">名字</th><th>说明</th><th style="width:190px">来源</th><th style="width:130px">操作</th>
39
+ </tr></thead>
40
+ <tbody id="skillBody"><tr><td colspan="4" class="hint">加载中…</td></tr></tbody>
41
+ </table>
42
+ <div class="mini-note" id="skillEffective" style="margin-top:10px"></div>
31
43
  </div>
32
- <input type="file" id="skillZipFile" accept=".zip" style="display:none">
33
- <input type="search" class="list-search" id="skillSearch" placeholder="按名字 / 来源 / 路径过滤…">
34
- <table><thead><tr>
35
- <th style="width:170px">名字</th><th>说明</th><th style="width:130px">来源</th><th style="width:240px">路径</th><th style="width:130px"></th>
36
- </tr></thead>
37
- <tbody id="skillBody"><tr><td colspan="5" class="desc">加载中…</td></tr></tbody>
38
- </table>
39
- <div class="desc" id="skillEffective" style="margin-top:10px"></div>
44
+ <footer class="panel-foot">新增 / 导入统一落盘用户级生效目录;项目级与插件内来源只读。改动即时生效。</footer>
40
45
  </div>`;
41
46
  $('#skillAdd').onclick = addSkill;
42
- $('#skillSearch').oninput = filterSkillRows;
47
+ bindSearch();
43
48
  bindZipImport();
44
49
  void loadSkills();
45
50
  }
46
51
 
47
- /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden */
52
+ /** 搜索过滤:只匹配名称+说明;只切换 tr 显隐不重建 DOM;组合输入(中文 IME)期间暂停 */
53
+ function bindSearch() {
54
+ const inp = $('#skillSearch');
55
+ let composing = false;
56
+ inp.addEventListener('compositionstart', () => { composing = true; });
57
+ inp.addEventListener('compositionend', () => { composing = false; filterSkillRows(); });
58
+ inp.oninput = () => { if (!composing) filterSkillRows(); };
59
+ }
60
+
61
+ /** 搜索过滤:遍历 tr.dataset.searchText 切换 hidden(关键词只命中名称与说明) */
48
62
  function filterSkillRows() {
49
63
  const q = ($('#skillSearch').value || '').trim().toLowerCase();
50
64
  const body = $('#skillBody');
@@ -70,7 +84,7 @@ async function loadSkills() {
70
84
  try {
71
85
  data = await api('GET', '/api/skills');
72
86
  } catch (e) {
73
- body.innerHTML = `<tr><td colspan="5" class="desc">加载失败:${esc(e.message)}</td></tr>`;
87
+ body.innerHTML = `<tr><td colspan="4" class="hint">加载失败:${esc(e.message)}</td></tr>`;
74
88
  return;
75
89
  }
76
90
  const eff = $('#skillEffective');
@@ -83,22 +97,21 @@ async function loadSkills() {
83
97
  }
84
98
  const skills = data.skills || [];
85
99
  if (skills.length === 0) {
86
- body.innerHTML = `<tr class="empty-row"><td colspan="5" class="desc">暂无 skill(四个来源均未发现 SKILL.md)。可点上方「+ 新建」或「导入 zip」。</td></tr>`;
100
+ body.innerHTML = `<tr class="empty-row"><td colspan="4" class="empty">暂无 skill(四个来源均未发现 SKILL.md)<br><span class="mini-note">可点右上「+ 新建」或「导入 zip」。</span></td></tr>`;
87
101
  return;
88
102
  }
89
103
  body.innerHTML = '';
90
104
  for (const s of skills) {
91
- const tag = sourceTag(s);
92
105
  const tr = document.createElement('tr');
93
- tr.dataset.searchText = `${s.name} ${sourceTag(s)} ${s.path} ${s.description || ''}`.toLowerCase();
106
+ // 搜索只匹配名称 + 说明两列(路径/来源不参与,避免全字段模糊误命中)
107
+ tr.dataset.searchText = `${s.name} ${s.description || ''}`.toLowerCase();
94
108
  tr.innerHTML = `
95
- <td><strong>${esc(s.name)}</strong></td>
96
- <td>${esc(s.description) || '<span class="desc">(无说明)</span>'}</td>
97
- <td><span class="chip">${tag}</span></td>
98
- <td><code class="desc">${esc(s.path)}</code></td>
109
+ <td><b>${esc(s.name)}</b>${s.path ? `<div class="mini-note" style="margin-top:2px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="完整目录:${esc(s.path)}"><code>${esc(s.path)}</code></div>` : ''}</td>
110
+ <td>${esc(s.description) || '<span class="mut">(无说明)</span>'}</td>
111
+ <td><span class="tag ${sourceTagClass(s)}">${sourceTag(s)}</span></td>
99
112
  <td class="ops">
100
113
  <button class="btn sm" data-browse="${esc(s.name)}">查看</button>
101
- ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : ''}
114
+ ${isDeletable(s) ? `<button class="btn sm danger" data-del="${esc(s.name)}">删除</button>` : '<span class="dim" style="font-size:11.5px">只读</span>'}
102
115
  </td>`;
103
116
  tr.querySelector('[data-browse]').onclick = () => browseSkill(s);
104
117
  const delBtn = tr.querySelector('[data-del]');
@@ -121,71 +134,31 @@ async function delSkill(s) {
121
134
  .catch((e) => toast(e.message, true));
122
135
  }
123
136
 
124
- // ---------- skill 文件浏览(#3):目录树 + 原文/视图/双栏三种查看模式 ----------
125
-
126
- /** 极简 markdown → HTML(先整体转义再还原语法,绝无 XSS 面):标题/代码块/行内代码/粗斜体/列表/引用/链接/分割线 */
127
- function renderMarkdown(src) {
128
- const codeBlocks = [];
129
- let text = String(src).replace(/```(\w*)\n?([\s\S]*?)(?:```|$)/g, (_m, lang, code) => {
130
- codeBlocks.push(`<pre style="background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px;overflow:auto;font-size:12px"><code>${esc(code.replace(/\n$/, ''))}</code></pre>`);
131
- return `${codeBlocks.length - 1}`;
132
- });
133
- const lines = text.split('\n');
134
- const out = [];
135
- let listOpen = false;
136
- const closeList = () => { if (listOpen) { out.push('</ul>'); listOpen = false; } };
137
- const inline = (s) => esc(s)
138
- .replace(/`([^`]+)`/g, '<code style="background:var(--code-bg);padding:1px 5px;border-radius:4px;font-size:12px">$1</code>')
139
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
140
- .replace(/\*([^*]+)\*/g, '<em>$1</em>')
141
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--primary)">$1</a>');
142
- for (const line of lines) {
143
- const block = /^ (\d+) $/.exec(line);
144
- if (block) { closeList(); out.push(codeBlocks[Number(block[1])]); continue; }
145
- const h = /^(#{1,4})\s+(.*)$/.exec(line);
146
- if (h) { closeList(); const lv = h[1].length; out.push(`<h${lv + 1} style="margin:12px 0 6px">${inline(h[2])}</h${lv + 1}>`); continue; }
147
- if (/^\s*[-*]\s+/.test(line)) {
148
- if (!listOpen) { out.push('<ul style="margin:4px 0;padding-left:22px">'); listOpen = true; }
149
- out.push(`<li>${inline(line.replace(/^\s*[-*]\s+/, ''))}</li>`);
150
- continue;
151
- }
152
- if (/^\s*\d+\.\s+/.test(line)) {
153
- if (!listOpen) { out.push('<ul style="margin:4px 0;padding-left:22px">'); listOpen = true; }
154
- out.push(`<li>${inline(line.replace(/^\s*\d+\.\s+/, ''))}</li>`);
155
- continue;
156
- }
157
- closeList();
158
- if (/^---+\s*$/.test(line)) { out.push('<hr style="border:none;border-top:1px solid var(--border);margin:10px 0">'); continue; }
159
- if (/^>\s?/.test(line)) { out.push(`<blockquote style="margin:6px 0;padding:4px 12px;border-left:3px solid var(--border);color:var(--muted,#667)">${inline(line.replace(/^>\s?/, ''))}</blockquote>`); continue; }
160
- if (line.trim() === '') { out.push(''); continue; }
161
- out.push(`<p style="margin:4px 0">${inline(line)}</p>`);
162
- }
163
- closeList();
164
- return out.join('\n');
165
- }
137
+ // ---------- skill 文件浏览:目录树 + 原文/视图/双栏三种查看模式 ----------
138
+ // markdown 渲染走公共模块 ../markdown.js(与会话页 AI 气泡同源,含表格支持)
166
139
 
167
140
  /** 浏览抽屉:左侧文件树(目录懒加载展开),右侧查看区(markdown 文件支持 原文/视图/两者 三模式) */
168
141
  function browseSkill(s) {
169
142
  openDrawer({
170
143
  title: `${s.name} · ${sourceTag(s)}`,
171
144
  bodyHtml: `
172
- <div class="hint" style="margin:0 0 10px"><code class="desc">${esc(s.path)}</code></div>
173
- <div style="display:flex;gap:14px;align-items:flex-start">
174
- <div id="sbTree" style="width:250px;flex:none;border:1px solid var(--border);border-radius:8px;padding:8px;max-height:62vh;overflow:auto;font-size:13px">
175
- <div class="desc">目录加载中…</div>
176
- </div>
177
- <div style="flex:1;min-width:0">
145
+ <div class="hint" style="margin:0 0 10px"><code>${esc(s.path)}</code></div>
146
+ <div class="dw-layout">
147
+ <nav class="dw-nav" id="sbTree" style="width:250px;border:1px solid var(--border);border-radius:10px;padding:8px;max-height:62vh;overflow:auto;font-size:13px">
148
+ <span class="hint">目录加载中…</span>
149
+ </nav>
150
+ <div class="dw-pane">
178
151
  <div id="sbModes" style="display:none;gap:8px;margin-bottom:8px" class="subtabs">
179
152
  <button data-mode="both" class="active">两者都看</button>
180
153
  <button data-mode="raw">只看原文</button>
181
154
  <button data-mode="view">只看视图</button>
182
155
  </div>
183
- <div id="sbFileName" class="desc" style="margin-bottom:6px">点击左侧文件查看内容</div>
184
- <div id="sbView" style="display:none;border:1px solid var(--border);border-radius:8px;padding:12px;max-height:56vh;overflow:auto;font-size:13px;line-height:1.6"></div>
185
- <textarea id="sbRaw" readonly style="display:none;width:100%;min-height:56vh;font-family:ui-monospace,Consolas,monospace;font-size:12px"></textarea>
156
+ <div id="sbFileName" class="mini-note" style="margin-bottom:6px">点击左侧文件查看内容</div>
157
+ <div id="sbView" style="display:none;border:1px solid var(--border);border-radius:10px;padding:12px;max-height:56vh;overflow:auto;font-size:13px;line-height:1.6;background:var(--sunk)"></div>
158
+ <textarea id="sbRaw" readonly style="display:none;width:100%;min-height:56vh;font-family:var(--mono);font-size:12px"></textarea>
186
159
  </div>
187
160
  </div>`,
188
- footHtml: `<button class="btn" data-act="close">关闭</button>`,
161
+ footHtml: `<span class="sp"></span><button class="btn" data-act="close">关闭</button>`,
189
162
  onMount({ body, foot }) {
190
163
  foot.querySelector('[data-act="close"]').onclick = () => closeDrawer();
191
164
  const tree = body.querySelector('#sbTree');
@@ -207,7 +180,7 @@ function browseSkill(s) {
207
180
  fileName.textContent = `加载中:${fp}`;
208
181
  try {
209
182
  const r = await api('GET', `/api/skills/raw?path=${encodeURIComponent(fp)}`);
210
- fileName.innerHTML = `<strong>${esc(r.name)}</strong> <span class="desc">· ${(r.size / 1024).toFixed(1)}KB</span>`;
183
+ fileName.innerHTML = `<b>${esc(r.name)}</b> <span class="dim">· ${(r.size / 1024).toFixed(1)}KB</span>`;
211
184
  rawPane.value = r.content;
212
185
  if (isMd(r.name)) {
213
186
  viewPane.innerHTML = renderMarkdown(r.content);
@@ -225,25 +198,25 @@ function browseSkill(s) {
225
198
  }
226
199
  };
227
200
  const renderDir = async (dir, container) => {
228
- container.innerHTML = '<div class="desc" style="padding:2px 6px">加载中…</div>';
201
+ container.innerHTML = '<div class="hint" style="padding:2px 6px">加载中…</div>';
229
202
  let files;
230
203
  try {
231
204
  const r = await api('GET', `/api/skills/files?path=${encodeURIComponent(dir)}`);
232
205
  files = r.files || [];
233
206
  } catch (e) {
234
- container.innerHTML = `<div class="desc" style="padding:2px 6px;color:var(--err-tx)">${esc(e.message)}</div>`;
207
+ container.innerHTML = `<div class="hint" style="padding:2px 6px;color:var(--err-tx)">${esc(e.message)}</div>`;
235
208
  return;
236
209
  }
237
210
  container.innerHTML = '';
238
211
  if (files.length === 0) {
239
- container.innerHTML = '<div class="desc" style="padding:2px 6px">(空目录)</div>';
212
+ container.innerHTML = '<div class="hint" style="padding:2px 6px">(空目录)</div>';
240
213
  return;
241
214
  }
242
215
  for (const f of files) {
243
- const row = document.createElement('div');
244
- row.style.cssText = 'padding:3px 6px;border-radius:6px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
245
- row.onmouseenter = () => { row.style.background = 'var(--off-bg)'; };
246
- row.onmouseleave = () => { row.style.background = ''; };
216
+ const row = document.createElement('button');
217
+ row.style.cssText = 'display:block;width:100%;text-align:left;border:0;background:none;font:inherit;cursor:pointer;padding:4px 8px;border-radius:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
218
+ row.onmouseenter = () => { row.style.background = 'var(--pri-w)'; };
219
+ row.onmouseleave = () => { if (!row.dataset.active) row.style.background = ''; };
247
220
  const childPath = `${dir.replace(/[\\/]+$/, '')}/${f.name}`;
248
221
  if (f.isDir) {
249
222
  row.textContent = `▸ 📁 ${f.name}`;
@@ -267,7 +240,7 @@ function browseSkill(s) {
267
240
  row.onclick = () => {
268
241
  tree.querySelectorAll('[data-active]').forEach((x) => { delete x.dataset.active; x.style.background = ''; x.style.fontWeight = ''; });
269
242
  row.dataset.active = '1';
270
- row.style.background = 'var(--primary-weak)';
243
+ row.style.background = 'var(--pri-w)';
271
244
  row.style.fontWeight = '600';
272
245
  void openFile(childPath);
273
246
  };
@@ -283,13 +256,14 @@ function browseSkill(s) {
283
256
  function addSkill() {
284
257
  openDrawer({
285
258
  title: '新建 skill(用户级)',
259
+ narrow: true,
286
260
  bodyHtml: `
287
- <label>名字(字母/数字/下划线/连字符,1-64 位)</label>
261
+ <label class="fld">名字(字母/数字/下划线/连字符,1-64 位)</label>
288
262
  <input type="text" id="nskName" placeholder="my-skill">
289
- <label>说明(frontmatter description)</label>
263
+ <label class="fld">说明(frontmatter description)</label>
290
264
  <input type="text" id="nskDesc" placeholder="这个 skill 做什么、什么时候用">
291
- <div class="hint" style="margin-top:10px">创建后生成目录与 SKILL.md 模板,请到目录中补充正文与脚本。</div>`,
292
- footHtml: `<button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">创建</button>`,
265
+ <div class="mini-note" style="margin-top:10px">创建后生成目录与 SKILL.md 模板,请到目录中补充正文与脚本。</div>`,
266
+ footHtml: `<span class="sp"></span><button class="btn" data-act="cancel">取消</button><button class="btn primary" data-act="save">创建</button>`,
293
267
  onMount({ foot }) {
294
268
  foot.querySelector('[data-act="cancel"]').onclick = () => closeDrawer();
295
269
  foot.querySelector('[data-act="save"]').onclick = async () => {
@@ -337,6 +311,8 @@ function bindZipImport() {
337
311
  export const page = {
338
312
  id: 'skills',
339
313
  title: 'Skills',
314
+ desc: '四个来源的技能聚合:本机 / bridge / 项目 / 插件(只读)',
315
+ legend: false,
340
316
  icon: '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>',
341
317
  render: renderSkills,
342
318
  };