Compare commits

..

4 Commits

Author SHA1 Message Date
caoqianming 3ca37f7041 doc(PROGRESS): 05-19 dev SPA 文件预览弹框
加 05-19 条目 + 文件清单更新 dev.html 行数 + 加 web/static/vendor/ 一行。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:25:30 +08:00
caoqianming 15bbadf6d6 ui(dev SPA): 文件点击弹框预览(image/pdf/text/md/docx/xlsx, 其它 fallback)
原行为 click → 走 downloadFile 直接落盘,不能在线看。
现 click → openFilePreview(rel) 打开 #file-preview-modal(90vw × 90vh),
按扩展名分派渲染器:
- image (jpg/png/gif/webp/bmp/svg/ico) → <img> blob URL
- pdf → <iframe> blob URL + application/pdf mime
- text 类 (~30 种 txt/log/json/yaml/code) → <pre> textContent (2MB cap)
- md → 复用 renderMd(marked + DOMPurify + hljs)
- docx → 懒加载 jszip + docx-preview → renderAsync 到 DOM
- xlsx/xls → 懒加载 SheetJS → 多 sheet tab + sheet_to_html
- 其它 (pptx/doc/ppt/...) → fallback "暂不支持在线预览" + 下载按钮

机制:fetch /v1/files/download 取 blob 绕 auth header 限制(后端不动);
懒加载 vendor 脚本(_scriptCache 防重入,失败 fallback);
_trackBlobUrl + _flushBlobUrls 弹框关时统一 revoke 防泄漏;
Esc / 点 backdrop / × 三种关闭路径;
auth 401 → logout;binary 50MB / text 2MB 上限兜底防 OOM。

pptx 整个社区 JS 库都不成熟(动画/复杂版式失真),先 fallback,
真有需求再上服务端 LibreOffice 转 PDF 统一处理。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:25:20 +08:00
caoqianming e3215e023a vendor(office preview): jszip 3.10.1 + docx-preview 0.3.6 + xlsx 0.18.5
dev SPA 文件预览所需的三方 JS 库,入 git 锁版本:
- jszip 3.10.1 (MIT) — docx-preview 依赖
- docx-preview 0.3.6 (Apache-2.0) — docx → DOM 渲染
- xlsx 0.18.5 (Apache-2.0, SheetJS 社区版) — xlsx → HTML table

总共 ~1MB,前端按需懒加载(仅 office 文件首次打开才拉)。
项目无 npm 工具链,直接 vendor 比 fetch 脚本简单。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:25:07 +08:00
caoqianming d6fc004367 skill(proposal): mermaid 管线 + render_docx 图片插入 + 图题自动编号
新增 render_diagrams.py 把 ```mermaid``` 块预渲染到 figures/fig_<sha1>.png
(优先本地 mmdc, 回退 mermaid.ink 公网 API, 都失败留 WARN 不阻塞);
render_docx.py 加 ![](path) 识别 + mermaid 缓存查找, 缺缓存自动 ASCII fallback,
图题"图 N <caption>"全局自增, 替换原模板里的 [图 2-2 ...] 裸占位写法。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:37:16 +08:00
10 changed files with 1008 additions and 16 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(system prompt skill 机制改"可选辅助",通用任务不必硬套 skill) 最后更新:2026-05-18(proposal skill 加 mermaid 管线:`render_diagrams.py` 预渲染 + `render_docx.py` 图片插入 + 图题自动编号;`key_rd.md` 占位 `[图 N-N ...]` 换成真 mermaid 例子)
--- ---
@ -21,6 +21,8 @@
## 已完成关键能力 ## 已完成关键能力
- **05-19 / dev SPA 文件预览弹框**:用户提:"web 右侧点击文件可以弹框加载预览,带下载按钮"。原行为是 click → 直接 `downloadFile`(走 `/v1/files/download`)落盘,不能在线看。**方案**:复用现有 `/v1/files/download`(blob URL 绕过 auth header 限制,不动后端),前端按扩展名分派渲染器。新加 `#file-preview-modal`(90vw × 90vh,max 1200px),头部 filename + 下载 + × 关,body 按 cat 切不同布局。**分派**:① image(jpg/png/gif/webp/bmp/svg/ico)→ `<img>` blob URL;② pdf → `<iframe>` blob URL 强制 `application/pdf` mime,浏览器内置 PDF viewer;③ text 类(txt/log/json/yaml/csv/py/js/ts/go 等近 30 种)→ `<pre>` textContent,2MB 上限超限 fallback;④ md / markdown → 复用现有 `renderMd`(marked + DOMPurify + hljs);⑤ docx → 懒加载 `/static/vendor/jszip.min.js` + `docx-preview.min.js``window.docx.renderAsync(blob, host)` 渲染到 DOM,带表格 / 图片 / 样式还原;⑥ xlsx / xls → 懒加载 `xlsx.full.min.js`(SheetJS 社区版),多 sheet 出 tab 切换,`sheet_to_html` 直接出表格;⑦ 其它(pptx / doc / ppt / 未识别)→ fallback "暂不支持在线预览,请下载查看" + 大号下载按钮。**机制**:`loadScript()` 懒加载只在首次访问 office 文件才拉 1MB vendor;`_trackBlobUrl` + `_flushBlobUrls` 弹框关时统一 revoke 防漏;Esc / 点 backdrop 关弹框;auth 401 → logout;binary 50MB 上限兜底防 OOM。**库选型**:① docx 用 docx-preview(Apache-2.0,2k star,2025-07 还在发版,UMD/CDN OK,只依赖 JSZip,DOM 渲染,fidelity 显著优于 mammoth.js)② xlsx 用 SheetJS 社区版(Apache-2.0,长期维护,单文件 UMD,`sheet_to_html` 直出)③ pptx 整个社区 JS 库都不成熟(pptx-preview / PptxViewJS 都对动画 / 复杂版式失真),先 fallback,真有需求再上服务端 LibreOffice 转 PDF 统一处理。**新增 `web/static/vendor/`**(入 git,项目无 npm 工具链就是直 vendor;锁版本好处:复现部署一致 + 安全审计直观;~1MB 可接受):jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5。**改 `web/static/dev.html`**(+~240 行 JS + ~60 行 CSS):file row .name onclick 从 downloadFile 切到 openFilePreview;现有 downloadFile 保留供 fallback / 头部下载按钮直接复用。**没动**:后端 app.py(blob URL 路径足够;弹框关闭统一 revoke 避免 URL 泄漏);DESIGN(纯 UI 增强非架构变化);RUN(无 CLI / env / 文件布局变化)。**文档**:**只动 PROGRESS + 文件清单加 vendor/ 目录**(按 CLAUDE.md 三文档边界)。
- **05-18 / proposal skill 流程图/结构图管线**:用户反馈"申报 skill 关于流程图、结构图等的生成有些问题,包括渲染到 docx 里"。诊断结果:① `render_docx.py` 整个脚本没有 `add_picture` / 没引 `Inches`,所谓"画流程图"只能走 `add_code_block` 的 ASCII 字符画(`新宋体` + Consolas + box drawing),Word 里 CJK 与 `─ │ ┌ ┐` 不真等宽,中文标签一长就错位,评审看到字符画扣印象分;② 模板里写满 `[图 2-2 关键技术关系架构]` 裸占位,但 SKILL.md 零提及 mermaid / graphviz / matplotlib,模型只能瞎编 ASCII;③ 评审红线"图编号连续无遗漏"(`references/review_redlines.md:96`)无机制保证。**方案**:Mermaid 管线 + matplotlib 兜底 + 图编号自增。**新增 `scripts/render_diagrams.py`**(143 行):扫 sections/*.md 的 ```mermaid``` 块 → 算 sha1 前 10 位作稳定 id → 落到 `<task_dir>/figures/fig_<hash>.png`;两阶 backend:① 本地 `mmdc`(npm i -g @mermaid-js/mermaid-cli;最高质量、离线)② `mermaid.ink` 公网 API(`https://mermaid.ink/img/<url-safe-b64>`,urlsafe_b64encode rstrip '=';不装东西、要联网);两个都失败留 WARN 退出 0(不阻塞流水线);`%% caption: <图题>` 行注释抽题文,mermaid 本身当注释跳过、render_docx 当题用;不改动 .md 文件(源是真相);幂等(png 存在跳过)。**改 `render_docx.py`**(+~70 行):① 加 `![caption](path)` 单行识别 → `add_picture(width=Cm(15))` 居中 + 五号宋体居中图题段落"图 N <caption>",N 通过 `ctx` 字典(`{sections_dir, figures_dir, fig_no}`)在 `render_md_block` 调用链里递增,relative 路径以 .md 所在目录为锚;图片源缺失 → 留 `[图片缺失: <src>]` 占位段防 silent miss、文档不崩;② 围栏 lang == "mermaid" 特判:算同源 sha1 查 `<sections_dir>/../figures/fig_<hash>.png`,命中走插图 + 题(同样自增编号、复用 `extract_mermaid_caption`),未命中**继续走原 `add_code_block` ASCII fallback 路径**(mmdc 没装也能交差,只是不漂亮);③ A4 减页边距得正文宽 16cm,图宽 cap `Cm(15)` 留 1cm 安全垫;④ `add_picture` 失败 try/except 不让整 doc 崩,改占位文字。**改 SKILL.md**:`资源` 段加 `render_diagrams.py` 行;阶段三命令链插入 `render_diagrams.py` 前置(可选,无 mermaid 块直接跳过);新增"插图"段(类型选择表 / mermaid `%% caption` 约定 + 完整 flowchart 例子 / matplotlib `figsize=(10,4)` `dpi=150` 中文字体 SimHei 配色规范 / 不要手写"图 2-2"章节-序号);反模式加 3 条(ASCII 字符画当真图 / 手写图编号 / 裸 `[图 N-N ...]` 占位)。**改 `templates/key_rd.md`**:① §04_content (一) 主要研究内容里 `[图 2-2 关键技术关系架构]` 占位换成完整 ```mermaid flowchart LR``` 块(关键问题 Q1/Q2 → 技术 T1/T2/T3 → 平台,带 `%% caption:`);② §04 (三) 技术路线加"项目总体技术路线" mermaid `flowchart TB` 例子(需求→设计→突破→集成→示范 5 阶段 + 双向反馈虚线);③ §09_schedule 甘特图改"两种画法 A. mermaid `gantt` B. matplotlib `barh`"并给完整 mermaid gantt 示例。**没动**:`major_project.md` / `nsfc_joint_fund.md` 只是"配图"提示,不是裸占位,通过 SKILL.md 横向覆盖;`scripts/word_count.py` / `quality_check.py`(图不计字数,质量检查暂不涉及图占位)。**Smoke 4 case 全绿**(`scripts/_smoke_proposal_diagrams.py`,留作回归):① cached mermaid + direct image + ASCII fallback 混排(`figures: 2` 报告对、`inline_shapes == 2`、缓存命中走"图 1/图 2"、缺缓存 mermaid 走 ASCII 源保留 + 不申请图号"图 3");② 无插图回归(`figures: 0` + table 完好);③ `render_diagrams.py` API 调用(`find_mermaid_blocks` 抽 2 块 / `extract_caption` 命中/未命中 / 预填 cache png 全走 `cache` backend 不走网络);④ 图片源缺失走占位文字,后续段落不丢。**Windows GBK 子进程坑**:smoke 跑 subprocess 拿不到 UTF-8 stdout(`UnicodeDecodeError 0xd6`),给子进程 env 加 `PYTHONIOENCODING=utf-8` 修;同 memory 里 emoji 编码教训同源。**文档**:**只动 PROGRESS**(skill 内部能力增强 ≠ 架构变化,不动 DESIGN;skill CLI 不是 zcbot 对外行为,不动 RUN —— 按 CLAUDE.md 三文档边界)。**净增量**:~213 行代码新增,5 行文档示例改写,sections/*.md 不动(源永远是 mermaid 真相)。**留给真用户的体验**:模型不需要再瞎编 ASCII,直接写 mermaid 块就行;mmdc/网络都没的极端环境下 docx 仍能产(ASCII 退化,文字不丢);图编号永远连续不重不漏(自动),手工占位的旧坑彻底关上。
- **05-18 / `POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:用户反复抠"文件夹改名 / 删除时怎么不破 DB 一致性"。架构最终落点:**`/v1/files/*` 是唯一的目录树 mutation 命名空间,DB-FS 一致性作为服务端不变量内化**(放弃曾经的"files API 永不进 DB"惯例 —— 那是当初没考虑顶层目录时形成的偶然,把它升格成铁律反而导出双命名空间代价);`GET /v1/folders` 保留,但定位为"项目聚合视图"(只读,带 n_tasks/last_used,新建任务 datalist 用),不做 mutation。**判定**:`target.parent.resolve() == root.resolve() and target.is_dir()` ⇒ 顶层目录(就是 task 的 working_dir)。**新 `POST /v1/files/rename`**:校验 `validate_task_name(new_name)` / target 存在 / 不能等于 user_root / sibling 不能已存在;**顶层目录**走 DB-aware 分支:`session_scope()` 事务内 `SELECT task_id, run_status WHERE working_dir=old_db FOR UPDATE` 锁所有关联 task,任一 `run_status in ('running','cancelling')` → 409;`check_no_subtask(new_db, exclude_task_ids=tids)` 防改名后与其它 task 形成嵌套(exclude 平移过去的自己);`UPDATE tasks SET working_dir=new_db` → `os.rename(old_fs, new_fs)` —— FS 失败 raise → session_scope 回滚 DB UPDATE。**非顶层**(子目录 / 文件)纯 FS rename,不动 DB。**事务顺序考量**:DB UPDATE 在 FS rename 之前(都在事务未提交期间),FS 失败可回滚 UPDATE;唯一不一致窗口是"FS 改完 + commit 失败"(PG 单事务 commit 极少失败,接受)。**`POST /v1/files/delete` 收紧**:同样的顶层目录判定,若顶层目录有任意 task 引用 → 409 "请先 DELETE 关联 task 再删目录",避免悬空指针。**`check_no_subtask` 扩 `exclude_task_ids` 参数**:`core/storage/utils.py` 加可选 Iterable[UUID],循环里跳过这些 task_id;rename 场景刚需(否则被改名 task 与自己未来的 new_db 误判为嵌套);其它 caller 默认 None 行为不变。**dev SPA 同步**(`web/static/dev.html`):file row 加 `改名` 按钮,prompt 拿新名 → POST `/v1/files/rename`;rename 后:① 当前 `state.filesPath` 若在被改名子树内做前缀替换继续停留(`rel === filesPath` 或 `filesPath.startsWith(rel + "/")` → 替换前缀为 res.new);② `loadFolderSuggestions()` 刷 datalist;③ `res.tasks_updated > 0``loadTaskList()` + `selectTask(state.taskId)`(task 卡片 / chat 头里展示的 working_dir 末段也跟着变)。delete confirm 文案补一句"顶层目录且仍被 task 引用需先删 task";删除完成也 `loadFolderSuggestions()`。**Smoke 5 case 全绿**(in-process TestClient + PG):① 子目录 rename 纯 FS / tasks_updated=0;② 顶层目录 rename 同步 UPDATE / tasks_updated=N / FS 改完 + DB working_dir 跟着变;③ 顶层目录 rename 时有 running task → 409;④ 删顶层有 task 引用 → 409;⑤ rename 目标已存在 → 409。**Smoke 文件**(`scripts/smoke_files_rename.py`)跑完未删(留作回归用)。**没动**:`GET /v1/folders` 接口、`DELETE /v1/tasks/{id}` 行为(仍删 DB 行不动 FS,与新 delete 配对刚好覆盖"销毁项目"全链路);`/v1/files/{list,upload,download}` 路由签名;skill / chat / cancel 等其它路由。**架构反思**:此前一版我先提的双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 if path is top-level 切分支被自己视为"代码异味" —— 实际是反了,这种分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。这条工程教训记 §7.9。 - **05-18 / `POST /v1/files/rename` + 顶层目录 delete 加 task 引用闸**:用户反复抠"文件夹改名 / 删除时怎么不破 DB 一致性"。架构最终落点:**`/v1/files/*` 是唯一的目录树 mutation 命名空间,DB-FS 一致性作为服务端不变量内化**(放弃曾经的"files API 永不进 DB"惯例 —— 那是当初没考虑顶层目录时形成的偶然,把它升格成铁律反而导出双命名空间代价);`GET /v1/folders` 保留,但定位为"项目聚合视图"(只读,带 n_tasks/last_used,新建任务 datalist 用),不做 mutation。**判定**:`target.parent.resolve() == root.resolve() and target.is_dir()` ⇒ 顶层目录(就是 task 的 working_dir)。**新 `POST /v1/files/rename`**:校验 `validate_task_name(new_name)` / target 存在 / 不能等于 user_root / sibling 不能已存在;**顶层目录**走 DB-aware 分支:`session_scope()` 事务内 `SELECT task_id, run_status WHERE working_dir=old_db FOR UPDATE` 锁所有关联 task,任一 `run_status in ('running','cancelling')` → 409;`check_no_subtask(new_db, exclude_task_ids=tids)` 防改名后与其它 task 形成嵌套(exclude 平移过去的自己);`UPDATE tasks SET working_dir=new_db` → `os.rename(old_fs, new_fs)` —— FS 失败 raise → session_scope 回滚 DB UPDATE。**非顶层**(子目录 / 文件)纯 FS rename,不动 DB。**事务顺序考量**:DB UPDATE 在 FS rename 之前(都在事务未提交期间),FS 失败可回滚 UPDATE;唯一不一致窗口是"FS 改完 + commit 失败"(PG 单事务 commit 极少失败,接受)。**`POST /v1/files/delete` 收紧**:同样的顶层目录判定,若顶层目录有任意 task 引用 → 409 "请先 DELETE 关联 task 再删目录",避免悬空指针。**`check_no_subtask` 扩 `exclude_task_ids` 参数**:`core/storage/utils.py` 加可选 Iterable[UUID],循环里跳过这些 task_id;rename 场景刚需(否则被改名 task 与自己未来的 new_db 误判为嵌套);其它 caller 默认 None 行为不变。**dev SPA 同步**(`web/static/dev.html`):file row 加 `改名` 按钮,prompt 拿新名 → POST `/v1/files/rename`;rename 后:① 当前 `state.filesPath` 若在被改名子树内做前缀替换继续停留(`rel === filesPath` 或 `filesPath.startsWith(rel + "/")` → 替换前缀为 res.new);② `loadFolderSuggestions()` 刷 datalist;③ `res.tasks_updated > 0``loadTaskList()` + `selectTask(state.taskId)`(task 卡片 / chat 头里展示的 working_dir 末段也跟着变)。delete confirm 文案补一句"顶层目录且仍被 task 引用需先删 task";删除完成也 `loadFolderSuggestions()`。**Smoke 5 case 全绿**(in-process TestClient + PG):① 子目录 rename 纯 FS / tasks_updated=0;② 顶层目录 rename 同步 UPDATE / tasks_updated=N / FS 改完 + DB working_dir 跟着变;③ 顶层目录 rename 时有 running task → 409;④ 删顶层有 task 引用 → 409;⑤ rename 目标已存在 → 409。**Smoke 文件**(`scripts/smoke_files_rename.py`)跑完未删(留作回归用)。**没动**:`GET /v1/folders` 接口、`DELETE /v1/tasks/{id}` 行为(仍删 DB 行不动 FS,与新 delete 配对刚好覆盖"销毁项目"全链路);`/v1/files/{list,upload,download}` 路由签名;skill / chat / cancel 等其它路由。**架构反思**:此前一版我先提的双命名空间 `/v1/folders/rename` vs `/v1/files/rename`,内部 if path is top-level 切分支被自己视为"代码异味" —— 实际是反了,这种分支**从数据状态派生**(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。这条工程教训记 §7.9。
- **05-18 / system prompt skill 机制改"可选辅助"**:接 `GET /v1/skills` + 下拉选择落地后,task 创建时 skill 字段允许留空成为常态。原 `prompts/system/general_v1.md` 第 14 行 `"永远 load 一下。skill 数有限,加载成本很低"` 在新形态下变得过激 —— 简单问答 / 通用编码 / 文件操作不该被强行匹配到 coding 等 skill。改为"Skill 是**可选辅助**"+ 明确列出"简单问答、读代码、改 bug、文件操作这类通用任务,直接用通用工具就够,不必为每个任务硬套 skill"。一旦决定要用仍要求 load 完整指引(原则不变)。**未动**:skill discovery block 内容(name + description 注入仍按 registry 顺序)、`load_skill` 工具协议、SKILL.md 内容。**tradeoff**:边缘场景(用户提"整理大纲"可能落 proposal 也可能不用)agent 现在会偏向不 load,可能漏掉好的模板;但比原来"什么都套 coding"的噪音更可接受。 - **05-18 / system prompt skill 机制改"可选辅助"**:接 `GET /v1/skills` + 下拉选择落地后,task 创建时 skill 字段允许留空成为常态。原 `prompts/system/general_v1.md` 第 14 行 `"永远 load 一下。skill 数有限,加载成本很低"` 在新形态下变得过激 —— 简单问答 / 通用编码 / 文件操作不该被强行匹配到 coding 等 skill。改为"Skill 是**可选辅助**"+ 明确列出"简单问答、读代码、改 bug、文件操作这类通用任务,直接用通用工具就够,不必为每个任务硬套 skill"。一旦决定要用仍要求 load 完整指引(原则不变)。**未动**:skill discovery block 内容(name + description 注入仍按 registry 顺序)、`load_skill` 工具协议、SKILL.md 内容。**tradeoff**:边缘场景(用户提"整理大纲"可能落 proposal 也可能不用)agent 现在会偏向不 load,可能漏掉好的模板;但比原来"什么都套 coding"的噪音更可接受。
- **05-18 / `GET /v1/skills` + dev SPA skill 字段改下拉**:原 `nt-skill` 是自由输入(用户得记住 `coding / ppt / proposal` 拼写),用户反馈"加 skill 接口给前端选"。后端 `web/app.py` lifespan 启动时 `SkillRegistry(ROOT / cfg["skills_dir"])` 扫一次挂到 `app.state.skill_registry`(文件系统静态,运行中不变);新增 `GET /v1/skills``require_user` JWT 鉴权,返 `{skills:[{name,description}]}` 按 name 升序(registry 已 sorted)。dev SPA(`web/static/dev.html`):`<input id=nt-skill>` 换 `<select>`,首项固定 `(默认 · 不限定)` 空值;`hd-new` 打开 modal 时 `loadSkillOptions()``loadFolderSuggestions()` 并发(`Promise.all`),首次拉到的列表缓存到 `state.skills`,失败时静默退化为只剩"默认"项不阻塞。option 文案 `name — description`,`title` 也带 description 鼠标悬停看长文。Smoke:`TestClient` 起 app → `/v1/auth/login` 拿 token → `/v1/skills` 返 3 项(coding/ppt/proposal)+ 描述;无 token 401。**未动**:`_build_system_prompt` 注入的 skill discovery block(name + description)和这里渲染的下拉项是同源 registry,改一处不影响另一处;`POST /v1/tasks` body 不校验 `skill ∈ registry`(留空 / 任意串都允许,与 schema 一致 — 真要拦在 UI 层早就拦了)。 - **05-18 / `GET /v1/skills` + dev SPA skill 字段改下拉**:原 `nt-skill` 是自由输入(用户得记住 `coding / ppt / proposal` 拼写),用户反馈"加 skill 接口给前端选"。后端 `web/app.py` lifespan 启动时 `SkillRegistry(ROOT / cfg["skills_dir"])` 扫一次挂到 `app.state.skill_registry`(文件系统静态,运行中不变);新增 `GET /v1/skills``require_user` JWT 鉴权,返 `{skills:[{name,description}]}` 按 name 升序(registry 已 sorted)。dev SPA(`web/static/dev.html`):`<input id=nt-skill>` 换 `<select>`,首项固定 `(默认 · 不限定)` 空值;`hd-new` 打开 modal 时 `loadSkillOptions()``loadFolderSuggestions()` 并发(`Promise.all`),首次拉到的列表缓存到 `state.skills`,失败时静默退化为只剩"默认"项不阻塞。option 文案 `name — description`,`title` 也带 description 鼠标悬停看长文。Smoke:`TestClient` 起 app → `/v1/auth/login` 拿 token → `/v1/skills` 返 3 项(coding/ppt/proposal)+ 描述;无 token 401。**未动**:`_build_system_prompt` 注入的 skill discovery block(name + description)和这里渲染的下拉项是同源 registry,改一处不影响另一处;`POST /v1/tasks` body 不校验 `skill ∈ registry`(留空 / 任意串都允许,与 schema 一致 — 真要拦在 UI 层早就拦了)。
@ -116,9 +118,10 @@ web/app.py 889 ← /v1/ JSON API + user_id 隔离 + run lock +
web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换 web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py 121 ← in-process pub/sub + cancel signal,全 task_id 索引(0004) web/broker.py 121 ← in-process pub/sub + cancel signal,全 task_id 索引(0004)
web/sinks.py 21 ← WebEventSink 绑 task_id(0004) web/sinks.py 21 ← WebEventSink 绑 task_id(0004)
web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge web/static/dev.html 1516 ← D' dev SPA + 文件预览弹框(image/pdf/text/md/docx/xlsx)
web/static/vendor/ ~1 MB ← jszip 3.10.1 / docx-preview 0.3.6 / xlsx 0.18.5(office 预览 vendor)
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1133 静态)— 05-18 入口归位净减 ~400 行 REPL/CLI Python 合计 ~3400 行(+ dev.html 1516 静态 + vendor 1MB)— 05-18 入口归位净减 ~400 行 REPL/CLI
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。

View File

@ -0,0 +1,244 @@
"""Smoke: render_docx.py 图片 + mermaid 缓存路径。
构造一个临时 sections/, figures/ 结构, render_docx, 验证:
- mermaid hash figures/ 有对应 png 走插图路径
- mermaid hash figures/ png ASCII fallback (不崩, 文本保留)
- ![](path) 直接图片 走插图路径
- 图编号自增
- inline_shapes = 命中插图的次数
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RENDER_DOCX = ROOT / "skills" / "proposal" / "scripts" / "render_docx.py"
PYTHON = ROOT / ".venv" / "Scripts" / "python.exe"
if not PYTHON.exists():
PYTHON = Path(sys.executable) # CI / unix fallback
def _run_render(sections: Path, out: Path) -> subprocess.CompletedProcess:
"""跑 render_docx.py, 子进程强制 utf-8 输出 (Windows GBK stdout 兜底)。"""
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
return subprocess.run(
[str(PYTHON), str(RENDER_DOCX), str(sections), "--fund-type", "key_rd", "-o", str(out)],
capture_output=True, text=True, encoding="utf-8", env=env,
)
def mermaid_hash(source: str) -> str:
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def make_tiny_png(out: Path) -> None:
"""用 matplotlib 生成一张 1-bar 的真 png(确保 python-docx 能 add_picture)。"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(4, 2), dpi=100)
ax.bar(["A", "B", "C"], [1, 3, 2], color="#c00000")
ax.set_title("smoke")
fig.savefig(str(out), bbox_inches="tight")
plt.close(fig)
def case(name: str):
def deco(fn):
def wrapped(*a, **kw):
print(f"[case] {name} ...", end=" ")
try:
fn(*a, **kw)
print("OK")
except Exception as e:
print(f"FAIL: {e}")
raise
return wrapped
return deco
@case("happy: cached mermaid + direct image + ASCII fallback")
def smoke_happy(tmp: Path) -> None:
sections = tmp / "sections"
figures = tmp / "figures"
sections.mkdir(parents=True)
figures.mkdir(parents=True)
# mermaid block #1 — 命中缓存
m1 = (
"%% caption: 关键问题与技术映射\n"
"flowchart LR\n"
" A --> B\n"
)
h1 = mermaid_hash(m1)
png1 = figures / f"fig_{h1}.png"
make_tiny_png(png1)
# mermaid block #2 — 缺缓存, 走 ASCII fallback
m2 = (
"%% caption: 缺缓存的图\n"
"flowchart TB\n"
" X --> Y\n"
)
# direct image — 自己造 png
direct_png = figures / "direct.png"
make_tiny_png(direct_png)
# 写 .md
(sections / "01_test.md").write_text(
f"""# 测试章节
这是一段散文**加粗** *斜体* 应当正确解析
```mermaid
{m1.rstrip()}
```
正文继续下面是一张缺缓存的 mermaid:
```mermaid
{m2.rstrip()}
```
再看一张直接引用的图:
![已有 PNG: 一柱形示例](../figures/direct.png)
末尾段
""",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}\nSTDOUT: {proc.stdout}"
assert out.is_file() and out.stat().st_size > 1000, f"output docx not produced: {out}"
# 报告里应明确 figures: 2 (mermaid#1 + direct)
assert "figures: 2" in proc.stdout, f"expected 'figures: 2' in stdout, got:\n{proc.stdout}"
# 打开 docx 验内容
from docx import Document
doc = Document(str(out))
# 真插图数(inline_shapes 计 add_picture)= 2
assert len(doc.inline_shapes) == 2, f"expected 2 inline shapes, got {len(doc.inline_shapes)}"
all_text = "\n".join(p.text for p in doc.paragraphs)
# 命中缓存的 mermaid 走图 + 题
assert "图 1" in all_text and "关键问题与技术映射" in all_text, "missing fig 1 caption"
# direct 图也有题
assert "图 2" in all_text and "已有 PNG" in all_text, "missing fig 2 caption"
# 缺缓存的 mermaid 走 ASCII fallback,源码保留
assert "flowchart TB" in all_text and "X --> Y" in all_text, "ASCII fallback didn't preserve mermaid source"
# 缺缓存的不应该有 "图 3"(没插入图就不计数)
assert "图 3" not in all_text, "ghost figure number for missed cache"
@case("happy: no diagrams at all (regression: existing flows unchanged)")
def smoke_no_diagrams(tmp: Path) -> None:
sections = tmp / "sections"
sections.mkdir(parents=True)
(sections / "01.md").write_text(
"# 标题\n\n散文段落。**加粗**。\n\n| 列 1 | 列 2 |\n|---|---|\n| a | b |\n",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}"
assert "figures: 0" in proc.stdout, f"expected 'figures: 0' in stdout, got:\n{proc.stdout}"
from docx import Document
doc = Document(str(out))
assert len(doc.inline_shapes) == 0
assert len(doc.tables) == 1 # markdown table
@case("render_diagrams: scans + hashes mermaid blocks, cache hit short-circuit")
def smoke_render_diagrams(tmp: Path) -> None:
"""不依赖 mmdc / mermaid.ink:预先放 cache png, 期望 render_diagrams 全部 'cache' 命中。"""
sys.path.insert(0, str(ROOT / "skills" / "proposal" / "scripts"))
try:
import render_diagrams as rd
finally:
sys.path.pop(0)
sections = tmp / "sections"
figures = tmp / "figures"
sections.mkdir(parents=True)
figures.mkdir(parents=True)
m1 = "%% caption: 图甲\nflowchart LR\n A --> B\n"
m2 = "flowchart TB\n X --> Y\n Y --> Z\n"
(sections / "a.md").write_text(
f"# A\n\n```mermaid\n{m1.rstrip()}\n```\n\n散文。\n\n```mermaid\n{m2.rstrip()}\n```\n",
encoding="utf-8",
)
(sections / "b.md").write_text("# B\n\n仅文本,无 mermaid。\n", encoding="utf-8")
# 预填两个 png 让 render_one 走 cache 分支(避开网络)
for src in (m1, m2):
h = rd.mermaid_hash(src)
(figures / f"fig_{h}.png").write_bytes(b"\x89PNG\r\n\x1a\nfake")
# API 调用(不走 subprocess, 避免 stdout 编码再次干扰)
rc = rd.render_sections(sections)
assert rc == 0, f"render_sections rc={rc}"
# caption 抽取
assert rd.extract_caption(m1) == "图甲"
assert rd.extract_caption(m2) is None
# find_mermaid_blocks 行为
text = (sections / "a.md").read_text(encoding="utf-8")
blocks = rd.find_mermaid_blocks(text)
assert len(blocks) == 2, f"expected 2 blocks, got {len(blocks)}"
@case("missing image src → 占位文字, 不崩")
def smoke_missing_image(tmp: Path) -> None:
sections = tmp / "sections"
sections.mkdir(parents=True)
(sections / "01.md").write_text(
"# 测试\n\n![不存在](figures/ghost.png)\n\n后面一段。\n",
encoding="utf-8",
)
out = tmp / "test.docx"
proc = _run_render(sections, out)
assert proc.returncode == 0, f"render_docx exited {proc.returncode}\nSTDERR: {proc.stderr}"
from docx import Document
doc = Document(str(out))
assert len(doc.inline_shapes) == 0
all_text = "\n".join(p.text for p in doc.paragraphs)
assert "图片缺失" in all_text, "missing-image placeholder not rendered"
assert "后面一段" in all_text, "後续段落丢了"
def main() -> None:
if not RENDER_DOCX.exists():
print(f"[ERR] render_docx.py not found: {RENDER_DOCX}", file=sys.stderr)
sys.exit(2)
with tempfile.TemporaryDirectory(prefix="zcbot_smoke_") as td:
base = Path(td)
smoke_happy(base / "happy")
smoke_no_diagrams(base / "nodia")
smoke_render_diagrams(base / "diag")
smoke_missing_image(base / "ghost")
print()
print("[OK] all smoke cases passed")
if __name__ == "__main__":
main()

View File

@ -17,7 +17,8 @@ description: 撰写中国科研项目申报书 / 课题任务书 (国家重点
- `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表 - `<skill_dir>/references/budget_rules.md` —— 间接费用台阶 + B1-B4 表
- `<skill_dir>/templates/spec_lock.md` —— 阶段一八条对齐的固定字段模板 (复制到 `<task_dir>/spec_lock.md`) - `<skill_dir>/templates/spec_lock.md` —— 阶段一八条对齐的固定字段模板 (复制到 `<task_dir>/spec_lock.md`)
- `<skill_dir>/templates/{key_rd,major_project,nsfc_joint_fund}.md` —— **有完整章节模板**的 3 类基金;其它 4 类 (`nsfc_general` / `nsfc_youth` / `provincial` / `enterprise`) 复用 `nsfc_joint_fund``key_rd` 骨架,差异看 `fund_types.md` § 4-6 - `<skill_dir>/templates/{key_rd,major_project,nsfc_joint_fund}.md` —— **有完整章节模板**的 3 类基金;其它 4 类 (`nsfc_general` / `nsfc_youth` / `provincial` / `enterprise`) 复用 `nsfc_joint_fund``key_rd` 骨架,差异看 `fund_types.md` § 4-6
- `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录、解析 `**bold**`/`*italic*`/`` `code` ``、列表分行 - `<skill_dir>/scripts/render_docx.py` —— md→docx,自动加目录、解析 `**bold**`/`*italic*`/`` `code` ``、列表分行、`![](path)` 居中插图 + 图题自动编号、识别 mermaid 块查 `figures/` 缓存
- `<skill_dir>/scripts/render_diagrams.py` —— sections/*.md 里的 ```mermaid``` 块预渲染成 `<task_dir>/figures/fig_<hash>.png`(优先用本地 `mmdc`,回退 `mermaid.ink` 公网 API,失败留警告)
- `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算 - `<skill_dir>/scripts/word_count.py` —— 章节字数 vs 预算
- `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空话术 / 占位符未替换 / 指南覆盖度 (--spec 选项) - `<skill_dir>/scripts/quality_check.py` —— 结构完整性 / 假大空话术 / 占位符未替换 / 指南覆盖度 (--spec 选项)
@ -73,11 +74,14 @@ markitdown https://example.com/x -o <task_dir>/source/policy.md
```bash ```bash
python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --fund-type key_rd python <skill_dir>/scripts/word_count.py <task_dir>/sections/ --fund-type key_rd
python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --fund-type key_rd --spec <task_dir>/spec_lock.md python <skill_dir>/scripts/quality_check.py <task_dir>/sections/ --fund-type key_rd --spec <task_dir>/spec_lock.md
python <skill_dir>/scripts/render_diagrams.py <task_dir>/sections/ # 预渲染 mermaid → figures/*.png (有插图的 task 才跑)
python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key_rd -o <task_dir>/<topic>.docx
``` ```
`--spec` 让质量检查交叉对照 spec_lock,提示哪些指南考核指标在 sections 里没出现。不通过的项,回头 edit 对应章节。 `--spec` 让质量检查交叉对照 spec_lock,提示哪些指南考核指标在 sections 里没出现。不通过的项,回头 edit 对应章节。
`render_diagrams.py` 是**可选**前置 —— 章节里没 mermaid 块就跳过。即使跑了但 mmdc 没装 + 联不上 mermaid.ink,`render_docx.py` 会把 mermaid 源文本以 ASCII 等宽兜底显示(评审能看,但不漂亮)。**只要 mermaid 块就能直接交给 `render_docx.py`,figures/ 没有就走 ASCII fallback,不会崩。**
## 工作目录 ## 工作目录
`<task_dir>` = system prompt 给的**绝对路径**(`…/workspace/tasks/<task_id>/`)。**所有产物都写到 task_dir 下**,不要写到 cwd / `skills/` / repo 根。 `<task_dir>` = system prompt 给的**绝对路径**(`…/workspace/tasks/<task_id>/`)。**所有产物都写到 task_dir 下**,不要写到 cwd / `skills/` / repo 根。
@ -102,6 +106,56 @@ python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key
边界 (最容易混): 边界 (最容易混):
- 立项依据回答 **WHY** (背景+痛点) | 研究目标回答 **WHAT 终态** (量化指标) | 研究内容回答 **WHAT 任务** (技术清单) | 研究方法回答 **HOW 原理** (算法/模型) | 技术路线回答 **HOW 流程** (输入→处理→输出) - 立项依据回答 **WHY** (背景+痛点) | 研究目标回答 **WHAT 终态** (量化指标) | 研究内容回答 **WHAT 任务** (技术清单) | 研究方法回答 **HOW 原理** (算法/模型) | 技术路线回答 **HOW 流程** (输入→处理→输出)
## 插图 (流程图 / 结构图 / 技术路线图 / 甘特图)
**禁止把 ASCII 字符画 (`┌─┐│└─┘ →`) 当真图交差** —— Word 里 box-drawing 与 CJK 不真等宽,标签一长就错位,评审看到直接扣印象分。
### 类型选择
| 图类型 | 用法 | 工具 |
|---|---|---|
| 流程图 / 结构图 / 关系图 / 技术路线图 | flowchart / graph | **mermaid** (写在 md 里) |
| 时序图 (调用关系) | sequenceDiagram | **mermaid** |
| 甘特图 (进度安排) | gantt | **mermaid** 或 matplotlib `barh` (matplotlib 更适合打印) |
| 数据图表 (柱/折/饼) | 数据可视化 | **matplotlib**(配 spec_lock 主色,生成 png 后 ![]() 引用) |
| 已有图片 (截图 / 设计稿 / 厂商架构图) | 直接 ![]() | 用户提供 png/jpg |
### mermaid 块约定
直接写进章节 `.md` 里。**第一行加 `%% caption: <图题>` 注释**(题号 render_docx.py 自动加),mermaid 当注释跳过,render_docx 当题文用:
````markdown
```mermaid
%% caption: 关键技术与研究内容映射
flowchart LR
Q1[关键问题 1<br/>多源异构数据融合] --> T1[技术 1<br/>跨模态对齐]
Q1 --> T2[技术 2<br/>实体消歧]
Q2[关键问题 2<br/>实时响应] --> T3[技术 3<br/>增量索引]
T1 & T2 & T3 --> P[支撑平台]
```
````
`render_diagrams.py` 一遍 → `figures/fig_<hash>.png` 落盘 → 再跑 `render_docx.py` 会把这个块替换成"图 N 关键技术与研究内容映射"居中插图。
### matplotlib 直出 png
甘特图 / 数据图用 `run_python` 跑 matplotlib,落到 `<task_dir>/figures/<name>.png`,章节里直接:
```markdown
![甘特图: 项目 4 阶段进度安排](figures/gantt.png)
```
写 matplotlib 代码时:
- 中文字体 `plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']`,负号 `axes.unicode_minus=False`
- `figsize=(10, 4)` 起步,过宽超出页边距(`render_docx` 上限 15cm,超了自动等比缩)
- `dpi=150`(印刷质量;< 100 会糊)
- 颜色用 spec_lock 里定的主/辅色,不要 matplotlib 默认色板
- `bbox_inches='tight'` 收紧白边
### 图编号
不要手写"图 2-2"这种章节-序号,render_docx 渲染时**全局自增**(图 1 / 图 2 / ...),手写编号会和自动编号撞。要 chapter-section 编号等真有需要再扩。
## 硬规则速查 (违反即扣分) ## 硬规则速查 (违反即扣分)
- **字体**: 标题黑体四号; 正文中文宋体小四 / 英文 Times New Roman; 行距 1.5 倍 —— `render_docx.py` 已强制 - **字体**: 标题黑体四号; 正文中文宋体小四 / 英文 Times New Roman; 行距 1.5 倍 —— `render_docx.py` 已强制
@ -124,6 +178,9 @@ python <skill_dir>/scripts/render_docx.py <task_dir>/sections/ --fund-type key
- 引文写"[Smith et al., 2023]" 但其实没这篇文献 - 引文写"[Smith et al., 2023]" 但其实没这篇文献
- 不跑 `quality_check.py` 就交付 - 不跑 `quality_check.py` 就交付
- 文件名 `output.docx` / `申报书.docx` —— 按主题命名 - 文件名 `output.docx` / `申报书.docx` —— 按主题命名
- **用 ASCII 字符画 `┌─┐│└─┘ →` 当流程图/结构图** —— 中文 Word 里必错位,看插图段
- **手写图编号 (图 2-2 / 图 3-1)** —— render_docx 自动全局编号,手写会撞
- **`[图 2-2 关键技术架构]` 这种裸占位** —— 占位等于没图,要么写 mermaid 块要么 ![]() 引 png
## 输出 ## 输出

View File

@ -0,0 +1,193 @@
"""预处理 sections/*.md 里的 mermaid 块 → 缓存为 figures/fig_<hash>.png。
不改动 sections/*.md(mermaid 源是真相,留着方便迭代),只往 <sections_dir>/../figures/
下落 PNG 缓存, mermaid 源的 sha1 前缀为文件名render_docx.py 在遇到 ```mermaid
时按相同 hash figures/,有就插图 + 图题,没有就 ASCII 兜底
渲染后端选择 (按优先级):
1. 本地 mmdc (mermaid-cli) 最高质量, Node.js + npm i -g @mermaid-js/mermaid-cli
2. mermaid.ink 公网 API 不装东西,要联网
两种都没,留警告退出 0(让流水线继续),render_docx.py ASCII fallback
题注约定:mermaid 块第一行可写
%% caption: 关键技术关系架构
caption 不写也能渲染,题号自动在 render_docx.py 里递增
用法:
python render_diagrams.py <task_dir>/sections/
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
_FENCE_OPEN_RE = re.compile(r"^\s*```\s*mermaid\s*$")
_FENCE_CLOSE_RE = re.compile(r"^\s*```\s*$")
_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
MERMAID_INK_URL = "https://mermaid.ink/img/{payload}?type=png&bgColor=FFFFFF"
def mermaid_hash(source: str) -> str:
"""对 mermaid 源算 sha1, 取前 10 位作为文件名稳定 id。"""
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def extract_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def find_mermaid_blocks(md_text: str) -> list[str]:
"""返回 .md 里所有 mermaid 块的源码(不含 ``` fence)。"""
blocks: list[str] = []
lines = md_text.splitlines()
i = 0
n = len(lines)
while i < n:
if _FENCE_OPEN_RE.match(lines[i]):
buf: list[str] = []
i += 1
while i < n and not _FENCE_CLOSE_RE.match(lines[i]):
buf.append(lines[i])
i += 1
blocks.append("\n".join(buf))
i += 1
else:
i += 1
return blocks
def render_via_mmdc(source: str, out_png: Path) -> bool:
"""有 mmdc 就用 mmdc, 输出 png 到 out_png。成功 True, 失败 False。"""
mmdc = shutil.which("mmdc")
if not mmdc:
return False
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False, encoding="utf-8") as tf:
tf.write(source)
tmp_path = Path(tf.name)
try:
proc = subprocess.run(
[mmdc, "-i", str(tmp_path), "-o", str(out_png), "-b", "white", "--quiet"],
capture_output=True,
text=True,
timeout=60,
)
if proc.returncode != 0:
print(f" [mmdc] returncode={proc.returncode}: {proc.stderr.strip()[:200]}", file=sys.stderr)
return False
return out_png.exists()
except (subprocess.TimeoutExpired, OSError) as e:
print(f" [mmdc] error: {e}", file=sys.stderr)
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
def render_via_mermaid_ink(source: str, out_png: Path) -> bool:
"""通过 mermaid.ink 公网 API 渲染。要联网。成功 True, 失败 False。"""
payload = base64.urlsafe_b64encode(source.strip().encode("utf-8")).decode("ascii").rstrip("=")
url = MERMAID_INK_URL.format(payload=payload)
try:
req = urllib.request.Request(url, headers={"User-Agent": "zcbot-proposal/1.0"})
with urllib.request.urlopen(req, timeout=30) as resp:
if resp.status != 200:
print(f" [mermaid.ink] HTTP {resp.status}", file=sys.stderr)
return False
data = resp.read()
if not data or len(data) < 100:
print(f" [mermaid.ink] payload too small ({len(data)} bytes)", file=sys.stderr)
return False
out_png.write_bytes(data)
return True
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
print(f" [mermaid.ink] error: {e}", file=sys.stderr)
return False
def render_one(source: str, out_png: Path) -> str:
"""渲染一块 mermaid → png。返回使用的后端名 / "skip" / "fail""""
if out_png.exists():
return "cache"
if render_via_mmdc(source, out_png):
return "mmdc"
if render_via_mermaid_ink(source, out_png):
return "mermaid.ink"
return "fail"
def render_sections(sections_dir: Path) -> int:
if not sections_dir.is_dir():
print(f"[ERR] sections dir not found: {sections_dir}", file=sys.stderr)
return 2
figures_dir = sections_dir.parent / "figures"
figures_dir.mkdir(parents=True, exist_ok=True)
md_files = sorted(sections_dir.glob("*.md"))
if not md_files:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
return 2
total = 0
by_backend: dict[str, int] = {}
fail_blocks: list[tuple[Path, str]] = []
for md in md_files:
text = md.read_text(encoding="utf-8")
blocks = find_mermaid_blocks(text)
if not blocks:
continue
for src in blocks:
total += 1
h = mermaid_hash(src)
png = figures_dir / f"fig_{h}.png"
backend = render_one(src, png)
by_backend[backend] = by_backend.get(backend, 0) + 1
cap = extract_caption(src) or "(no caption)"
mark = {"cache": "·", "mmdc": "+", "mermaid.ink": "+", "fail": "x"}[backend]
print(f" {mark} [{backend:11s}] {md.name} :: {h} :: {cap}")
if backend == "fail":
fail_blocks.append((md, cap))
print()
print(f"[OK] processed {total} mermaid block(s) -> {figures_dir}")
for b, c in sorted(by_backend.items()):
print(f" {b}: {c}")
if fail_blocks:
print()
print(f"[WARN] {len(fail_blocks)} block(s) failed to render. render_docx.py 会走 ASCII fallback.")
print(f" 要画真图: 装 mmdc (npm i -g @mermaid-js/mermaid-cli) 或保证联网走 mermaid.ink。")
for md, cap in fail_blocks:
print(f" - {md.name} :: {cap}")
return 0
def main() -> None:
ap = argparse.ArgumentParser(description="预处理 sections/*.md 的 mermaid 块 → figures/*.png")
ap.add_argument("sections_dir", type=Path, help="sections/*.md 目录")
args = ap.parse_args()
rc = render_sections(args.sections_dir)
sys.exit(rc)
if __name__ == "__main__":
main()

View File

@ -10,12 +10,17 @@
- 内联 markdown 解析: **加粗** / *斜体* / `等宽` - 内联 markdown 解析: **加粗** / *斜体* / `等宽`
- 列表/引用文献项 ([N], 1., (1), , -, *) 各自独立成段 - 列表/引用文献项 ([N], 1., (1), , -, *) 各自独立成段
- markdown 表格自动识别, 包含分隔行 |---|---| - markdown 表格自动识别, 包含分隔行 |---|---|
- 图片 ![caption](path.png) 居中插入 + 图题自动编号 ( 1 / 2 / ...)
- ```mermaid``` : sha1 <sections_dir>/../figures/fig_<hash>.png,
命中走图 + 图题; 未命中走 ASCII fallback (等宽字体保留 box drawing)
PNG render_diagrams.py 预生成,本脚本只做查表 + 插入
用法: 用法:
python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx> python render_docx.py <sections_dir> --fund-type key_rd -o <out.docx>
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import re import re
import sys import sys
from pathlib import Path from pathlib import Path
@ -327,9 +332,70 @@ def render_table(doc: Document, table_lines: list[str]) -> None:
run.bold = True run.bold = True
# ───────────────────────── 图片 + 图题 ─────────────────────────
# ![caption](path) 或 ![](path)
_IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P<cap>[^\]]*)\]\((?P<src>[^)\s]+)\)\s*$")
# mermaid 块里第一行 %% caption: 关键技术关系架构
_MERMAID_CAPTION_RE = re.compile(r"^\s*%%\s*caption\s*:\s*(.+?)\s*$", re.IGNORECASE)
# 申报书正文最大图宽 (A4 - 左 3 - 右 2 = 16 cm,留 1 cm 边距更稳)
_MAX_IMG_WIDTH = Cm(15)
def mermaid_hash(source: str) -> str:
"""与 render_diagrams.py 同算法: sha1 前 10 位。"""
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10]
def extract_mermaid_caption(source: str) -> str | None:
for ln in source.splitlines():
m = _MERMAID_CAPTION_RE.match(ln)
if m:
return m.group(1).strip()
return None
def _resolve_image_path(src: str, base_dir: Path) -> Path | None:
"""图片相对路径以 base_dir (单个 .md 所在目录) 为锚。"""
p = Path(src)
if not p.is_absolute():
p = (base_dir / p).resolve()
return p if p.is_file() else None
def add_image(doc: Document, png_path: Path, caption: str | None, ctx: dict) -> None:
"""居中插入图片 + 图题 (五号宋体居中, 编号自增)。"""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.first_line_indent = None
p.paragraph_format.space_before = Pt(6)
p.paragraph_format.space_after = Pt(3)
run = p.add_run()
try:
run.add_picture(str(png_path), width=_MAX_IMG_WIDTH)
except Exception as e:
# 图片坏了不让整个 doc 崩, 退化成一条占位文字
run.add_text(f"[图片插入失败: {png_path.name}: {e}]")
return
ctx["fig_no"] = ctx.get("fig_no", 0) + 1
cap_p = doc.add_paragraph()
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
cap_p.paragraph_format.first_line_indent = None
cap_p.paragraph_format.space_before = Pt(0)
cap_p.paragraph_format.space_after = Pt(6)
cap_text = f"{ctx['fig_no']} {caption}" if caption else f"{ctx['fig_no']}"
cap_run = cap_p.add_run(cap_text)
cap_run.font.size = Pt(10.5) # 五号
cap_run.bold = True
_set_run_fonts(cap_run, cn_font="宋体", en_font="Times New Roman")
# ───────────────────────── 主渲染 ───────────────────────── # ───────────────────────── 主渲染 ─────────────────────────
def render_md_block(doc: Document, md_text: str) -> None: def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
lines = md_text.splitlines() lines = md_text.splitlines()
i = 0 i = 0
n = len(lines) n = len(lines)
@ -346,7 +412,21 @@ def render_md_block(doc: Document, md_text: str) -> None:
i += 1 i += 1
continue continue
# fenced 代码块 / ASCII 流程图 (```...``` 或 ~~~...~~~) # 图片 ![caption](path.png) — 单独成行
m_img = _IMAGE_LINE_RE.match(line)
if m_img:
src = m_img.group("src")
cap = m_img.group("cap").strip() or None
png = _resolve_image_path(src, ctx["sections_dir"])
if png is not None:
add_image(doc, png, cap, ctx)
else:
# 找不到图片源: 留占位段防止 silently miss
add_body_paragraph(doc, f"[图片缺失: {src}]", indent=False)
i += 1
continue
# fenced 代码块 / ASCII 流程图 / mermaid (```...``` 或 ~~~...~~~)
m_fence = _FENCE_RE.match(line) m_fence = _FENCE_RE.match(line)
if m_fence: if m_fence:
fence = m_fence.group(1) fence = m_fence.group(1)
@ -361,6 +441,15 @@ def render_md_block(doc: Document, md_text: str) -> None:
break break
code.append(lines[i]) # 不 rstrip, 保留原始空格 code.append(lines[i]) # 不 rstrip, 保留原始空格
i += 1 i += 1
# mermaid 块: 查 figures/fig_<hash>.png 缓存, 命中走图 + 题
if lang.lower() == "mermaid":
source = "\n".join(code)
png = ctx["figures_dir"] / f"fig_{mermaid_hash(source)}.png"
if png.is_file():
add_image(doc, png, extract_mermaid_caption(source), ctx)
continue
# else fall through to ASCII fallback (保留 mermaid 源文本)
add_code_block(doc, code, lang) add_code_block(doc, code, lang)
continue continue
@ -418,11 +507,18 @@ def render_sections(sections_dir: Path, out: Path, fund_type: str) -> None:
print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr) print(f"[ERR] no .md found in {sections_dir}", file=sys.stderr)
sys.exit(2) sys.exit(2)
figures_dir = sections_dir.parent / "figures"
ctx: dict = {
"sections_dir": sections_dir,
"figures_dir": figures_dir,
"fig_no": 0,
}
doc = init_doc() doc = init_doc()
add_toc(doc) add_toc(doc)
for f in md_files: for f in md_files:
text = f.read_text(encoding="utf-8") text = f.read_text(encoding="utf-8")
render_md_block(doc, text) render_md_block(doc, text, ctx)
doc.add_page_break() doc.add_page_break()
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
@ -432,7 +528,7 @@ def render_sections(sections_dir: Path, out: Path, fund_type: str) -> None:
chars = sum(len(p.text) for p in doc.paragraphs) chars = sum(len(p.text) for p in doc.paragraphs)
tbls = len(doc.tables) tbls = len(doc.tables)
print(f"[OK] rendered {len(md_files)} sections -> {out}") print(f"[OK] rendered {len(md_files)} sections -> {out}")
print(f" paragraphs: {paras} | tables: {tbls} | total chars: {chars}") print(f" paragraphs: {paras} | tables: {tbls} | figures: {ctx['fig_no']} | total chars: {chars}")
print(f" fund_type: {fund_type}") print(f" fund_type: {fund_type}")
print(f" font: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符") print(f" font: 中文宋体小四 / 英文 Times New Roman 小四 / 行距 1.5 / 首行缩进 2 字符")
print(f" 提示: 在 Word 中打开后按 F9 (或右键目录 -> 更新域) 生成实际目录。") print(f" 提示: 在 Word 中打开后按 F9 (或右键目录 -> 更新域) 生成实际目录。")

View File

@ -79,9 +79,19 @@
针对 <场景 2 痛点>,亟需解决问题二 "<问题 2>"; 针对 <场景 2 痛点>,亟需解决问题二 "<问题 2>";
项目研究内容 项目研究内容
本项目围绕 N 个关键问题, 开展 M 项关键技术研究, 对应关系如图 2-2 本项目围绕 N 个关键问题, 开展 M 项关键技术研究, 对应关系如图。
[图 2-2 关键技术关系架构] ```
```mermaid
%% caption: 关键问题与关键技术对应关系
flowchart LR
Q1[关键问题 1<br/>多源异构数据融合] --> T1[技术 1<br/>跨模态对齐]
Q1 --> T2[技术 2<br/>实体消歧]
Q2[关键问题 2<br/>实时响应] --> T3[技术 3<br/>增量索引]
T1 & T2 & T3 --> P[支撑平台]
```
```
技术 1: <技术名> 技术 1: <技术名>
针对 <具体痛点>, 提出 <方法>, 突破 <子技术 a/b/c>, 支持 <最终能达到> 针对 <具体痛点>, 提出 <方法>, 突破 <子技术 a/b/c>, 支持 <最终能达到>
@ -90,11 +100,22 @@
### (二) 项目拟采取的研究方法 (限 2000 字) ### (二) 项目拟采取的研究方法 (限 2000 字)
总体方法 + 各课题研究方法 (按"需求分析 → 体系设计 → 技术突破 → 系统研发 → 应用示范"5 阶段)。配 4-6 张图。 总体方法 + 各课题研究方法 (按"需求分析 → 体系设计 → 技术突破 → 系统研发 → 应用示范"5 阶段)。配 4-6 张图(mermaid `flowchart``graph`,见 SKILL.md 插图段)
### (三) 项目的技术路线 (限 2000 字) ### (三) 项目的技术路线 (限 2000 字)
总体路线图 + 各课题技术路线图。 总体路线图 + 各课题技术路线图。例:
```mermaid
%% caption: 项目总体技术路线
flowchart TB
A[需求分析] --> B[体系设计]
B --> C[关键技术突破]
C --> D[系统研发与集成]
D --> E[应用示范]
C -. 反馈 .-> B
E -. 反馈 .-> C
```
--- ---
@ -141,6 +162,29 @@
按周期分 4 阶段 + 配甘特图 + 里程碑节点 (每 6-12 月一个: 时间 + 事件 + 关键指标 + 考核方式 + 交付物)。 按周期分 4 阶段 + 配甘特图 + 里程碑节点 (每 6-12 月一个: 时间 + 事件 + 关键指标 + 考核方式 + 交付物)。
甘特图两种画法,任选其一:
**A. mermaid gantt** (简单,自动跟图编号):
```mermaid
%% caption: 项目进度甘特图
gantt
title 项目 4 阶段进度安排
dateFormat YYYY-MM
section 第一阶段 需求与体系
需求调研与场景拆解 :a1, 2026-01, 4M
技术体系设计 :a2, after a1, 3M
section 第二阶段 关键技术
技术 1 突破 :b1, after a2, 6M
技术 2-3 并行 :b2, after a2, 8M
section 第三阶段 系统集成
平台研发 :c1, after b1, 6M
section 第四阶段 应用示范
示范点部署与评测 :d1, after c1, 6M
```
**B. matplotlib `barh`** (打印质量更佳,但代码长;甘特图本身简单时优先用 A):
`run_python`,把 png 落到 `<task_dir>/figures/gantt.png`,然后章节里 `![甘特图: 项目 4 阶段进度安排](figures/gantt.png)`。代码骨架见 SKILL.md "插图" 段。
--- ---
## 10_organization.md — 第五部分 组织实施、保障措施及风险分析 ## 10_organization.md — 第五部分 组织实施、保障措施及风险分析

View File

@ -222,6 +222,74 @@
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; } #new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; } #new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
/* ───── file preview modal ───── */
#file-preview-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: none; align-items: center; justify-content: center; z-index: 90;
}
#file-preview-modal.show { display: flex; }
#file-preview-modal .card {
background: var(--panel); border-radius: 6px;
width: 90vw; height: 90vh; max-width: 1200px;
display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,.2);
}
#file-preview-modal .hdr {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
#file-preview-modal .hdr .name {
flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#file-preview-modal .body {
flex: 1; overflow: auto; padding: 12px; position: relative;
}
#file-preview-modal .body.center {
display: flex; align-items: center; justify-content: center;
}
#file-preview-modal .body .ph {
color: var(--muted); font-size: 13px; text-align: center;
}
#file-preview-modal .body img.preview-img {
max-width: 100%; max-height: 100%; object-fit: contain;
display: block; margin: 0 auto;
}
#file-preview-modal .body iframe.preview-frame {
width: 100%; height: 100%; border: 0;
}
#file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg);
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 12px; line-height: 1.5;
}
#file-preview-modal .body .md-render {
max-width: 860px; margin: 0 auto; line-height: 1.7;
}
#file-preview-modal .body .md-render pre {
background: var(--code-bg); padding: 10px; border-radius: 4px; overflow: auto;
}
#file-preview-modal .body .md-render code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
#file-preview-modal .body .md-render pre code { background: transparent; padding: 0; }
#file-preview-modal .body .docx-host { background: #fff; }
#file-preview-modal .body .xlsx-tabs {
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;
border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
#file-preview-modal .body .xlsx-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
#file-preview-modal .body .xlsx-sheet {
overflow: auto;
}
#file-preview-modal .body .xlsx-sheet table {
border-collapse: collapse; font-size: 12px;
}
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
}
.small { font-size: 12px; } .small { font-size: 12px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
</style> </style>
@ -357,6 +425,19 @@
</div> </div>
</div> </div>
<!-- ───── file preview modal ───── -->
<div id="file-preview-modal">
<div class="card">
<div class="hdr">
<span class="name" id="fp-name"></span>
<span class="small muted" id="fp-meta"></span>
<button class="small" id="fp-download" title="下载原文件">下载</button>
<button class="small" id="fp-close" title="关闭 (Esc)">×</button>
</div>
<div class="body" id="fp-body"></div>
</div>
</div>
<script> <script>
const SENTINEL = "00000000-0000-0000-0000-000000000000"; const SENTINEL = "00000000-0000-0000-0000-000000000000";
const LS_TOKEN = "zcbot.token"; const LS_TOKEN = "zcbot.token";
@ -1003,7 +1084,7 @@ function renderFiles(data) {
el.onclick = () => { el.onclick = () => {
const rel = el.dataset.rel; const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); } if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
else { downloadFile(rel); } else { openFilePreview(rel); }
}; };
}); });
$("file-list").querySelectorAll(".del-file").forEach((btn) => { $("file-list").querySelectorAll(".del-file").forEach((btn) => {
@ -1072,6 +1153,243 @@ function downloadFile(rel) {
}); });
} }
// ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const _scriptCache = new Map();
function loadScript(src) {
if (_scriptCache.has(src)) return _scriptCache.get(src);
const p = new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = () => resolve();
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
document.head.appendChild(s);
});
_scriptCache.set(src, p);
return p;
}
const _previewBlobUrls = new Set();
function _trackBlobUrl(blob, mime) {
const b = mime ? new Blob([blob], { type: mime }) : blob;
const url = URL.createObjectURL(b);
_previewBlobUrls.add(url);
return url;
}
function _flushBlobUrls() {
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
_previewBlobUrls.clear();
}
const _EXT_GROUPS = {
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
pdf: new Set(["pdf"]),
md: new Set(["md","markdown"]),
text: new Set([
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
]),
docx: new Set(["docx"]),
xlsx: new Set(["xlsx","xls"]),
};
function _categorize(rel) {
const m = /\.([a-z0-9]+)$/i.exec(rel);
const ext = m ? m[1].toLowerCase() : "";
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
return "fallback";
}
let _fpCurrentRel = null;
async function openFilePreview(rel) {
_fpCurrentRel = rel;
const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name;
$("fp-meta").textContent = "";
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`;
$("file-preview-modal").classList.add("show");
const cat = _categorize(rel);
try {
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token },
});
if (!r.ok) throw new Error("HTTP " + r.status);
const blob = await r.blob();
$("fp-meta").textContent = humanSize(blob.size);
if (cat === "text" || cat === "md") {
if (blob.size > PREVIEW_TEXT_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
const text = await blob.text();
if (cat === "md") _showMarkdown(text);
else _showText(text);
return;
}
if (blob.size > PREVIEW_BIN_MAX) {
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
return;
}
if (cat === "image") _showImage(blob);
else if (cat === "pdf") _showPdf(blob);
else if (cat === "docx") await _showDocx(blob);
else if (cat === "xlsx") await _showXlsx(blob);
else _showFallback("暂不支持在线预览此格式,请下载查看");
} catch (e) {
if (e.status === 401) { closeFilePreview(); logout(); return; }
_showFallback("加载失败:" + e.message);
}
}
function _showImage(blob) {
const url = _trackBlobUrl(blob);
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const img = document.createElement("img");
img.className = "preview-img";
img.src = url;
body.appendChild(img);
}
function _showPdf(blob) {
const url = _trackBlobUrl(blob, "application/pdf");
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
}
function _showText(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = "";
const pre = document.createElement("pre");
pre.className = "preview-text";
pre.textContent = text;
body.appendChild(pre);
}
function _showMarkdown(text) {
const body = $("fp-body");
body.className = "body";
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
highlightIn(body);
}
async function _showDocx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
try {
await loadScript("/static/vendor/jszip.min.js");
await loadScript("/static/vendor/docx-preview.min.js");
} catch (e) {
_showFallback("docx 解析库加载失败:" + e.message);
return;
}
if (!window.docx || !window.docx.renderAsync) {
_showFallback("docx 解析库不可用");
return;
}
body.className = "body";
body.innerHTML = `<div class="docx-host"></div>`;
try {
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
inWrapper: false,
ignoreLastRenderedPageBreak: true,
});
} catch (e) {
_showFallback("docx 渲染失败:" + e.message);
}
}
async function _showXlsx(blob) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = `<div class="ph">解析表格中…</div>`;
try {
await loadScript("/static/vendor/xlsx.full.min.js");
} catch (e) {
_showFallback("xlsx 解析库加载失败:" + e.message);
return;
}
if (!window.XLSX || !window.XLSX.read) {
_showFallback("xlsx 解析库不可用");
return;
}
let wb;
try {
const ab = await blob.arrayBuffer();
wb = window.XLSX.read(ab, { type: "array" });
} catch (e) {
_showFallback("xlsx 解析失败:" + e.message);
return;
}
const names = wb.SheetNames || [];
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
body.className = "body";
const tabsHtml = names.map((n, i) =>
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
).join("");
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
const render = (i) => {
const ws = wb.Sheets[names[i]];
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
};
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
btn.onclick = () => {
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
render(parseInt(btn.dataset.i));
};
});
render(0);
}
function _showFallback(msg) {
const body = $("fp-body");
body.className = "body center";
body.innerHTML = "";
const ph = document.createElement("div");
ph.className = "ph";
ph.textContent = msg;
const br = document.createElement("br");
const dl = document.createElement("button");
dl.className = "primary";
dl.textContent = "下载原文件";
dl.style.marginTop = "12px";
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
ph.appendChild(document.createElement("br"));
ph.appendChild(br);
ph.appendChild(dl);
body.appendChild(ph);
}
function closeFilePreview() {
$("file-preview-modal").classList.remove("show");
$("fp-body").innerHTML = "";
_flushBlobUrls();
_fpCurrentRel = null;
}
$("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) {
closeFilePreview();
}
});
async function uploadSelected() { async function uploadSelected() {
const inp = $("upload-input"); const inp = $("upload-input");
const files = Array.from(inp.files || []); const files = Array.from(inp.files || []);

2
web/static/vendor/docx-preview.min.js vendored Normal file

File diff suppressed because one or more lines are too long

13
web/static/vendor/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
web/static/vendor/xlsx.full.min.js vendored Normal file

File diff suppressed because one or more lines are too long