From 9394e065f15b8045631af173b9fc7def89175ca9 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Sat, 6 Jun 2026 21:57:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(dev):=20=E5=89=8D=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8C=96=20Step=202=20=E2=80=94=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20preview.js(=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88=20+=20mini?= =?UTF-8?q?=20=E9=A2=84=E8=A7=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载, docx/xlsx 走 loadScript 懒加载 vendor)+ 同时再开的小窗预览 (原 main.js 1687–2048)→ preview.js(379 行)。 - 导出 openFilePreview / openPasteFilePreview / closeFilePreview / closeMiniPreview / _categorize(媒体段判图/视频用)。 - 反向 import downloadFile(main 媒体段,加 export)、logout(auth)。 - Esc 关弹窗栈处理器留 main(跨模块协调 chpw/选入/文件预览/小预览)。 - 一处去耦:deletePastedFile(留 main)原直接读 preview 私有 _fpCurrentRel/_mpCurrentRel 判断要不要关预览 → 改为 preview 导出封装 closePreviewIfShowing(rel),行为不变但不泄漏内部状态(唯一非纯剪切微调)。 main.js 删至 2034 行。node --check 双过、preview 私有符号在 main 清零、 无未导入 glue 引用、静态测试 2 过。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 3 +- web/static/js/main.js | 369 +------------------------------------ web/static/js/preview.js | 379 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 366 deletions(-) create mode 100644 web/static/js/preview.js diff --git a/PROGRESS.md b/PROGRESS.md index 7afe00a..0345e54 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-06-06(前端模块化 Step 2:抽出 layout.js / auth.js) +最后更新:2026-06-06(前端模块化 Step 2:抽出 layout.js / auth.js / preview.js) --- @@ -23,6 +23,7 @@ ### 2026-06-06 +- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。 - **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。 - **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html` 找 `formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。 - **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试 + 非流式兜底**:用户报"测试docx"任务里 zcbot 回 `[Error] bad arguments to write: WriteTool.execute() missing 2 required positional arguments`。实证定位(dump 失败 task 全量 messages):**大参数(≈7–10K 字符)的 write/run_python 偶发把别处内容碎片错位粘进 `arguments` 开头**(如 `].cells[1].merge(...{"path":...}`),`json.loads` 直接失败;有时退化成空 `{}` → execute 缺参报 TypeError。**根因双层**:① 上游 deepseek-v4-flash 流式 delta 偶发错位(隔离复现 16/16 全干净,说明概率低);② 真正放大成灾的是 **loop 把损坏的 assistant 消息原样入库 + 每轮重发 → 模型学坏的投毒级联**(失败 task 里大半 write 连锁失败)。读 litellm `stream_chunk_builder` 源码排除"content 混进 args"(content 与 tool_args 两趟独立合并);批量验证非流式 8/8、流式 8/8 在干净上下文均不复现 → 确认是间歇上游抖动 + loop 零容错。**修法**(`core/loop.py`):`_stream_llm` 重构成「拉一轮 → `_malformed_tool_calls` 校验 tool_call arguments 能否 `json.loads` → 不能则**丢弃整轮(不 append/不记账)重 roll**」,最多 3 次;最后一次降级 `_nonstream_once`(provider 服务端拼 tool_calls,绕开流式错位,content 整段补 emit)。断投毒环 + 不依赖猜准上游成因 + 不动正常路径。**backstop**:`executor_host.py` / `sandbox/tool_runner.py` 缺必填参数(空 `{}`)早返 `缺少必填参数 [...];请带齐 [...] 重新调用`,替掉暴露内部签名的 `missing N required positional arguments`。重试消耗 token 不单独记账(罕见路径)。tests 全过(唯一失败 `test_static_vendor::formatContextStats` 是前端 ES module 化遗留,与本改无关)。 diff --git a/web/static/js/main.js b/web/static/js/main.js index fd06198..8dbe41f 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -15,6 +15,7 @@ import { api } from "./api.js"; import { renderMd, highlightIn } from "./markdown.js"; import { mqPhone, setMobileView } from "./layout.js"; import { logout, closeChpwModal } from "./auth.js"; +import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js"; // embed 首个 task 自动定位的一次性标志(仅 embed 段使用) let _embedInitialTaskHandled = false; @@ -671,8 +672,7 @@ async function deletePastedFile(rel, wrap) { if (btn) btn.disabled = true; try { await api("POST", "/v1/files/delete", { path: rel, recursive: false }); - if (_fpCurrentRel === rel) closeFilePreview(); - if (_mpCurrentRel === rel) closeMiniPreview(); + closePreviewIfShowing(rel); wrap.remove(); await loadFiles(); const hint = $("chat-hint"); @@ -1670,7 +1670,7 @@ function upgradeMediaArtifacts(root) { }); } -function downloadFile(rel) { +export function downloadFile(rel) { fetch("/v1/files/download?path=" + encodeURIComponent(rel), { headers: { "Authorization": "Bearer " + state.token }, }).then(async (r) => { @@ -1684,368 +1684,7 @@ 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"]), - video: new Set(["mp4","webm","mov","mkv","m4v"]), - 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 = `
加载中…
`; - // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0) - const cf = $("chat-form"); - const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0; - $("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px"); - $("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 === "video") _showVideo(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 _showVideo(blob) { - const url = _trackBlobUrl(blob); - const body = $("fp-body"); - body.className = "body center"; - body.innerHTML = ""; - const v = document.createElement("video"); - v.className = "preview-video"; - v.src = url; - v.controls = true; - v.autoplay = true; - body.appendChild(v); -} - -function _showPdf(blob) { - const url = _trackBlobUrl(blob, "application/pdf"); - const body = $("fp-body"); - body.className = "body"; - body.innerHTML = ``; -} - -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 = `
${renderMd(text)}
`; - highlightIn(body); -} - -async function _showDocx(blob) { - const body = $("fp-body"); - body.className = "body center"; - body.innerHTML = `
解析 docx 中…
`; - 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 = `
`; - 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 = `
解析表格中…
`; - 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) => - `` - ).join(""); - body.innerHTML = `
${tabsHtml}
`; - 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"); - $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); - $("fp-body").innerHTML = ""; - _flushBlobUrls(); - _fpCurrentRel = null; -} - -let _mpCurrentRel = null; -const _miniPreviewBlobUrls = new Set(); -function _trackMiniBlobUrl(blob, mime) { - const b = mime ? new Blob([blob], { type: mime }) : blob; - const url = URL.createObjectURL(b); - _miniPreviewBlobUrls.add(url); - return url; -} -function _flushMiniBlobUrls() { - for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u); - _miniPreviewBlobUrls.clear(); -} -function openPasteFilePreview(rel) { - if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel); - else openFilePreview(rel); -} -async function openMiniFilePreview(rel) { - _mpCurrentRel = rel; - const name = rel.split("/").pop() || rel; - $("mp-name").textContent = name; - $("mp-meta").textContent = ""; - const body = $("mp-body"); - body.className = "body center"; - body.innerHTML = `
加载中…
`; - _flushMiniBlobUrls(); - $("mini-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(); - $("mp-meta").textContent = humanSize(blob.size); - if (cat === "text" || cat === "md") { - if (blob.size > PREVIEW_TEXT_MAX) { - _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); - return; - } - const text = await blob.text(); - body.className = "body"; - if (cat === "md") { - body.innerHTML = `
${renderMd(text)}
`; - highlightIn(body); - } else { - body.innerHTML = ""; - const pre = document.createElement("pre"); - pre.className = "preview-text"; - pre.textContent = text; - body.appendChild(pre); - } - return; - } - if (blob.size > PREVIEW_BIN_MAX) { - _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); - return; - } - body.innerHTML = ""; - if (cat === "image") { - body.className = "body center"; - const img = document.createElement("img"); - img.className = "preview-img"; - img.src = _trackMiniBlobUrl(blob); - body.appendChild(img); - } else if (cat === "video") { - body.className = "body center"; - const v = document.createElement("video"); - v.className = "preview-video"; - v.src = _trackMiniBlobUrl(blob); - v.controls = true; - body.appendChild(v); - } else if (cat === "pdf") { - body.className = "body"; - body.innerHTML = ``; - } else { - _showMiniFallback("暂不支持小窗预览此格式,请下载查看"); - } - } catch (e) { - if (e.status === 401) { closeMiniPreview(); logout(); return; } - _showMiniFallback("加载失败:" + e.message); - } -} -function _showMiniFallback(msg) { - const body = $("mp-body"); - body.className = "body center"; - body.innerHTML = ""; - const ph = document.createElement("div"); - ph.className = "ph"; - ph.textContent = msg; - const dl = document.createElement("button"); - dl.className = "primary"; - dl.textContent = "下载原文件"; - dl.style.marginTop = "12px"; - dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; - ph.appendChild(document.createElement("br")); - ph.appendChild(dl); - body.appendChild(ph); -} -function closeMiniPreview() { - $("mini-preview-modal").classList.remove("show"); - $("mp-body").innerHTML = ""; - _flushMiniBlobUrls(); - _mpCurrentRel = 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(); -}); -$("mp-close").onclick = closeMiniPreview; -$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; -$("mini-preview-modal").addEventListener("click", (e) => { - if (e.target.id === "mini-preview-modal") closeMiniPreview(); -}); +// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)───── document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80) diff --git a/web/static/js/preview.js b/web/static/js/preview.js new file mode 100644 index 0000000..acc7779 --- /dev/null +++ b/web/static/js/preview.js @@ -0,0 +1,379 @@ +// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+ +// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。 +// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; +// _categorize 也供媒体段判图/视频。反向依赖 downloadFile(main 媒体段)、logout(auth)。 +import { state } from "./state.js"; +import { $ } from "./dom.js"; +import { humanSize, escapeHtml } from "./format.js"; +import { renderMd, highlightIn } from "./markdown.js"; +import { logout } from "./auth.js"; +import { downloadFile } from "./main.js"; + +// ───── 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"]), + video: new Set(["mp4","webm","mov","mkv","m4v"]), + 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"]), +}; +export 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; + +export 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 = `
加载中…
`; + // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0) + const cf = $("chat-form"); + const inset = (cf && cf.offsetParent) ? cf.offsetHeight : 0; + $("file-preview-modal").style.setProperty("--preview-bottom-inset", inset + "px"); + $("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 === "video") _showVideo(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 _showVideo(blob) { + const url = _trackBlobUrl(blob); + const body = $("fp-body"); + body.className = "body center"; + body.innerHTML = ""; + const v = document.createElement("video"); + v.className = "preview-video"; + v.src = url; + v.controls = true; + v.autoplay = true; + body.appendChild(v); +} + +function _showPdf(blob) { + const url = _trackBlobUrl(blob, "application/pdf"); + const body = $("fp-body"); + body.className = "body"; + body.innerHTML = ``; +} + +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 = `
${renderMd(text)}
`; + highlightIn(body); +} + +async function _showDocx(blob) { + const body = $("fp-body"); + body.className = "body center"; + body.innerHTML = `
解析 docx 中…
`; + 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 = `
`; + 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 = `
解析表格中…
`; + 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) => + `` + ).join(""); + body.innerHTML = `
${tabsHtml}
`; + 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); +} + +export function closeFilePreview() { + $("file-preview-modal").classList.remove("show"); + $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); + $("fp-body").innerHTML = ""; + _flushBlobUrls(); + _fpCurrentRel = null; +} + +let _mpCurrentRel = null; +const _miniPreviewBlobUrls = new Set(); +function _trackMiniBlobUrl(blob, mime) { + const b = mime ? new Blob([blob], { type: mime }) : blob; + const url = URL.createObjectURL(b); + _miniPreviewBlobUrls.add(url); + return url; +} +function _flushMiniBlobUrls() { + for (const u of _miniPreviewBlobUrls) URL.revokeObjectURL(u); + _miniPreviewBlobUrls.clear(); +} +export function openPasteFilePreview(rel) { + if ($("file-preview-modal").classList.contains("show")) openMiniFilePreview(rel); + else openFilePreview(rel); +} +async function openMiniFilePreview(rel) { + _mpCurrentRel = rel; + const name = rel.split("/").pop() || rel; + $("mp-name").textContent = name; + $("mp-meta").textContent = ""; + const body = $("mp-body"); + body.className = "body center"; + body.innerHTML = `
加载中…
`; + _flushMiniBlobUrls(); + $("mini-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(); + $("mp-meta").textContent = humanSize(blob.size); + if (cat === "text" || cat === "md") { + if (blob.size > PREVIEW_TEXT_MAX) { + _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); + return; + } + const text = await blob.text(); + body.className = "body"; + if (cat === "md") { + body.innerHTML = `
${renderMd(text)}
`; + highlightIn(body); + } else { + body.innerHTML = ""; + const pre = document.createElement("pre"); + pre.className = "preview-text"; + pre.textContent = text; + body.appendChild(pre); + } + return; + } + if (blob.size > PREVIEW_BIN_MAX) { + _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); + return; + } + body.innerHTML = ""; + if (cat === "image") { + body.className = "body center"; + const img = document.createElement("img"); + img.className = "preview-img"; + img.src = _trackMiniBlobUrl(blob); + body.appendChild(img); + } else if (cat === "video") { + body.className = "body center"; + const v = document.createElement("video"); + v.className = "preview-video"; + v.src = _trackMiniBlobUrl(blob); + v.controls = true; + body.appendChild(v); + } else if (cat === "pdf") { + body.className = "body"; + body.innerHTML = ``; + } else { + _showMiniFallback("暂不支持小窗预览此格式,请下载查看"); + } + } catch (e) { + if (e.status === 401) { closeMiniPreview(); logout(); return; } + _showMiniFallback("加载失败:" + e.message); + } +} +function _showMiniFallback(msg) { + const body = $("mp-body"); + body.className = "body center"; + body.innerHTML = ""; + const ph = document.createElement("div"); + ph.className = "ph"; + ph.textContent = msg; + const dl = document.createElement("button"); + dl.className = "primary"; + dl.textContent = "下载原文件"; + dl.style.marginTop = "12px"; + dl.onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; + ph.appendChild(document.createElement("br")); + ph.appendChild(dl); + body.appendChild(ph); +} +export function closeMiniPreview() { + $("mini-preview-modal").classList.remove("show"); + $("mp-body").innerHTML = ""; + _flushMiniBlobUrls(); + _mpCurrentRel = null; +} +// 删文件时:若该 rel 正在主/小预览中则关掉(供 main 的 deletePastedFile 等调用, +// 不对外暴露内部 current-rel 状态)。 +export function closePreviewIfShowing(rel) { + if (_fpCurrentRel === rel) closeFilePreview(); + if (_mpCurrentRel === rel) closeMiniPreview(); +} + +$("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(); +}); +$("mp-close").onclick = closeMiniPreview; +$("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; +$("mini-preview-modal").addEventListener("click", (e) => { + if (e.target.id === "mini-preview-modal") closeMiniPreview(); +});