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();
+});