384 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ====== 配置 ======
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();
}
});