ui(dev SPA): 文件点击弹框预览(image/pdf/text/md/docx/xlsx, 其它 fallback)
原行为 click → 走 downloadFile 直接落盘,不能在线看。 现 click → openFilePreview(rel) 打开 #file-preview-modal(90vw × 90vh), 按扩展名分派渲染器: - image (jpg/png/gif/webp/bmp/svg/ico) → <img> blob URL - pdf → <iframe> blob URL + application/pdf mime - text 类 (~30 种 txt/log/json/yaml/code) → <pre> textContent (2MB cap) - md → 复用 renderMd(marked + DOMPurify + hljs) - docx → 懒加载 jszip + docx-preview → renderAsync 到 DOM - xlsx/xls → 懒加载 SheetJS → 多 sheet tab + sheet_to_html - 其它 (pptx/doc/ppt/...) → fallback "暂不支持在线预览" + 下载按钮 机制:fetch /v1/files/download 取 blob 绕 auth header 限制(后端不动); 懒加载 vendor 脚本(_scriptCache 防重入,失败 fallback); _trackBlobUrl + _flushBlobUrls 弹框关时统一 revoke 防泄漏; Esc / 点 backdrop / × 三种关闭路径; auth 401 → logout;binary 50MB / text 2MB 上限兜底防 OOM。 pptx 整个社区 JS 库都不成熟(动画/复杂版式失真),先 fallback, 真有需求再上服务端 LibreOffice 转 PDF 统一处理。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3215e023a
commit
15bbadf6d6
|
|
@ -222,6 +222,74 @@
|
||||||
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
|
#new-task-modal .err { color: var(--accent); font-size: 12px; margin-top: 8px; min-height: 1em; }
|
||||||
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
|
#new-task-modal .actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* ───── file preview modal ───── */
|
||||||
|
#file-preview-modal {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||||
|
display: none; align-items: center; justify-content: center; z-index: 90;
|
||||||
|
}
|
||||||
|
#file-preview-modal.show { display: flex; }
|
||||||
|
#file-preview-modal .card {
|
||||||
|
background: var(--panel); border-radius: 6px;
|
||||||
|
width: 90vw; height: 90vh; max-width: 1200px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
#file-preview-modal .hdr {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#file-preview-modal .hdr .name {
|
||||||
|
flex: 1; font-weight: 500; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body {
|
||||||
|
flex: 1; overflow: auto; padding: 12px; position: relative;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body.center {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .ph {
|
||||||
|
color: var(--muted); font-size: 13px; text-align: center;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body img.preview-img {
|
||||||
|
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||||
|
display: block; margin: 0 auto;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body iframe.preview-frame {
|
||||||
|
width: 100%; height: 100%; border: 0;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body pre.preview-text {
|
||||||
|
margin: 0; padding: 8px; background: var(--code-bg);
|
||||||
|
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
|
||||||
|
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||||
|
font-size: 12px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .md-render {
|
||||||
|
max-width: 860px; margin: 0 auto; line-height: 1.7;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .md-render pre {
|
||||||
|
background: var(--code-bg); padding: 10px; border-radius: 4px; overflow: auto;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .md-render code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
|
||||||
|
#file-preview-modal .body .md-render pre code { background: transparent; padding: 0; }
|
||||||
|
#file-preview-modal .body .docx-host { background: #fff; }
|
||||||
|
#file-preview-modal .body .xlsx-tabs {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .xlsx-tabs button.active {
|
||||||
|
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .xlsx-sheet {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .xlsx-sheet table {
|
||||||
|
border-collapse: collapse; font-size: 12px;
|
||||||
|
}
|
||||||
|
#file-preview-modal .body .xlsx-sheet td, #file-preview-modal .body .xlsx-sheet th {
|
||||||
|
border: 1px solid var(--border); padding: 4px 8px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.small { font-size: 12px; }
|
.small { font-size: 12px; }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -357,6 +425,19 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ───── file preview modal ───── -->
|
||||||
|
<div id="file-preview-modal">
|
||||||
|
<div class="card">
|
||||||
|
<div class="hdr">
|
||||||
|
<span class="name" id="fp-name"></span>
|
||||||
|
<span class="small muted" id="fp-meta"></span>
|
||||||
|
<button class="small" id="fp-download" title="下载原文件">下载</button>
|
||||||
|
<button class="small" id="fp-close" title="关闭 (Esc)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="body" id="fp-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SENTINEL = "00000000-0000-0000-0000-000000000000";
|
const SENTINEL = "00000000-0000-0000-0000-000000000000";
|
||||||
const LS_TOKEN = "zcbot.token";
|
const LS_TOKEN = "zcbot.token";
|
||||||
|
|
@ -1003,7 +1084,7 @@ function renderFiles(data) {
|
||||||
el.onclick = () => {
|
el.onclick = () => {
|
||||||
const rel = el.dataset.rel;
|
const rel = el.dataset.rel;
|
||||||
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
|
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); }
|
||||||
else { downloadFile(rel); }
|
else { openFilePreview(rel); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
$("file-list").querySelectorAll(".del-file").forEach((btn) => {
|
$("file-list").querySelectorAll(".del-file").forEach((btn) => {
|
||||||
|
|
@ -1072,6 +1153,243 @@ function downloadFile(rel) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───── file preview ─────
|
||||||
|
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
|
||||||
|
const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
const _scriptCache = new Map();
|
||||||
|
function loadScript(src) {
|
||||||
|
if (_scriptCache.has(src)) return _scriptCache.get(src);
|
||||||
|
const p = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = src;
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => { _scriptCache.delete(src); reject(new Error("load failed: " + src)); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
_scriptCache.set(src, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _previewBlobUrls = new Set();
|
||||||
|
function _trackBlobUrl(blob, mime) {
|
||||||
|
const b = mime ? new Blob([blob], { type: mime }) : blob;
|
||||||
|
const url = URL.createObjectURL(b);
|
||||||
|
_previewBlobUrls.add(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
function _flushBlobUrls() {
|
||||||
|
for (const u of _previewBlobUrls) URL.revokeObjectURL(u);
|
||||||
|
_previewBlobUrls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const _EXT_GROUPS = {
|
||||||
|
image: new Set(["jpg","jpeg","png","gif","webp","bmp","svg","ico"]),
|
||||||
|
pdf: new Set(["pdf"]),
|
||||||
|
md: new Set(["md","markdown"]),
|
||||||
|
text: new Set([
|
||||||
|
"txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv",
|
||||||
|
"py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp",
|
||||||
|
"html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env",
|
||||||
|
]),
|
||||||
|
docx: new Set(["docx"]),
|
||||||
|
xlsx: new Set(["xlsx","xls"]),
|
||||||
|
};
|
||||||
|
function _categorize(rel) {
|
||||||
|
const m = /\.([a-z0-9]+)$/i.exec(rel);
|
||||||
|
const ext = m ? m[1].toLowerCase() : "";
|
||||||
|
for (const [cat, set] of Object.entries(_EXT_GROUPS)) if (set.has(ext)) return cat;
|
||||||
|
return "fallback";
|
||||||
|
}
|
||||||
|
|
||||||
|
let _fpCurrentRel = null;
|
||||||
|
|
||||||
|
async function openFilePreview(rel) {
|
||||||
|
_fpCurrentRel = rel;
|
||||||
|
const name = rel.split("/").pop() || rel;
|
||||||
|
$("fp-name").textContent = name;
|
||||||
|
$("fp-meta").textContent = "";
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body center";
|
||||||
|
body.innerHTML = `<div class="ph">加载中…</div>`;
|
||||||
|
$("file-preview-modal").classList.add("show");
|
||||||
|
|
||||||
|
const cat = _categorize(rel);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
|
||||||
|
headers: { "Authorization": "Bearer " + state.token },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
const blob = await r.blob();
|
||||||
|
$("fp-meta").textContent = humanSize(blob.size);
|
||||||
|
|
||||||
|
if (cat === "text" || cat === "md") {
|
||||||
|
if (blob.size > PREVIEW_TEXT_MAX) {
|
||||||
|
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await blob.text();
|
||||||
|
if (cat === "md") _showMarkdown(text);
|
||||||
|
else _showText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blob.size > PREVIEW_BIN_MAX) {
|
||||||
|
_showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cat === "image") _showImage(blob);
|
||||||
|
else if (cat === "pdf") _showPdf(blob);
|
||||||
|
else if (cat === "docx") await _showDocx(blob);
|
||||||
|
else if (cat === "xlsx") await _showXlsx(blob);
|
||||||
|
else _showFallback("暂不支持在线预览此格式,请下载查看");
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) { closeFilePreview(); logout(); return; }
|
||||||
|
_showFallback("加载失败:" + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showImage(blob) {
|
||||||
|
const url = _trackBlobUrl(blob);
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body center";
|
||||||
|
body.innerHTML = "";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "preview-img";
|
||||||
|
img.src = url;
|
||||||
|
body.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showPdf(blob) {
|
||||||
|
const url = _trackBlobUrl(blob, "application/pdf");
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body";
|
||||||
|
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showText(text) {
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body";
|
||||||
|
body.innerHTML = "";
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.className = "preview-text";
|
||||||
|
pre.textContent = text;
|
||||||
|
body.appendChild(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showMarkdown(text) {
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body";
|
||||||
|
body.innerHTML = `<div class="md-render">${renderMd(text)}</div>`;
|
||||||
|
highlightIn(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _showDocx(blob) {
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body center";
|
||||||
|
body.innerHTML = `<div class="ph">解析 docx 中…</div>`;
|
||||||
|
try {
|
||||||
|
await loadScript("/static/vendor/jszip.min.js");
|
||||||
|
await loadScript("/static/vendor/docx-preview.min.js");
|
||||||
|
} catch (e) {
|
||||||
|
_showFallback("docx 解析库加载失败:" + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.docx || !window.docx.renderAsync) {
|
||||||
|
_showFallback("docx 解析库不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.className = "body";
|
||||||
|
body.innerHTML = `<div class="docx-host"></div>`;
|
||||||
|
try {
|
||||||
|
await window.docx.renderAsync(blob, body.querySelector(".docx-host"), null, {
|
||||||
|
inWrapper: false,
|
||||||
|
ignoreLastRenderedPageBreak: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
_showFallback("docx 渲染失败:" + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _showXlsx(blob) {
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body center";
|
||||||
|
body.innerHTML = `<div class="ph">解析表格中…</div>`;
|
||||||
|
try {
|
||||||
|
await loadScript("/static/vendor/xlsx.full.min.js");
|
||||||
|
} catch (e) {
|
||||||
|
_showFallback("xlsx 解析库加载失败:" + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.XLSX || !window.XLSX.read) {
|
||||||
|
_showFallback("xlsx 解析库不可用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let wb;
|
||||||
|
try {
|
||||||
|
const ab = await blob.arrayBuffer();
|
||||||
|
wb = window.XLSX.read(ab, { type: "array" });
|
||||||
|
} catch (e) {
|
||||||
|
_showFallback("xlsx 解析失败:" + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = wb.SheetNames || [];
|
||||||
|
if (!names.length) { _showFallback("xlsx 内无 sheet"); return; }
|
||||||
|
body.className = "body";
|
||||||
|
const tabsHtml = names.map((n, i) =>
|
||||||
|
`<button class="small xlsx-tab${i===0?" active":""}" data-i="${i}">${escapeHtml(n)}</button>`
|
||||||
|
).join("");
|
||||||
|
body.innerHTML = `<div class="xlsx-tabs">${tabsHtml}</div><div class="xlsx-sheet" id="fp-xlsx-sheet"></div>`;
|
||||||
|
const render = (i) => {
|
||||||
|
const ws = wb.Sheets[names[i]];
|
||||||
|
$("fp-xlsx-sheet").innerHTML = window.XLSX.utils.sheet_to_html(ws);
|
||||||
|
};
|
||||||
|
body.querySelectorAll(".xlsx-tab").forEach((btn) => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
body.querySelectorAll(".xlsx-tab").forEach((b) => b.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
render(parseInt(btn.dataset.i));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
render(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showFallback(msg) {
|
||||||
|
const body = $("fp-body");
|
||||||
|
body.className = "body center";
|
||||||
|
body.innerHTML = "";
|
||||||
|
const ph = document.createElement("div");
|
||||||
|
ph.className = "ph";
|
||||||
|
ph.textContent = msg;
|
||||||
|
const br = document.createElement("br");
|
||||||
|
const dl = document.createElement("button");
|
||||||
|
dl.className = "primary";
|
||||||
|
dl.textContent = "下载原文件";
|
||||||
|
dl.style.marginTop = "12px";
|
||||||
|
dl.onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
|
||||||
|
ph.appendChild(document.createElement("br"));
|
||||||
|
ph.appendChild(br);
|
||||||
|
ph.appendChild(dl);
|
||||||
|
body.appendChild(ph);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFilePreview() {
|
||||||
|
$("file-preview-modal").classList.remove("show");
|
||||||
|
$("fp-body").innerHTML = "";
|
||||||
|
_flushBlobUrls();
|
||||||
|
_fpCurrentRel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("fp-close").onclick = closeFilePreview;
|
||||||
|
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
|
||||||
|
$("file-preview-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "file-preview-modal") closeFilePreview();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) {
|
||||||
|
closeFilePreview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function uploadSelected() {
|
async function uploadSelected() {
|
||||||
const inp = $("upload-input");
|
const inp = $("upload-input");
|
||||||
const files = Array.from(inp.files || []);
|
const files = Array.from(inp.files || []);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue