refactor(dev): 前端模块化 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),行为不变但不泄漏内部状态(唯一非纯剪切微调)。 main.js 删至 2034 行。node --check 双过、preview 私有符号在 main 清零、 无未导入 glue 引用、静态测试 2 过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
16dc1719eb
commit
9394e065f1
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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:抽出 `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 解)。
|
- **前端模块化 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 化遗留,与本改无关)。
|
- **修 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 化遗留,与本改无关)。
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { api } from "./api.js";
|
||||||
import { renderMd, highlightIn } from "./markdown.js";
|
import { renderMd, highlightIn } from "./markdown.js";
|
||||||
import { mqPhone, setMobileView } from "./layout.js";
|
import { mqPhone, setMobileView } from "./layout.js";
|
||||||
import { logout, closeChpwModal } from "./auth.js";
|
import { logout, closeChpwModal } from "./auth.js";
|
||||||
|
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
|
||||||
|
|
||||||
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
||||||
let _embedInitialTaskHandled = false;
|
let _embedInitialTaskHandled = false;
|
||||||
|
|
@ -671,8 +672,7 @@ async function deletePastedFile(rel, wrap) {
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await api("POST", "/v1/files/delete", { path: rel, recursive: false });
|
await api("POST", "/v1/files/delete", { path: rel, recursive: false });
|
||||||
if (_fpCurrentRel === rel) closeFilePreview();
|
closePreviewIfShowing(rel);
|
||||||
if (_mpCurrentRel === rel) closeMiniPreview();
|
|
||||||
wrap.remove();
|
wrap.remove();
|
||||||
await loadFiles();
|
await loadFiles();
|
||||||
const hint = $("chat-hint");
|
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), {
|
fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||||||
headers: { "Authorization": "Bearer " + state.token },
|
headers: { "Authorization": "Bearer " + state.token },
|
||||||
}).then(async (r) => {
|
}).then(async (r) => {
|
||||||
|
|
@ -1684,368 +1684,7 @@ function downloadFile(rel) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── file preview ─────
|
// ───── Esc 关弹窗栈(跨模块协调:chpw/选入/文件预览/小预览)─────
|
||||||
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 = `<div class="ph">加载中…</div>`;
|
|
||||||
// 让出聊天输入区高度,弹框不遮挡 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 = `<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");
|
|
||||||
$("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 = `<div class="ph">加载中…</div>`;
|
|
||||||
_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 = `<div class="md-render">${renderMd(text)}</div>`;
|
|
||||||
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 = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
|
|
||||||
} 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();
|
|
||||||
});
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
// 多模态共存:优先关靠前栈顶 — 小预览(z 96)→ 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||||
|
|
|
||||||
|
|
@ -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 = `<div class="ph">加载中…</div>`;
|
||||||
|
// 让出聊天输入区高度,弹框不遮挡 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 = `<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="ph">加载中…</div>`;
|
||||||
|
_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 = `<div class="md-render">${renderMd(text)}</div>`;
|
||||||
|
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 = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
|
||||||
|
} 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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue