384 lines
12 KiB
JavaScript
Raw Normal View History

// ====== 配置 ======
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 = [];
// 方案 1File System Access APIChromium 新方案)
if (supportsFSH) {
const jobs = [];
for (const item of items) {
jobs.push(handleItemWithFSH(item, collected));
}
await Promise.all(jobs);
return collected;
}
// 方案 2webkitGetAsEntrySafari/老版 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;
}
// ------ 方案 1FS 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);
}
}
}
// ------ 方案 2webkitGetAsEntry ------
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();
}
});