384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
|
// ====== 配置 ======
|
|||
|
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();
|
|||
|
}
|
|||
|
});
|