magic-agent 1.0.0 → 1.0.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 (41) hide show
  1. package/CLIENT-CONTRACT.md +298 -0
  2. package/README.md +35 -0
  3. package/client-ui/assets/OFL-Caveat.txt +93 -0
  4. package/client-ui/assets/caveat.woff2 +0 -0
  5. package/client-ui/index.html +246 -0
  6. package/client-ui/pc/app.css +5110 -0
  7. package/client-ui/pc/assets/damiao-mascot.png +0 -0
  8. package/client-ui/pc/assets/icon.png +0 -0
  9. package/client-ui/pc/index.html +154 -0
  10. package/client-ui/pc/js/00-base.js +52 -0
  11. package/client-ui/pc/js/03-chat-message.js +880 -0
  12. package/client-ui/pc/js/04-task-list.js +430 -0
  13. package/client-ui/pc/js/05-task-list-render.js +812 -0
  14. package/client-ui/pc/js/06-ask-answer.js +204 -0
  15. package/client-ui/pc/js/07-chat-stream-render.js +493 -0
  16. package/client-ui/pc/js/08-ask-event-map.js +171 -0
  17. package/client-ui/pc/js/09-stream-apply.js +367 -0
  18. package/client-ui/pc/js/10-markdown.js +430 -0
  19. package/client-ui/pc/js/18-askbox.js +262 -0
  20. package/client-ui/pc/js/19-attachments.js +679 -0
  21. package/client-ui/pc/js/29-msg-variants.js +462 -0
  22. package/client-ui/pc/js/90-glue.js +212 -0
  23. package/client-ui/pc/js/91-workbench-stubs.js +134 -0
  24. package/client-ui/pc/js/92-composer.js +406 -0
  25. package/client-ui/pc/js/93-rail.js +362 -0
  26. package/client-ui/pc/vendor/highlight.min.js +1262 -0
  27. package/client-ui/pc/vendor/marked.min.js +6 -0
  28. package/client-ui/pc/vendor/purify.min.js +3 -0
  29. package/client-ui/shared/conversation.css +256 -0
  30. package/client-ui/shared/conversation.js +1225 -0
  31. package/client-ui/shared/transport-http.js +123 -0
  32. package/client-ui-mobile/assets/OFL-Caveat.txt +93 -0
  33. package/client-ui-mobile/assets/caveat.woff2 +0 -0
  34. package/client-ui-mobile/index.html +173 -0
  35. package/npm/bin/magic-agent.js +43 -0
  36. package/npm/dist/darwin-arm64/magic-agent +0 -0
  37. package/npm/dist/darwin-x64/magic-agent +0 -0
  38. package/npm/dist/linux-arm64/magic-agent +0 -0
  39. package/npm/dist/linux-x64/magic-agent +0 -0
  40. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  41. package/package.json +4 -1
@@ -0,0 +1,430 @@
1
+ /* ---------- 完整 Markdown 渲染器(专业栈:marked + DOMPurify + highlight.js)----------
2
+ 解析:marked(GFM)——表格对齐 / 任务列表 / 删除线 / 自动链接 / 围栏语言标签,
3
+ 远超手写正则的边界正确性(嵌套列表、松散列表、行内混排等)。
4
+ 净化:DOMPurify——文档正文多来自网页采集(不可信源),渲染后必须消毒,
5
+ 防脚本 / 事件属性注入;放行 title / target / align / class 等阅读必需属性。
6
+ 高亮:highlight.js——代码块按 ```lang 自动着色(hljs.highlightElement)。
7
+ ⚠️ 它是**按需加载**的(2026-09-19):129KB,只有真渲染出代码块时才注入,
8
+ 见下方 loadHljs(),不要在 index.html 里同步引它。
9
+ 保留契约:仍返回 { html, anchors }。anchors 为标题索引(level/text/id),
10
+ 供右侧目录(.md-toc)点击定位;标题锚点 id 稳定(h-1 / h-2 …),
11
+ 悬停显示「#」。与既有的 .md-body 版式 / .doc-reader 结构完全对接。 */
12
+
13
+ const MD_STACK_READY = (typeof marked !== "undefined") && (typeof DOMPurify !== "undefined");
14
+ let _mdAnchors = [], _mdHSeq = 0;
15
+
16
+ /** 取纯文本(去行内标记),用于目录标题 */
17
+ function mdPlainText(s){
18
+ return String(s||"")
19
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g,"$1")
20
+ .replace(/\[([^\]]*)\]\([^)]*\)/g,"$1")
21
+ .replace(/`([^`]*)`/g,"$1")
22
+ .replace(/\*\*([^*]+)\*\*/g,"$1").replace(/__([^_]+)__/g,"$1")
23
+ .replace(/\*([^*]+)\*/g,"$1").replace(/_([^_]+)_/g,"$1")
24
+ .replace(/~~([^~]+)~~/g,"$1")
25
+ .replace(/\s+/g," ").trim();
26
+ }
27
+
28
+ /* 一次性配置 marked 渲染器:
29
+ - heading → 稳定锚点 id + 收集目录 + 「#」锚点(.h-anc,::before 出 # 号)
30
+ - code → 保留 code-lang 角标 + hljs 类,随后由 highlightElement 上色
31
+ - listitem → GFM 任务项改写成既有 .task-li 结构;普通项回退默认渲染
32
+ - link → 外链加 target=_blank rel=noopener,防钓鱼落页 */
33
+ if(MD_STACK_READY){
34
+ marked.use({
35
+ gfm:true,
36
+ breaks:false,
37
+ renderer:{
38
+ heading(tok){
39
+ const inner = this.parser.parseInline(tok.tokens||[]);
40
+ const id = "h-" + (++_mdHSeq);
41
+ _mdAnchors.push({ level:tok.depth, text:mdPlainText(tok.text), id });
42
+ return `<h${tok.depth} id="${id}">${inner}<a class="h-anc" href="#${id}" title="定位此标题" aria-hidden="true"></a></h${tok.depth}>\n`;
43
+ },
44
+ code(tok){
45
+ const lang = String(tok.lang||"").split(/\s+/)[0];
46
+ const cls = "hljs" + (lang ? " language-" + lang : "");
47
+ // 转义代码正文:既保证 <tag> 原样显示,也避免消毒阶段误删
48
+ const body = String(tok.text||"")
49
+ .replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
50
+ return `<pre>${lang?`<span class="code-lang">${lang}</span>`:""}<code class="${cls}">${body}</code></pre>\n`;
51
+ },
52
+ listitem(tok){
53
+ if(tok.task){
54
+ const body = this.parser.parse(tok.tokens||[], !!tok.loose);
55
+ return `<li class="task-li${tok.checked?" done":""}"><span class="bx">✓</span><span class="tx">${body}</span></li>\n`;
56
+ }
57
+ return false; // 回退 marked 默认(保留嵌套 <ul>/<ol>)
58
+ },
59
+ link(tok){
60
+ const text = this.parser.parseInline(tok.tokens||[]);
61
+ const href = String(tok.href||"");
62
+ const ti = tok.title ? ` title="${tok.title}"` : "";
63
+ const ext = /^https?:/i.test(href);
64
+ return `<a href="${href}"${ext?' target="_blank" rel="noopener"':""}${ti}>${text}</a>`;
65
+ },
66
+ },
67
+ });
68
+ }
69
+
70
+ /** 手写兜底:vendor 未加载时仍能阅读(仅线性块级,极端降级) */
71
+ function mdFallback(src){
72
+ const esc = (s)=>String(s??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
73
+ let html = "", inCode = false, buf = [];
74
+ const flushPara = ()=>{ if(buf.length){ html += "<p>" + esc(buf.join(" ")) + "</p>"; buf = []; } };
75
+ String(src||"").split(/\r?\n/).forEach((ln)=>{
76
+ if(/^\s*```/.test(ln)){ flushPara(); if(inCode){ html += `<pre><code>${esc(buf.join("\n"))}</code></pre>`; buf=[]; } inCode=!inCode; return; }
77
+ if(inCode){ buf.push(ln); return; }
78
+ const hm = /^(#{1,6})\s+(.*)$/.exec(ln);
79
+ if(hm){ flushPara(); const lv=hm[1].length, id="h-"+(++_mdHSeq);
80
+ _mdAnchors.push({level:lv,text:mdPlainText(hm[2]),id});
81
+ html += `<h${lv} id="${id}">${esc(hm[2])}</h${lv}>`; return; }
82
+ if(/^\s*$/.test(ln)){ flushPara(); return; }
83
+ buf.push(ln.trim());
84
+ });
85
+ flushPara();
86
+ return html;
87
+ }
88
+
89
+ /** 把「独占一行的图片路径」变成 markdown 图片,让对话里**直接看到图**(2026-09-19)。
90
+ *
91
+ * 为什么需要:引擎产出/引用的截图通常写成**一行绝对路径**(或本项目入库时的 `[图片] <路径>`),
92
+ * 不是 markdown —— 不转的话对话里只有一行字,看不到图;而飞书那边的完成回执也会把同一批图
93
+ * 上传成内联图(见 feishu-bridge.cjs::extractImageRefs),两边口径对齐。
94
+ *
95
+ * ⚠️ 只认**独占一行**的绝对路径 + 常见图片扩展名:
96
+ * · 不碰行内路径(正文里提到 `src/js/xxx.png` 不该变成图);
97
+ * · 路径里不含空格 / 引号 / 括号(含了就不转,免得把 markdown 语法拼坏);
98
+ * · alt 也填路径 —— 图加载不出来时还能看见它指向哪。 */
99
+ function mdLocalizeImagePaths(src){
100
+ const EX = "png|jpe?g|gif|webp|bmp";
101
+ return String(src||"")
102
+ // 本项目入库时给飞书图片的写法:`[图片] /abs/x.png`
103
+ .replace(new RegExp("^[ \\t]*\\[图片\\][ \\t]*(/\\S+\\.(?:" + EX + "))[ \\t]*$", "gim"),
104
+ (_a, p) => `![${p}](${p})`)
105
+ // 裸路径独占一行
106
+ .replace(new RegExp("^[ \\t]*(/[^\\s`'\"()<>]+\\.(?:" + EX + "))[ \\t]*$", "gim"),
107
+ (_a, p) => `![${p}](${p})`);
108
+ }
109
+
110
+ /** 渲染 Markdown → { html, anchors } */
111
+ function mdToHtml(src){
112
+ _mdAnchors = []; _mdHSeq = 0;
113
+ src = mdLocalizeImagePaths(src);
114
+ if(!MD_STACK_READY){ return { html: mdFallback(src), anchors: _mdAnchors }; }
115
+ let raw;
116
+ try{
117
+ raw = marked.parse(String(src||""));
118
+ }catch(e){
119
+ console.warn("[md] marked 解析失败,回退简易渲染:", e && e.message);
120
+ return { html: mdFallback(src), anchors: _mdAnchors };
121
+ }
122
+ // 消毒:放行阅读必需的属性 / 标签;禁 script、事件属性、javascript: 协议
123
+ const html = DOMPurify.sanitize(raw, {
124
+ ADD_ATTR: ["target", "rel", "align", "class", "id", "title", "disabled", "checked", "type"],
125
+ FORBID_TAGS: ["script", "style", "iframe", "form", "input"],
126
+ FORBID_ATTR: ["onerror", "onload", "onclick", "style"],
127
+ });
128
+ return { html, anchors: _mdAnchors };
129
+ }
130
+
131
+ /** 只取 HTML(兼容旧调用) */
132
+ function mdToHtmlOnly(src){ return mdToHtml(src).html; }
133
+
134
+ /* ---------- highlight.js 按需加载(2026-09-19)----------
135
+ 129KB,比 marked + purify + floating-ui 加起来还大 —— 但只有真渲染出代码块时才用得到。
136
+ 原来它在 index.html 里同步加载,每次启动都白解析一遍。现在首次需要时才注入;
137
+ 注入完成前代码块以纯文本呈现(下面的 typeof hljs 分支本来就兜住了,不影响功能)。
138
+ 流式渲染每闭合一段都会调一次 highlightIn → 加载期间可能登记多个根节点,
139
+ 用 Set 去重,加载完统一补扫一遍。 */
140
+ let _hljsLoading = false;
141
+ const _hljsPending = new Set();
142
+ function loadHljs(){
143
+ if(typeof hljs !== "undefined" || _hljsLoading) return;
144
+ _hljsLoading = true;
145
+ const s = document.createElement("script");
146
+ s.src = "vendor/highlight.min.js";
147
+ s.onload = () => {
148
+ _hljsLoading = false;
149
+ _hljsPending.forEach((r)=>{ try{ highlightIn(r); }catch(e){ /* 节点可能已卸载,忽略 */ } });
150
+ _hljsPending.clear();
151
+ };
152
+ s.onerror = () => { _hljsLoading = false; }; // 加载失败就一直纯文本,不影响功能
153
+ document.head.appendChild(s);
154
+ }
155
+
156
+ /** 对已插入 DOM 的代码块执行语法高亮;hljs 还没就绪就先登记,加载完补扫。
157
+ * ⚠️ 先查有没有代码块再决定要不要惊动 129KB —— 渲染普通文档(无 ``` 块)时
158
+ * highlightIn 照样会被调一次,不能让它把 highlight.js 白拉下来。 */
159
+ function highlightIn(root){
160
+ if(!root) return;
161
+ const blocks = root.querySelectorAll("pre code");
162
+ if(!blocks.length) return;
163
+ if(typeof hljs === "undefined"){ _hljsPending.add(root); loadHljs(); return; }
164
+ blocks.forEach((el)=>{
165
+ if(el.dataset.hl) return;
166
+ try{ hljs.highlightElement(el); el.dataset.hl = "1"; }catch(e){ /* 未知语言静默跳过 */ }
167
+ });
168
+ }
169
+
170
+ /* ---------- 右栏多标签系统 ----------
171
+ tab 形态:{ id, kind:'browser'|'doc', docId?, el, paneEl }
172
+ - 浏览器工具标签常驻、不可关闭
173
+ - 每份产品文档直接就是一个标签页(不经任何索引层、无弹窗),内容即完整 Markdown 阅读器 */
174
+ let dockTabs = []; // 全部标签(含常驻浏览器)
175
+ let activeDockTab = null; // 当前激活 tab id
176
+ let docTabSeq = 0;
177
+
178
+ const DOC_ICON = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5h8l4 4v13a1 1 0 01-1 1H6a1 1 0 01-1-1v-16a1 1 0 011-1z"/><path d="M14 3.5v4h4M8.5 12h7M8.5 15h7M8.5 18h4"/></svg>`;
179
+
180
+ function findTab(id){ return dockTabs.find(t=>t.id===id); }
181
+
182
+ /** 初始化常驻「浏览器工具」标签 */
183
+ function initDockTabs(){
184
+ const browser = { id:'tab-browser', kind:'browser', el:$("#tab-browser"), paneEl:null };
185
+ const features = { id:'tab-req-features', kind:'features', el:$("#tab-req-features"), paneEl:$("#dock-pane-tab-req-features") };
186
+ /* 审查截图(2026-09-20):与「文档阅读」同族 —— 默认收起、点卡片才亮
187
+ (见 reqShotsShowTab 与 app.css 的 #tab-req-shots[hidden])。 */
188
+ const shots = { id:'tab-req-shots', kind:'shots', el:$("#tab-req-shots"), paneEl:$("#dock-pane-tab-req-shots") };
189
+ dockTabs = [browser, features, shots];
190
+ browser.el.addEventListener("click", ()=>activateDockTab('tab-browser'));
191
+ features.el.addEventListener("click", ()=>{
192
+ activateDockTab('tab-req-features');
193
+ // 2026-09-18 二次调整:列表在主视图,本面板只是「单篇阅读器」。
194
+ // 直接点本 tab 时,若主视图已经在累计档且有缓存的当前文档 → 不动;否则给个引导。
195
+ if(!$("#rf-ws-name") || $("#rf-ws-name").textContent.trim() === "—"){
196
+ reqFeatPaneHint();
197
+ }
198
+ });
199
+ /* 直接点这枚 tab = 「回到我上次看的那条需求的截图」(面板内容由 reqShotsOpen 填)。
200
+ 内容为空时给一句引导,而不是一片空白 —— 与 features tab 的 reqFeatPaneHint 同一考虑。 */
201
+ shots.el.addEventListener("click", ()=>{
202
+ activateDockTab('tab-req-shots');
203
+ if(!$("#rs-body") || !$("#rs-body").childElementCount) reqShotsPaneHint();
204
+ });
205
+ activateDockTab('tab-browser');
206
+ }
207
+
208
+ /** 亮出「文档阅读」标签(2026-09-20 用户:「有侧边栏不需要默认显示文档阅读 点击文档才显示」)。
209
+ * 旧行为:这枚 tab 与「浏览器」并列**常驻**,打开侧栏就先看见一个空阅读器的入口 ——
210
+ * 用户不想默认看到它。现在它由 index.html 的 hidden 属性直出收起,
211
+ * 首次真去读某篇文档时才(由 reqFeatOpenInDock / rdocOvOpenInDock)调这里摘掉 hidden。
212
+ * 幂等:重复调用无副作用;亮出来之后就常驻(不做「读完又收回去」——
213
+ * 关闭动作与「点了没反应」是最容易让人以为功能坏了的两种交互)。
214
+ * ⚠️ 改的是 el.hidden(属性),不是样式;.dock-tab-btn 的 display 会盖掉 UA 的
215
+ * [hidden]{display:none},所以 app.css 里配了 #tab-req-features[hidden]。 */
216
+ function reqFeatShowTab(){
217
+ const tab = findTab('tab-req-features') || null;
218
+ const el = (tab && tab.el) || $("#tab-req-features");
219
+ if(el) el.hidden = false;
220
+ }
221
+
222
+ /** 亮出「审查截图」标签(2026-09-20)—— 与 reqFeatShowTab 同一套口径(hidden 属性,
223
+ * 不是样式;幂等;亮出来就常驻)。调用方只有 reqShotsOpen(点卡片那枚「截图 N」)。 */
224
+ function reqShotsShowTab(){
225
+ const tab = findTab('tab-req-shots') || null;
226
+ const el = (tab && tab.el) || $("#tab-req-shots");
227
+ if(el) el.hidden = false;
228
+ }
229
+
230
+ /** 打开某份产品文档 → 直接新建/聚焦其标签页(完整 Markdown 阅读器) */
231
+ function openDocTab(docId){
232
+ const exist = dockTabs.find(t=>t.kind==='doc' && t.docId===docId);
233
+ if(exist){ activateDockTab(exist.id); return exist; }
234
+ const tab = { id:'doc-' + (++docTabSeq), kind:'doc', docId };
235
+ const pill = document.createElement('button');
236
+ pill.className = 'dock-tab-btn';
237
+ pill.dataset.tab = tab.id;
238
+ pill.setAttribute('role','tab');
239
+ pill.innerHTML = `<span class="ti">${DOC_ICON}</span><span class="tt"></span><span class="tx" title="关闭">✕</span>`;
240
+ pill.addEventListener('click', (e)=>{
241
+ if(e.target.classList.contains('tx')){ closeDockTab(tab.id); return; }
242
+ activateDockTab(tab.id);
243
+ });
244
+ const pane = document.createElement('div');
245
+ pane.className = 'panel-body br-dock-body dock-pane';
246
+ pane.id = 'dock-pane-' + tab.id;
247
+ pane.setAttribute('role','tabpanel');
248
+ tab.el = pill; tab.paneEl = pane;
249
+ dockTabs.push(tab);
250
+ $("#dock-tab-scroll").appendChild(pill);
251
+ $("#dock-panes").appendChild(pane);
252
+ renderDocPane(tab);
253
+ activateDockTab(tab.id);
254
+ return tab;
255
+ }
256
+
257
+ function closeDockTab(id){
258
+ const i = dockTabs.findIndex(t=>t.id===id);
259
+ if(i < 0) return;
260
+ const tab = dockTabs[i];
261
+ if(tab.kind === 'browser') return; // 浏览器常驻不可关
262
+ tab.el.remove(); tab.paneEl.remove();
263
+ dockTabs.splice(i, 1);
264
+ if(activeDockTab === id){
265
+ const next = dockTabs[i] || dockTabs[i-1] || dockTabs[0];
266
+ activateDockTab(next ? next.id : null);
267
+ }
268
+ }
269
+
270
+ function activateDockTab(id){
271
+ activeDockTab = id;
272
+ const tab = findTab(id);
273
+ // 浏览器标签的 pane 复用静态 #dock-pane-browser;其余用各自 paneEl
274
+ dockTabs.forEach(t=>{
275
+ const on = t.id === id;
276
+ t.el.classList.toggle('active', on);
277
+ t.el.setAttribute('aria-selected', on);
278
+ const pane = t.paneEl || (t.kind==='browser' ? $("#dock-pane-browser") : null);
279
+ if(pane) pane.classList.toggle('active', on);
280
+ });
281
+ if(tab && tab.kind === 'doc') renderDocPane(tab);
282
+ if(tab && tab.kind === 'browser') syncWebviewViewport();
283
+ /* 需要「展开右栏才看得见」的两枚标签(文档阅读 · 2026-09-18 / 审查截图 · 2026-09-20):
284
+ 切到它们时把右栏打开 —— 收合态下整个 dock 列宽归 0、面板被 overflow 裁掉,看不见。
285
+ (浏览器 tab 不用管:右栏本就是被它打开的。) */
286
+ if(tab && (tab.kind === 'features' || tab.kind === 'shots') && !isDockOpen()) openBrowserDock();
287
+ }
288
+
289
+ /* ---------- 文档元信息解析 ----------
290
+ 优先取明细(含 markdown / detail / url);数据源未连接时明细为空,
291
+ 回落到列表行 prdData[ id, title, module, version, status, date ],
292
+ 保证标签页标题与工具条始终显示可读标题,而不是裸编号。
293
+ 正文再回落一层到内置种子正文 PRD_SEED_MD(00-seed-docs.js):
294
+ 预览态没有后端明细,没有这层兜底就只剩「暂无 Markdown 正文」空壳
295
+ (2026-09-20 用户:「在产品列表上要能看到文档」)。 */
296
+ function docMeta(id){
297
+ const d = prdDetailIndex[id] || null;
298
+ // 取元信息用**搜索前**的全量行(prdRowsAll):搜索把某行筛掉后,打开着的阅读器不该跟着掉标题
299
+ const row = prdRowsAll.find(r => r[0] === id) || prdData.find(r => r[0] === id) || null;
300
+ if(!d && !row) return { id };
301
+ const seed = (typeof PRD_SEED_MD !== "undefined") ? PRD_SEED_MD[id] : null;
302
+ return {
303
+ id,
304
+ title: (d && d.title) || (row && row[1]) || id,
305
+ module: (d && d.module) || (row && row[2]) || "",
306
+ version: (d && d.version) || (row && row[3]) || "",
307
+ status: (d && d.status) || (row && row[4]) || "",
308
+ date: (d && d.date) || (row && row[5]) || "",
309
+ url: d && d.url,
310
+ markdown: (d && d.markdown) || seed || undefined,
311
+ detail: d && d.detail,
312
+ workspace: d && d.workspace, // 供用例继承归属(工作台按 workspace 过滤)
313
+ };
314
+ }
315
+
316
+ /* ---------- 文档标签页 = 完整 Markdown 阅读器 ----------
317
+ 渲染内容:
318
+ .md-bar 工具条(文档编号 / 版本 / 状态 / 目录 / 导出 / 来源 / 置顶)
319
+ .md-toc 目录(由正文标题自动生成,点击平滑定位)
320
+ .md-body 正文(完整 Markdown 版式)
321
+ 无 Markdown 正文时给出可操作的空态说明(引导去浏览器标签沉淀)。 */
322
+ function renderDocPane(tab){
323
+ const d = docMeta(tab.docId);
324
+ const label = d.title || tab.docId;
325
+ tab.el.querySelector('.tt').textContent = label;
326
+ tab.el.title = `${tab.docId} · ${label}`;
327
+
328
+ const stText = prdStateText[d.status] || d.status || "—";
329
+ const bar = `<div class="md-bar">
330
+ <span class="no">${tab.docId}</span>
331
+ <span class="st ${d.status||""}">${stText}</span>
332
+ <span class="sp"></span>
333
+ <button class="mb" data-md="toc" title="显示 / 隐藏目录">目录</button>
334
+ <button class="mb" data-md="cases" title="按本文档正文推导测试用例初稿,写入测试用例库(按验证方式分界面类 / 脚本类)">生成用例</button>
335
+ <button class="mb" data-md="export" title="导出为 .md 文件">导出</button>
336
+ ${d.url ? `<a class="mb" href="${String(d.url).replace(/"/g,"&quot;")}" target="_blank" rel="noopener" title="打开来源页面">来源 ↗</a>` : ""}
337
+ <button class="mb" data-md="top" title="回到顶部">↑ 顶部</button>
338
+ </div>`;
339
+
340
+ let bodyHtml, anchors = [];
341
+ if(d.markdown){
342
+ const r = mdToHtml(d.markdown);
343
+ bodyHtml = r.html; anchors = r.anchors;
344
+ }else if(d.detail){
345
+ bodyHtml = `<div class="doc-reader-empty">此文档仅有<b>结构快照</b>,还没有 Markdown 正文。</div>`;
346
+ }else{
347
+ bodyHtml = `<div class="doc-reader-empty">这份演示种子文档暂无 Markdown 正文。</div>
348
+ <div class="doc-meta-note">产品模块:<code>${d.module||"—"}</code> · 版本:<code>${d.version||"—"}</code> · 更新:<code>${d.date||"—"}</code></div>`;
349
+ }
350
+
351
+ const toc = anchors.length
352
+ ? `<div class="md-toc" id="toc-${tab.id}"><div class="tc-h">目 录</div>${
353
+ anchors.map(a=>`<a class="lv${Math.min(4,a.level)}" data-h="${a.id}" title="${String(a.text).replace(/"/g,"&quot;")}">${String(a.text).replace(/</g,"&lt;")}</a>`).join("")
354
+ }</div>`
355
+ : `<div class="md-toc" id="toc-${tab.id}"><div class="tc-h">目 录</div><div class="tc-e">(正文无标题,无目录)</div></div>`;
356
+
357
+ tab.paneEl.innerHTML = `<div class="doc-reader">
358
+ <div class="md-prog" id="prog-${tab.id}"></div>
359
+ ${bar}
360
+ ${toc}
361
+ <div class="doc-pane-body md-body" id="mdb-${tab.id}">${bodyHtml}</div>
362
+ </div>`;
363
+
364
+ const root = tab.paneEl.querySelector(".doc-reader");
365
+ const body = tab.paneEl.querySelector(".doc-pane-body");
366
+ const tocEl = tab.paneEl.querySelector(".md-toc");
367
+ const prog = tab.paneEl.querySelector(".md-prog");
368
+ tab.bodyEl = body;
369
+
370
+ // 代码块语法高亮(highlight.js)——DOM 就位后再上色
371
+ highlightIn(body);
372
+
373
+ // 阅读进度条
374
+ body.addEventListener("scroll", ()=>{
375
+ const max = body.scrollHeight - body.clientHeight;
376
+ prog.style.width = (max > 0 ? Math.min(100, (body.scrollTop / max) * 100) : 0) + "%";
377
+ });
378
+
379
+ // 工具条动作
380
+ bar && root.addEventListener("click", (e)=>{
381
+ const b = e.target.closest("[data-md]"); if(!b) return;
382
+ const act = b.dataset.md;
383
+ if(act === "toc"){
384
+ tocEl.classList.toggle("show");
385
+ b.classList.toggle("on", tocEl.classList.contains("show"));
386
+ }else if(act === "cases"){
387
+ // 文档 → 用例(2026-09-20):推导逻辑与幂等口径都在 11-cases-reports.js::genCasesFromDoc
388
+ genCasesFromDoc(tab.docId);
389
+ }else if(act === "export"){
390
+ exportDocMarkdown(tab.docId, d);
391
+ }else if(act === "top"){
392
+ body.scrollTo({ top:0, behavior:"smooth" });
393
+ }
394
+ });
395
+
396
+ // 目录点击 → 平滑定位
397
+ tocEl.addEventListener("click", (e)=>{
398
+ const a = e.target.closest("a[data-h]"); if(!a) return;
399
+ const h = body.querySelector("#" + a.dataset.h);
400
+ if(h) h.scrollIntoView({ behavior:"smooth", block:"start" });
401
+ tocEl.querySelectorAll("a").forEach(x=>x.style.color="");
402
+ a.style.color = "var(--cinnabar-hi)";
403
+ });
404
+ // 正文内锚点(# 号)→ 平滑定位
405
+ body.addEventListener("click", (e)=>{
406
+ const a = e.target.closest("a.h-anc"); if(!a) return;
407
+ e.preventDefault();
408
+ const h = body.querySelector(a.getAttribute("href"));
409
+ if(h) h.scrollIntoView({ behavior:"smooth", block:"start" });
410
+ });
411
+ }
412
+
413
+ /** 导出当前文档为 .md 文件 */
414
+ function exportDocMarkdown(docId, d){
415
+ const src = (d && d.markdown) || "";
416
+ if(!src){ toast("此文档暂无 Markdown 正文可导出"); return; }
417
+ const blob = new Blob([src], { type:"text/markdown;charset=utf-8" });
418
+ const a = document.createElement("a");
419
+ a.href = URL.createObjectURL(blob);
420
+ a.download = `${docId}${d && d.title ? "-" + String(d.title).replace(/[\\/:*?"<>|\s]+/g,"_").slice(0,40) : ""}.md`;
421
+ document.body.appendChild(a); a.click();
422
+ setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 0);
423
+ toast("已导出 " + docId + " 为 Markdown 文件");
424
+ }
425
+
426
+ /* 数据源变化后刷新所有已打开的文档标签页 */
427
+ function refreshDocTabs(){
428
+ dockTabs.filter(t=>t.kind==='doc').forEach(renderDocPane);
429
+ }
430
+