// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+ // 同时再开一个的小窗预览(mini)。PDF 用 PDF.js canvas 渲染以兼容 App WebView, // docx/xlsx 走 loadScript 懒加载 vendor。 // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。 import { state } from "./state.js"; import { $ } from "./dom.js"; import { humanSize, escapeHtml } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; import { configureHtmlPreviewFrame } from "./preview_content.js"; import { logout } from "./auth.js"; import { downloadFile } from "./media.js"; // ───── file preview ───── const PREVIEW_TEXT_MAX = 2 * 1024 * 1024; const PREVIEW_BIN_MAX = 50 * 1024 * 1024; const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js"; const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js"; 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"]), html: new Set(["html","htm"]), 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", "xml","css","scss","sh","bash","zsh","sql","conf","env", ]), docx: new Set(["docx"]), xlsx: new Set(["xlsx","xls"]), ppt: new Set(["pptx","ppt"]), }; 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; let _fpCurrentTaskId = ""; let _fpCurrentLegacy = false; let _fpCurrentArtifactId = ""; // Markdown / HTML 共用“预览 / 源文件”切换。HTML 在 opaque-origin sandbox iframe // 中运行:可执行脚本、加载 HTTPS 资源,但不能读取 zcbot 页面或发起表单/顶层跳转。 const _textPreviewState = { fp: null, mp: null }; function _resetTextModes(prefix) { _textPreviewState[prefix] = null; const modes = $(`${prefix}-modes`); if (modes) modes.hidden = true; } async function _loadPdfJs() { await loadScript(PDFJS_SRC); if (!window.pdfjsLib || !window.pdfjsLib.getDocument) { throw new Error("PDF.js 不可用"); } window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_SRC; return window.pdfjsLib; } function _showSource(body, text) { body.className = "body"; body.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "preview-text"; pre.textContent = text; body.appendChild(pre); } function _showHtml(body, text) { body.className = "body html-preview"; body.innerHTML = ""; const frame = document.createElement("iframe"); frame.className = "preview-frame preview-html-frame"; configureHtmlPreviewFrame(frame, text); body.appendChild(frame); } function _renderTextMode(prefix, mode) { const state = _textPreviewState[prefix]; if (!state) return; state.mode = mode; const body = $(`${prefix}-body`); const previewBtn = $(`${prefix}-mode-preview`); const sourceBtn = $(`${prefix}-mode-source`); const showingPreview = mode === "preview"; previewBtn.classList.toggle("active", showingPreview); sourceBtn.classList.toggle("active", !showingPreview); previewBtn.setAttribute("aria-pressed", String(showingPreview)); sourceBtn.setAttribute("aria-pressed", String(!showingPreview)); if (!showingPreview) { _showSource(body, state.text); } else if (state.cat === "md") { body.className = "body"; body.innerHTML = `
${renderMd(state.text)}
`; highlightIn(body); } else { _showHtml(body, state.text); } } function _showRenderableText(prefix, cat, text) { _textPreviewState[prefix] = { cat, text, mode: "preview" }; $(`${prefix}-modes`).hidden = false; _renderTextMode(prefix, "preview"); } // ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ───── // body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel), // 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。 const _zoomState = new WeakMap(); // bodyEl -> { img, scale, badge, timer } function _captureBase(z) { const img = z.img; if (!z.baseW) { z.baseW = img.clientWidth || img.naturalWidth || 0; z.baseH = img.clientHeight || img.naturalHeight || 0; } return z.baseW > 0; } function _applyZoom(z, hint) { const img = z.img; if (z.scale === 1) { // 复位:还原 CSS 里的 max-width/height:100% 自适应贴合 img.style.maxWidth = ""; img.style.maxHeight = ""; img.style.width = ""; img.style.height = ""; img.style.cursor = ""; // 100%:普通箭头(左键不缩放,放大镜会误导) } else { // 以 scale=1 时的"贴合显示"尺寸为基准,给显式像素尺寸。 // 不用 CSS zoom:图片是带 max-width/height:100% 的 flex item,zoom 放大后会被 // 百分比 max 约束重新夹回 → 视觉不变;显式 px + max:none 才真正撑大并让 body 出滚动条。 if (!_captureBase(z)) return; // 图片还没量到尺寸(未加载完),本次跳过,下次再缩 img.style.maxWidth = "none"; img.style.maxHeight = "none"; img.style.width = Math.round(z.baseW * z.scale) + "px"; img.style.height = Math.round(z.baseH * z.scale) + "px"; img.style.cursor = "grab"; // 放大后可左键拖动平移 } z.badge.textContent = hint || (Math.round(z.scale * 100) + "%"); z.badge.classList.add("show"); if (z.timer) clearTimeout(z.timer); z.timer = setTimeout(() => z.badge.classList.remove("show"), hint ? 1400 : 1000); } // 给某 body 内的图片接上 Ctrl+滚轮缩放(badge + 双击复位),记录到 _zoomState。 function _makeImageZoomable(bodyEl, img) { _clearZoom(bodyEl); const card = bodyEl.closest(".card") || bodyEl; const badge = document.createElement("div"); badge.className = "zoom-badge"; // 挂到 card 而非 body,放大后 body 滚动时徽标不跟着滚走 card.appendChild(badge); const z = { img, scale: 1, badge, timer: null, baseW: 0, baseH: 0 }; _zoomState.set(bodyEl, z); img.draggable = false; // 关掉浏览器原生图片拖拽(否则拖出 ghost image) // 图片一加载完就量好贴合尺寸做基准,避免首次缩放时还没渲染量到 0 if (img.complete) _captureBase(z); else img.addEventListener("load", () => _captureBase(z), { once: true }); img.addEventListener("dblclick", () => { if (z.scale === 1) return; // 本来就 100%,无需提示 z.scale = 1; _applyZoom(z, "已复位 · 100%"); }); // 放大后左键拖动平移:本质是改 body 的滚动位置(图片此时已撑大、body 可滚) let dragging = false, sx = 0, sy = 0, sl = 0, st = 0; img.addEventListener("mousedown", (e) => { if (e.button !== 0 || z.scale === 1) return; // 仅左键、仅放大态 e.preventDefault(); dragging = true; sx = e.clientX; sy = e.clientY; sl = bodyEl.scrollLeft; st = bodyEl.scrollTop; img.style.cursor = "grabbing"; }); z._onMove = (e) => { if (!dragging) return; bodyEl.scrollLeft = sl - (e.clientX - sx); bodyEl.scrollTop = st - (e.clientY - sy); }; z._onUp = () => { if (!dragging) return; dragging = false; img.style.cursor = z.scale === 1 ? "" : "grab"; }; document.addEventListener("mousemove", z._onMove); document.addEventListener("mouseup", z._onUp); } function _clearZoom(bodyEl) { const z = _zoomState.get(bodyEl); if (!z) return; if (z.timer) clearTimeout(z.timer); if (z._onMove) document.removeEventListener("mousemove", z._onMove); if (z._onUp) document.removeEventListener("mouseup", z._onUp); z.badge.remove(); _zoomState.delete(bodyEl); } // 只挂一次:Ctrl+滚轮缩当前图片;否则拦住滚动穿透到背景对话列表。 function _bindBodyWheel(bodyEl) { bodyEl.addEventListener("wheel", (e) => { const z = _zoomState.get(bodyEl); if (e.ctrlKey) { if (!z) return; // 无可缩放图片:不拦,交还浏览器 e.preventDefault(); // 有图片:吃掉浏览器页面缩放,改缩图片 z.scale = Math.min(8, Math.max(0.1, +(z.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1)).toFixed(3))); _applyZoom(z); return; } // 非 ctrl:容器不可滚 / 已到边界时阻断,避免冒泡到背景滚动对话列表 const canScroll = bodyEl.scrollHeight > bodyEl.clientHeight + 1; if (!canScroll) { e.preventDefault(); return; } const atTop = bodyEl.scrollTop <= 0 && e.deltaY < 0; const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 1 && e.deltaY > 0; if (atTop || atBottom) e.preventDefault(); }, { passive: false }); } function _fileDownloadUrl(rel, taskId = "", legacy = false, artifactId = "") { const base = taskId ? `/v1/tasks/${encodeURIComponent(taskId)}/files/download` : "/v1/files/download"; return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "") + (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : ""); } function _pptPreviewUrl(rel, taskId = "", legacy = false, artifactId = "") { const base = taskId ? `/v1/tasks/${encodeURIComponent(taskId)}/files/preview_pdf` : "/v1/files/preview_pdf"; return base + "?path=" + encodeURIComponent(rel) + (legacy ? "&legacy=true" : "") + (artifactId ? "&artifact_id=" + encodeURIComponent(artifactId) : ""); } export async function openFilePreview(rel, taskId = "", legacy = false, artifactId = "") { _fpCurrentRel = rel; _fpCurrentTaskId = taskId; _fpCurrentLegacy = legacy; _fpCurrentArtifactId = artifactId; const name = rel.split("/").pop() || rel; $("fp-name").textContent = name; $("fp-meta").textContent = ""; const body = $("fp-body"); _resetTextModes("fp"); _clearZoom(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"); // 遮罩铺满整屏后,把输入区抬到遮罩之上继续可用(CSS body.fp-open #chat-form) document.body.classList.add("fp-open"); const cat = _categorize(rel); // pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。 if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, taskId, legacy, artifactId); return; } try { const r = await fetch(_fileDownloadUrl(rel, taskId, legacy, artifactId), { 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" || cat === "html") { if (blob.size > PREVIEW_TEXT_MAX) { _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); if (cat === "md" || cat === "html") _showRenderableText("fp", cat, 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") await _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) { // SVG 在 里必须 Content-Type=image/svg+xml 才渲染;下载接口靠服务端 // mimetypes 猜类型,部分部署环境(Linux)未注册 .svg → octet-stream → 不显示。 // 这里据扩展名强制纠正,与服务端响应头无关。 const mime = /\.svg$/i.test(_fpCurrentRel || "") ? "image/svg+xml" : ""; const url = _trackBlobUrl(blob, mime); 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); _makeImageZoomable(body, 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); } const _pdfPreviewStates = new WeakMap(); function _clearPdfPreview(body) { const state = _pdfPreviewStates.get(body); if (!state) return; state.disposed = true; if (state.renderTasks) { for (const task of state.renderTasks.values()) task.cancel(); state.renderTasks.clear(); } if (state.loadingTask) state.loadingTask.destroy(); if (state.resizeObserver) state.resizeObserver.disconnect(); if (state.pageObserver) state.pageObserver.disconnect(); if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame); if (state.onResize) window.removeEventListener("resize", state.onResize); if (state.pdf) state.pdf.destroy(); _pdfPreviewStates.delete(body); } async function _showPdfIn(body, blob, fallbackFn) { _clearPdfPreview(body); const state = { disposed: false, pdf: null, loadingTask: null, pageNumber: 1, zoom: 1, renderTasks: new Map(), renderSeq: 0, pageShells: [], pageObserver: null, resizeObserver: null, onResize: null, scrollFrame: null, }; _pdfPreviewStates.set(body, state); body.className = "body pdf-preview"; body.innerHTML = `
适宽
解析 PDF 中…
`; const viewportHost = body.querySelector(".pdf-viewport"); const pageInput = body.querySelector(".pdf-page"); const pagesLabel = body.querySelector(".pdf-pages"); const zoomLabel = body.querySelector(".pdf-zoom"); const zoomOut = body.querySelector(".pdf-zoom-out"); const zoomIn = body.querySelector(".pdf-zoom-in"); const renderPage = async (pageNumber) => { if (state.disposed || !state.pdf) return; const shell = state.pageShells[pageNumber - 1]; if (!shell) return; const renderKey = `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`; if (shell.dataset.renderKey === renderKey || state.renderTasks.has(pageNumber)) return; const page = await state.pdf.getPage(pageNumber); if (state.disposed || !shell.isConnected) return; const baseViewport = page.getViewport({ scale: 1 }); const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width); const viewport = page.getViewport({ scale: fitScale * state.zoom }); shell.dataset.baseWidth = String(baseViewport.width); shell.dataset.baseHeight = String(baseViewport.height); const outputScale = Math.min(window.devicePixelRatio || 1, 2); const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); canvas.className = "pdf-canvas"; canvas.width = Math.floor(viewport.width * outputScale); canvas.height = Math.floor(viewport.height * outputScale); canvas.style.width = Math.floor(viewport.width) + "px"; canvas.style.height = Math.floor(viewport.height) + "px"; shell.style.width = Math.floor(viewport.width) + "px"; shell.style.height = Math.floor(viewport.height) + "px"; const renderTask = page.render({ canvasContext: context, viewport, transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], }); state.renderTasks.set(pageNumber, renderTask); try { await renderTask.promise; } catch (e) { if (e && e.name === "RenderingCancelledException") return; throw e; } finally { if (state.renderTasks.get(pageNumber) === renderTask) state.renderTasks.delete(pageNumber); } if (state.disposed || renderKey !== `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`) return; shell.innerHTML = ""; shell.appendChild(canvas); shell.dataset.renderKey = renderKey; }; const goToPage = (pageNumber) => { if (!state.pdf) return; state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber)); pageInput.value = String(state.pageNumber); const shell = state.pageShells[state.pageNumber - 1]; if (shell) viewportHost.scrollTo({ top: Math.max(0, shell.offsetTop - 12), behavior: "smooth" }); }; pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1); const rerenderVisiblePages = () => { state.renderSeq += 1; for (const task of state.renderTasks.values()) task.cancel(); state.renderTasks.clear(); const availableWidth = Math.max(1, viewportHost.clientWidth - 24); for (const shell of state.pageShells) { shell.dataset.renderKey = ""; const baseWidth = Number(shell.dataset.baseWidth); const baseHeight = Number(shell.dataset.baseHeight); const scale = Math.max(0.1, availableWidth / baseWidth) * state.zoom; shell.style.width = Math.floor(baseWidth * scale) + "px"; shell.style.height = Math.floor(baseHeight * scale) + "px"; const rect = shell.getBoundingClientRect(); const hostRect = viewportHost.getBoundingClientRect(); if (rect.bottom >= hostRect.top - hostRect.height && rect.top <= hostRect.bottom + hostRect.height) { renderPage(Number(shell.dataset.page)).catch(handleRenderError); } } }; const handleRenderError = (e) => { if (state.disposed || (e && e.name === "RenderingCancelledException")) return; _clearPdfPreview(body); fallbackFn("PDF 渲染失败:" + e.message); }; zoomOut.onclick = () => { state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2)); zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%"; rerenderVisiblePages(); }; zoomIn.onclick = () => { state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2)); zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%"; rerenderVisiblePages(); }; try { const pdfjsLib = await _loadPdfJs(); const data = new Uint8Array(await blob.arrayBuffer()); state.loadingTask = pdfjsLib.getDocument({ data }); const pdf = await state.loadingTask.promise; state.loadingTask = null; if (state.disposed || _pdfPreviewStates.get(body) !== state) { pdf.destroy(); return; } state.pdf = pdf; pagesLabel.textContent = String(pdf.numPages); pageInput.max = String(pdf.numPages); pageInput.disabled = false; zoomOut.disabled = false; zoomIn.disabled = false; const firstPage = await pdf.getPage(1); const firstViewport = firstPage.getViewport({ scale: 1 }); const initialScale = Math.max(0.1, (viewportHost.clientWidth - 24) / firstViewport.width); viewportHost.innerHTML = '
'; const pagesList = viewportHost.firstElementChild; for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { const shell = document.createElement("div"); shell.className = "pdf-page-shell"; shell.dataset.page = String(pageNumber); shell.dataset.baseWidth = String(firstViewport.width); shell.dataset.baseHeight = String(firstViewport.height); shell.style.width = Math.floor(firstViewport.width * initialScale) + "px"; shell.style.height = Math.floor(firstViewport.height * initialScale) + "px"; shell.innerHTML = `第 ${pageNumber} 页`; pagesList.appendChild(shell); state.pageShells.push(shell); } state.pageObserver = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) renderPage(Number(entry.target.dataset.page)).catch(handleRenderError); } }, { root: viewportHost, rootMargin: "100% 0px" }); for (const shell of state.pageShells) state.pageObserver.observe(shell); viewportHost.addEventListener("scroll", () => { if (state.scrollFrame) return; state.scrollFrame = requestAnimationFrame(() => { state.scrollFrame = null; const center = viewportHost.scrollTop + viewportHost.clientHeight / 2; let current = 1; let distance = Infinity; for (const shell of state.pageShells) { const delta = Math.abs(shell.offsetTop + shell.offsetHeight / 2 - center); if (delta < distance) { distance = delta; current = Number(shell.dataset.page); } const farAway = shell.offsetTop + shell.offsetHeight < viewportHost.scrollTop - viewportHost.clientHeight * 2 || shell.offsetTop > viewportHost.scrollTop + viewportHost.clientHeight * 3; if (farAway && shell.dataset.renderKey) { shell.innerHTML = `第 ${shell.dataset.page} 页`; shell.dataset.renderKey = ""; } } state.pageNumber = current; pageInput.value = String(current); }); }, { passive: true }); const rerender = () => { if (state.disposed) return; rerenderVisiblePages(); }; if (window.ResizeObserver) { state.resizeObserver = new ResizeObserver(rerender); state.resizeObserver.observe(viewportHost); } else { state.onResize = rerender; window.addEventListener("resize", rerender); } } catch (e) { if (!state.disposed) { _clearPdfPreview(body); fallbackFn("PDF 预览失败:" + e.message); } } } async function _showPdf(blob) { await _showPdfIn($("fp-body"), blob, _showFallback); } // pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。 async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false, artifactId = "") { body.className = "body center"; body.innerHTML = `
由 PPT 转换为 PDF · 首次稍候…
`; if (metaEl) metaEl.textContent = ""; let r; try { r = await fetch(_pptPreviewUrl(rel, taskId, legacy, artifactId), { headers: { "Authorization": "Bearer " + state.token }, }); } catch (e) { fallbackFn("加载失败:" + e.message); return; } if (r.status === 401) { logout(); return; } if (!r.ok) { let msg = "PPT 在线预览不可用,请下载查看"; if (r.status === 501) msg = "服务器未装 LibreOffice,无法在线预览 PPT,请下载查看"; else if (r.status === 500) msg = "PPT 转换失败,请下载原文件查看"; fallbackFn(msg); return; } const blob = await r.blob(); if (metaEl) metaEl.textContent = humanSize(blob.size) + " · PDF 预览"; await _showPdfIn(body, blob, fallbackFn); } function _showText(text) { _showSource($("fp-body"), text); } 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, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); }; ph.appendChild(document.createElement("br")); ph.appendChild(br); ph.appendChild(dl); body.appendChild(ph); } export function closeFilePreview() { $("file-preview-modal").classList.remove("show"); document.body.classList.remove("fp-open"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); _clearZoom($("fp-body")); _clearPdfPreview($("fp-body")); _resetTextModes("fp"); $("fp-body").innerHTML = ""; _flushBlobUrls(); _fpCurrentRel = null; _fpCurrentTaskId = ""; _fpCurrentLegacy = false; _fpCurrentArtifactId = ""; } 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"); _resetTextModes("mp"); _clearZoom(body); _clearPdfPreview(body); body.className = "body center"; body.innerHTML = `
加载中…
`; _flushMiniBlobUrls(); $("mini-preview-modal").classList.add("show"); const cat = _categorize(rel); if (cat === "ppt") { await _showPptAsPdf(rel, $("mp-body"), $("mp-meta"), _showMiniFallback); return; } 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" || cat === "html") { if (blob.size > PREVIEW_TEXT_MAX) { _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text); else _showSource(body, text); 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"; const _mime = /\.svg$/i.test(_mpCurrentRel || "") ? "image/svg+xml" : ""; img.src = _trackMiniBlobUrl(blob, _mime); body.appendChild(img); _makeImageZoomable(body, 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") { await _showPdfIn(body, blob, _showMiniFallback); } 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"); _clearZoom($("mp-body")); _clearPdfPreview($("mp-body")); _resetTextModes("mp"); $("mp-body").innerHTML = ""; _flushMiniBlobUrls(); _mpCurrentRel = null; } // 消息进入发送流程后,预览不应继续遮住正在生成的回答。主预览与粘贴附件的 // 次级预览可能同时存在,因此必须一起关闭。 export function closeAllPreviews() { if ($("file-preview-modal").classList.contains("show")) closeFilePreview(); if ($("mini-preview-modal").classList.contains("show")) closeMiniPreview(); } // 删文件时:若该 rel 正在主/小预览中则关掉(供 main 的 deletePastedFile 等调用, // 不对外暴露内部 current-rel 状态)。 export function closePreviewIfShowing(rel) { if (_fpCurrentRel === rel) closeFilePreview(); if (_mpCurrentRel === rel) closeMiniPreview(); } _bindBodyWheel($("fp-body")); _bindBodyWheel($("mp-body")); $("fp-close").onclick = closeFilePreview; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel, _fpCurrentTaskId, _fpCurrentLegacy, _fpCurrentArtifactId); }; $("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview"); $("fp-mode-source").onclick = () => _renderTextMode("fp", "source"); $("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); }; $("mp-mode-preview").onclick = () => _renderTextMode("mp", "preview"); $("mp-mode-source").onclick = () => _renderTextMode("mp", "source"); $("mini-preview-modal").addEventListener("click", (e) => { if (e.target.id === "mini-preview-modal") closeMiniPreview(); });