// ====== 配置 ====== const ENDPOINT = "http://localhost:8095/api/v1/rag/file/upload"; const ALLOWED_EXTS = [".pdf", ".csv", ".txt", ".md", ".sql", ".java"]; const MAX_SCAN_DEPTH = 20; // 防止极端深层目录卡住(可自行调整) // ====== 工具函数 ====== const $ = (sel) => document.querySelector(sel); function formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } function extOfName(name) { return (name.match(/\.[^.]+$/) || [""])[0].toLowerCase(); } function isAllowedName(name) { return ALLOWED_EXTS.includes(extOfName(name)); } // 用于渲染与去重时的“相对路径”,优先使用浏览器提供的相对路径 function displayPath(file) { return file.webkitRelativePath || file._relativePath || file.name; } // 以路径+大小+mtime 作为去重 key(避免不同子目录下同名文件冲突) function uniqueKey(file) { return `${displayPath(file)}__${file.size}__${file.lastModified}`; } // 将 FileList + 新增 files 合并为去重后的 DataTransfer.files function mergeFiles(existingList, newFiles) { const map = new Map(); Array.from(existingList || []).forEach((f) => map.set(uniqueKey(f), f)); Array.from(newFiles || []).forEach((f) => map.set(uniqueKey(f), f)); const dt = new DataTransfer(); for (const f of map.values()) dt.items.add(f); return dt.files; } // ====== DOM 引用 ====== const form = $("#uploadForm"); const dropZone = $("#dropZone"); const fileInput = $("#fileInput"); const dirInput = $("#dirInput"); const chooseDirBtn = $("#chooseDirBtn"); const fileListWrap = $("#fileList"); const fileListUl = $("#fileList ul"); const clearAllBtn = $("#clearAllBtn"); const loadingOverlay = $("#loadingOverlay"); const submitBtn = $("#submitBtn"); const progressWrap = $("#progressWrap"); const progressBar = $("#progressBar"); const progressText = $("#progressText"); // ====== 阻止默认拖拽打开新窗口(关键) ====== ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { document.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }); }); // ====== 支持:点击选择文件 / 选择目录 ====== dropZone.addEventListener("click", () => fileInput.click()); dropZone.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); } }); chooseDirBtn?.addEventListener("click", () => dirInput?.click()); // ====== 拖拽区交互状态 ====== dropZone.addEventListener("dragenter", () => dropZone.classList.add("drag-active")); dropZone.addEventListener("dragover", () => dropZone.classList.add("drag-active")); dropZone.addEventListener("dragleave", (e) => { if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove("drag-active"); }); // ====== 拖拽接收(支持文件夹递归) ====== dropZone.addEventListener("drop", async (e) => { dropZone.classList.remove("drag-active"); const items = e.dataTransfer?.items; const plainFiles = e.dataTransfer?.files; let collected = []; try { if (items && items.length) { collected = await collectFromDataTransferItems(items); } else if (plainFiles && plainFiles.length) { collected = Array.from(plainFiles); } } catch (err) { console.warn("解析拖拽内容失败,回退到 files:", err); collected = Array.from(plainFiles || []); } const { accepted, rejectedCount } = filterAllowed(collected); if (rejectedCount > 0) { alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`); } if (accepted.length) { fileInput.files = mergeFiles(fileInput.files, accepted); renderFileList(); } }); // ====== 目录选择(webkitdirectory) ====== dirInput?.addEventListener("change", () => { const all = Array.from(dirInput.files || []); // 目录选择会包含 webkitRelativePath const { accepted, rejectedCount } = filterAllowed(all, (f) => f.webkitRelativePath || f.name); if (rejectedCount > 0) { alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`); } if (accepted.length) { fileInput.files = mergeFiles(fileInput.files, accepted); renderFileList(); } // 允许连续两次选择同一文件夹 dirInput.value = ""; }); // ====== 文件选择变化(普通文件选择) ====== fileInput.addEventListener("change", renderFileList); clearAllBtn.addEventListener("click", () => { fileInput.value = ""; renderFileList(); }); // ====== 渲染文件列表(显示相对路径) ====== function renderFileList() { const files = Array.from(fileInput.files || []); fileListUl.innerHTML = ""; if (files.length === 0) { fileListWrap.classList.add("hidden"); return; } fileListWrap.classList.remove("hidden"); files.forEach((file, idx) => { const li = document.createElement("li"); const leftWrap = document.createElement("div"); const nameEl = document.createElement("div"); const metaEl = document.createElement("div"); nameEl.className = "file-name"; metaEl.className = "file-meta"; const rel = displayPath(file); nameEl.textContent = rel; metaEl.textContent = `${file.type || "未知类型"} · ${formatBytes(file.size)}`; leftWrap.appendChild(nameEl); leftWrap.appendChild(metaEl); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "remove-btn text-sm"; removeBtn.textContent = "删除"; removeBtn.addEventListener("click", () => removeFile(idx)); li.appendChild(leftWrap); li.appendChild(removeBtn); fileListUl.appendChild(li); }); } // ====== 删除单个文件 ====== function removeFile(index) { const files = Array.from(fileInput.files); files.splice(index, 1); const dt = new DataTransfer(); files.forEach((f) => dt.items.add(f)); fileInput.files = dt.files; renderFileList(); } // ====== 过滤后缀白名单 ====== function filterAllowed(files, pathGetter) { let rejectedCount = 0; const accepted = []; for (const f of files) { const p = pathGetter ? pathGetter(f) : displayPath(f) || f.name; if (isAllowedName(p)) { accepted.push(f); } else { rejectedCount++; } } return { accepted, rejectedCount }; } // ====== 拖拽目录解析(两套方案 + 回退) ====== async function collectFromDataTransferItems(items) { const supportsFSH = typeof DataTransferItem !== "undefined" && DataTransferItem.prototype && "getAsFileSystemHandle" in DataTransferItem.prototype; const collected = []; // 方案 1:File System Access API(Chromium 新方案) if (supportsFSH) { const jobs = []; for (const item of items) { jobs.push(handleItemWithFSH(item, collected)); } await Promise.all(jobs); return collected; } // 方案 2:webkitGetAsEntry(Safari/老版 Chromium) const supportsWebkitEntry = items[0] && typeof items[0].webkitGetAsEntry === "function"; if (supportsWebkitEntry) { const jobs = []; for (const item of items) { const entry = item.webkitGetAsEntry && item.webkitGetAsEntry(); if (!entry) continue; jobs.push(walkWebkitEntry(entry, collected, "", 0)); } await Promise.all(jobs); return collected; } // 回退:只能拿到平铺的 files,无法识别目录 for (const item of items) { const f = item.getAsFile && item.getAsFile(); if (f) collected.push(f); } return collected; } // ------ 方案 1:FS Access API ------ async function handleItemWithFSH(item, out) { const handle = await item.getAsFileSystemHandle(); if (!handle) return; if (handle.kind === "file") { const file = await handle.getFile(); // 无法直接获取相对路径,使用文件名作为相对路径 file._relativePath = file.name; out.push(file); } else if (handle.kind === "directory") { await walkDirectoryHandle(handle, out, `${handle.name}/`, 0); } } async function walkDirectoryHandle(dirHandle, out, prefix = "", depth = 0) { if (depth > MAX_SCAN_DEPTH) return; for await (const [name, handle] of dirHandle.entries()) { if (handle.kind === "file") { const file = await handle.getFile(); file._relativePath = `${prefix}${name}`; out.push(file); } else if (handle.kind === "directory") { await walkDirectoryHandle(handle, out, `${prefix}${name}/`, depth + 1); } } } // ------ 方案 2:webkitGetAsEntry ------ function readEntriesAsync(dirReader) { return new Promise((resolve, reject) => { dirReader.readEntries(resolve, reject); }); } async function walkWebkitEntry(entry, out, prefix = "", depth = 0) { if (depth > MAX_SCAN_DEPTH) return; if (entry.isFile) { await new Promise((resolve) => { entry.file((file) => { file._relativePath = `${prefix}${entry.name}`; resolve(out.push(file)); }, () => resolve()); }); } else if (entry.isDirectory) { const dirReader = entry.createReader(); let entries = []; do { entries = await readEntriesAsync(dirReader); for (const ent of entries) { await walkWebkitEntry(ent, out, `${prefix}${entry.name}/`, depth + 1); } } while (entries.length > 0); } } // ====== 表单提交 ====== form.addEventListener("submit", async (e) => { e.preventDefault(); const title = $("#title").value.trim(); const files = Array.from(fileInput.files || []); if (!title) { alert("请先填写知识库名称"); return; } if (files.length === 0) { alert("请先选择至少一个文件或文件夹"); return; } const fd = new FormData(); fd.append("ragTag", title); // 将文件按顺序追加 // 可选:若你想把“相对路径”也传给后端,请同时 append 一个 filePath(顺序与 file 对应) files.forEach((f) => { fd.append("file", f); fd.append("filePath", displayPath(f)); // 需要后端额外接收 }); // UI 状态 loadingOverlay.classList.remove("hidden"); progressWrap.classList.remove("hidden"); submitBtn.disabled = true; try { const res = await axios.post(ENDPOINT, fd, { onUploadProgress: (evt) => { if (!evt.total) return; const pct = Math.round((evt.loaded / evt.total) * 100); progressBar.style.width = `${pct}%`; progressText.textContent = `${pct}%`; }, }); if (res?.data?.code === "0000") { // === 通知 index.html 刷新 Rag 列表(两种方式都发,保证健壮性) === const ragTagJustCreated = title; // 你提交的 ragTag 标题 try { // 1) postMessage:同源且 opener 存在时生效 if (window.opener && !window.opener.closed) { window.opener.postMessage( { type: "RAG_LIST_REFRESH", ragTag: ragTagJustCreated }, window.location.origin ); } } catch (_) {} try { // 2) BroadcastChannel:同源标签页广播 if ("BroadcastChannel" in window) { const bc = new BroadcastChannel("rag-updates"); bc.postMessage({ type: "rag:updated", ragTag: ragTagJustCreated }); bc.close(); } } catch (_) {} alert("上传成功,窗口即将关闭"); setTimeout(() => window.close(), 500); } else { throw new Error(res?.data?.info || "上传失败"); } } catch (err) { alert(err?.message || "上传过程中出现错误"); } finally { loadingOverlay.classList.add("hidden"); submitBtn.disabled = false; progressWrap.classList.add("hidden"); progressBar.style.width = "0%"; progressText.textContent = "0%"; renderFileList(); } });