435 lines
16 KiB
JavaScript
435 lines
16 KiB
JavaScript
// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
|
// 拖拽上传 overlay + 上传(XHR 带进度)+ 上传状态条。
|
|
// 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件触发刷新)/ closeSrcPicker(main Esc 关栈)
|
|
// / uploadFiles(聊天区粘贴或拖拽文件复用)。其余入口在本模块顶层自绑。
|
|
// 反向依赖:openFilePreview(preview)、logout(auth)、以及 main 的 glue
|
|
// downloadFile / selectTask / loadTaskList / loadFolderSuggestions(后续随 media/tasks/newtask 模块化再迁)。
|
|
import { state } from "./state.js";
|
|
import { $, showMenu } from "./dom.js";
|
|
import { api } from "./api.js";
|
|
import { escapeHtml, humanSize } from "./format.js";
|
|
import { openFilePreview } from "./preview.js";
|
|
import { logout } from "./auth.js";
|
|
import { downloadFile } from "./media.js";
|
|
import { selectTask, loadTaskList } from "./main.js";
|
|
import { loadFolderSuggestions } from "./newtask.js";
|
|
|
|
// ───── files(user-rooted,不绑 task) ─────
|
|
$("btn-refresh-files").onclick = () => loadFiles();
|
|
$("btn-upload").onclick = () => $("upload-input").click();
|
|
$("upload-input").addEventListener("change", uploadSelected);
|
|
|
|
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
|
|
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
|
|
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
|
|
const srcPicker = { path: "", selected: new Set() };
|
|
|
|
async function openSrcPicker() {
|
|
srcPicker.path = "";
|
|
srcPicker.selected.clear();
|
|
const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
|
|
$("sp-dest").textContent = destLabel;
|
|
$("sp-dest").title = destLabel;
|
|
syncSrcCount();
|
|
$("src-picker-modal").classList.add("show");
|
|
await loadSrcPicker();
|
|
}
|
|
|
|
export function closeSrcPicker() {
|
|
$("src-picker-modal").classList.remove("show");
|
|
srcPicker.path = "";
|
|
srcPicker.selected.clear();
|
|
}
|
|
|
|
async function loadSrcPicker() {
|
|
try {
|
|
const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : "";
|
|
const data = await api("GET", "/v1/files" + qs);
|
|
renderSrcPicker(data);
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
$("sp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderSrcPicker(data) {
|
|
const cr = data.crumbs.map((c, i) => {
|
|
const label = i === 0 ? "我的" : c.label;
|
|
const isLast = i === data.crumbs.length - 1;
|
|
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
|
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
|
}).join(" ");
|
|
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
|
$("sp-crumbs").querySelectorAll("a").forEach((a) => {
|
|
a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
|
|
});
|
|
const entries = data.entries || [];
|
|
if (!data.exists) {
|
|
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
|
return;
|
|
}
|
|
if (!entries.length) {
|
|
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
|
return;
|
|
}
|
|
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
|
|
const destPath = state.filesPath || "";
|
|
const sameAsDest = srcPicker.path === destPath;
|
|
$("sp-list").innerHTML = entries.map((e) => {
|
|
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
|
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
|
|
const disabled = sameAsDest ? " disabled" : "";
|
|
const fullTitle = e.rel || e.name;
|
|
return `
|
|
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
|
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
|
|
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span>
|
|
<span class="sp-size">${humanSize(e.size)}</span>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
$("sp-list").querySelectorAll(".sp-name").forEach((el) => {
|
|
el.onclick = () => {
|
|
if (el.dataset.isdir === "true") {
|
|
srcPicker.path = el.dataset.rel;
|
|
loadSrcPicker();
|
|
}
|
|
};
|
|
});
|
|
$("sp-list").querySelectorAll(".sp-cb").forEach((cb) => {
|
|
cb.onchange = () => {
|
|
const rel = cb.dataset.rel;
|
|
if (cb.checked) srcPicker.selected.add(rel);
|
|
else srcPicker.selected.delete(rel);
|
|
syncSrcCount();
|
|
};
|
|
});
|
|
}
|
|
|
|
function syncSrcCount() {
|
|
const n = srcPicker.selected.size;
|
|
$("sp-count").textContent = String(n);
|
|
$("sp-copy").disabled = n === 0;
|
|
$("sp-move").disabled = n === 0;
|
|
}
|
|
|
|
async function doSrcTransfer(mode) {
|
|
const sources = [...srcPicker.selected];
|
|
if (!sources.length) return;
|
|
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
|
|
const verb = mode === "copy" ? "复制" : "移动";
|
|
try {
|
|
await api("POST", endpoint, {
|
|
paths: sources,
|
|
dest_dir: state.filesPath || "",
|
|
});
|
|
closeSrcPicker();
|
|
await loadFiles();
|
|
await loadFolderSuggestions();
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
alert(verb + "失败:" + e.message);
|
|
}
|
|
}
|
|
|
|
$("btn-src-pick").onclick = openSrcPicker;
|
|
$("sp-cancel").onclick = closeSrcPicker;
|
|
$("sp-copy").onclick = () => doSrcTransfer("copy");
|
|
$("sp-move").onclick = () => doSrcTransfer("move");
|
|
$("src-picker-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "src-picker-modal") closeSrcPicker();
|
|
});
|
|
|
|
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
|
|
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
|
|
let _dragDepth = 0;
|
|
function _hasFiles(ev) {
|
|
const t = ev.dataTransfer;
|
|
if (!t) return false;
|
|
if (t.types && [...t.types].includes("Files")) return true;
|
|
return false;
|
|
}
|
|
$("pane-right").addEventListener("dragenter", (e) => {
|
|
if (!_hasFiles(e)) return;
|
|
e.preventDefault();
|
|
_dragDepth++;
|
|
$("file-droparea").classList.add("show");
|
|
});
|
|
$("pane-right").addEventListener("dragover", (e) => {
|
|
if (!_hasFiles(e)) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
});
|
|
$("pane-right").addEventListener("dragleave", (e) => {
|
|
if (!_hasFiles(e)) return;
|
|
_dragDepth = Math.max(0, _dragDepth - 1);
|
|
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
|
|
});
|
|
$("pane-right").addEventListener("drop", async (e) => {
|
|
if (!_hasFiles(e)) return;
|
|
e.preventDefault();
|
|
_dragDepth = 0;
|
|
$("file-droparea").classList.remove("show");
|
|
const files = Array.from(e.dataTransfer.files || []);
|
|
if (!files.length) return;
|
|
await uploadFilesWithPaneStatus(files);
|
|
});
|
|
|
|
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
|
|
let _filesRefreshTimer = null;
|
|
export function scheduleFilesRefresh() {
|
|
clearTimeout(_filesRefreshTimer);
|
|
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
|
|
}
|
|
|
|
export async function loadFiles() {
|
|
try {
|
|
const qs = state.filesPath ? "?path=" + encodeURIComponent(state.filesPath) : "";
|
|
const data = await api("GET", "/v1/files" + qs);
|
|
renderFiles(data);
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
|
|
$("file-list").innerHTML = "";
|
|
}
|
|
}
|
|
|
|
// 切换文件面板浏览路径
|
|
function navFiles(newPath) {
|
|
state.filesPath = newPath || "";
|
|
loadFiles();
|
|
}
|
|
|
|
function renderFiles(data) {
|
|
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
|
|
const segs = (data.current || "").split("/").filter(Boolean);
|
|
const projName = segs[0] || "";
|
|
// 名称过长时显示前 11 字符 + …,完整名留 title 提示(避免顶栏挤压"文件"换行)
|
|
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
|
|
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
|
|
$("files-proj").title = projName || data.root || "";
|
|
// crumbs root 标"我的"(user_root),更直观;其余原样
|
|
const cr = data.crumbs.map((c, i) => {
|
|
const label = i === 0 ? "我的" : c.label;
|
|
const isLast = i === data.crumbs.length - 1;
|
|
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
|
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
|
}).join(" ");
|
|
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
|
$("file-crumbs").querySelectorAll("a").forEach((a) => {
|
|
a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); };
|
|
});
|
|
if (!data.exists) {
|
|
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
|
state.entriesByRel = {};
|
|
return;
|
|
}
|
|
if (!data.entries.length) {
|
|
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
|
state.entriesByRel = {};
|
|
return;
|
|
}
|
|
state.entriesByRel = {};
|
|
for (const e of data.entries) state.entriesByRel[e.rel] = e;
|
|
$("file-list").innerHTML = data.entries.map((e) => {
|
|
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
|
const fullTitle = e.rel || e.name;
|
|
return `
|
|
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
|
|
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">
|
|
${escapeHtml(e.name)}
|
|
</span>
|
|
<span class="size">${humanSize(e.size)}</span>
|
|
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作">⋯</button>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
$("file-list").querySelectorAll(".name").forEach((el) => {
|
|
el.style.cursor = "pointer";
|
|
el.onclick = () => {
|
|
const rel = el.dataset.rel;
|
|
if (el.dataset.isdir === "true") { navFiles(rel); }
|
|
else { openFilePreview(rel); }
|
|
};
|
|
});
|
|
$("file-list").querySelectorAll(".file-menu").forEach((btn) => {
|
|
btn.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
const e = state.entriesByRel[btn.dataset.rel];
|
|
if (!e) return;
|
|
showMenu(btn, fileMenuItems(e));
|
|
};
|
|
});
|
|
}
|
|
|
|
function fileMenuItems(e) {
|
|
const items = [
|
|
{ act: "rename", label: "重命名", cls: "act-rename",
|
|
onclick: () => renameFile(e.rel, e.name, e.is_dir) },
|
|
];
|
|
if (!e.is_dir) {
|
|
items.push({ act: "download", label: "下载", cls: "act-download",
|
|
onclick: () => downloadFile(e.rel) });
|
|
}
|
|
items.push({ act: "delete", label: "删除", cls: "act-delete",
|
|
onclick: () => deleteFile(e.rel, e.name, e.is_dir) });
|
|
return items;
|
|
}
|
|
|
|
async function deleteFile(rel, name, isDir) {
|
|
let recursive = false;
|
|
if (!isDir) {
|
|
if (!confirm(`确认删除文件 "${name}"?`)) return;
|
|
} else {
|
|
// 探一下目录内容:空目录走普通 rmdir;非空才递归,二次确认显示条目数
|
|
let entries;
|
|
try {
|
|
const data = await api("GET", "/v1/files?path=" + encodeURIComponent(rel));
|
|
entries = data.entries || [];
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
alert("读目录失败:" + e.message);
|
|
return;
|
|
}
|
|
if (entries.length === 0) {
|
|
if (!confirm(`确认删除空目录 "${name}"?`)) return;
|
|
} else {
|
|
const hasSub = entries.some((x) => x.is_dir);
|
|
const tip = hasSub ? "(含子目录)" : "";
|
|
if (!confirm(
|
|
`目录 "${name}" 含 ${entries.length} 项${tip},` +
|
|
`将递归删除全部内容,不可恢复。\n` +
|
|
`(若为顶层目录且仍被 task 引用,需先删 task)\n确认?`
|
|
)) return;
|
|
recursive = true;
|
|
}
|
|
}
|
|
try {
|
|
await api("POST", "/v1/files/delete", { path: rel, recursive });
|
|
await loadFiles();
|
|
// 删的若是顶层目录,folders 列表也得跟着变;子级删除走这里也无副作用
|
|
await loadFolderSuggestions();
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
alert("删除失败:" + e.message);
|
|
}
|
|
}
|
|
|
|
async function renameFile(rel, name, isDir) {
|
|
const what = isDir ? "目录" : "文件";
|
|
const newName = prompt(`将${what} "${name}" 重命名为:`, name);
|
|
if (newName == null) return;
|
|
const trimmed = newName.trim();
|
|
if (!trimmed || trimmed === name) return;
|
|
try {
|
|
const res = await api("POST", "/v1/files/rename", { path: rel, new_name: trimmed });
|
|
// 面板若停在被改名的子树里,做前缀替换继续停留在等价位置
|
|
if (state.filesPath === rel) {
|
|
state.filesPath = res.new;
|
|
} else if (state.filesPath && state.filesPath.startsWith(rel + "/")) {
|
|
state.filesPath = res.new + state.filesPath.slice(rel.length);
|
|
}
|
|
await loadFolderSuggestions();
|
|
// 顶层目录改名 → tasks_updated>0,任务列表 / 当前 task 头里的 working_dir 都得刷
|
|
if (res && res.tasks_updated > 0) {
|
|
await loadTaskList();
|
|
if (state.taskId) { await selectTask(state.taskId); return; }
|
|
}
|
|
await loadFiles();
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return; }
|
|
alert("重命名失败:" + e.message);
|
|
}
|
|
}
|
|
|
|
function uploadTotalBytes(files) {
|
|
return (files || []).reduce((sum, f) => sum + (f.size || 0), 0);
|
|
}
|
|
function uploadFilesLabel(files) {
|
|
if (!files || !files.length) return "";
|
|
return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`;
|
|
}
|
|
function formatUploadProgress(files, loaded, total) {
|
|
const denom = total || uploadTotalBytes(files);
|
|
const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0;
|
|
const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : "";
|
|
return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`;
|
|
}
|
|
function setPaneUploadStatus(files, loaded, total) {
|
|
const el = $("file-upload-status");
|
|
const denom = total || uploadTotalBytes(files);
|
|
const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0;
|
|
el.classList.add("show");
|
|
el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}<div class="bar"><span style="width:${pct}%"></span></div>`;
|
|
}
|
|
function finishPaneUploadStatus(ok, files) {
|
|
const el = $("file-upload-status");
|
|
el.classList.add("show");
|
|
el.innerHTML = ok
|
|
? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}<div class="bar"><span style="width:100%"></span></div>`
|
|
: `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`;
|
|
setTimeout(() => {
|
|
if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) {
|
|
el.classList.remove("show");
|
|
el.innerHTML = "";
|
|
}
|
|
}, 3500);
|
|
}
|
|
async function uploadFilesWithPaneStatus(files) {
|
|
if (!files || !files.length) return null;
|
|
setPaneUploadStatus(files, 0, uploadTotalBytes(files));
|
|
const saved = await uploadFiles(files, {
|
|
onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total),
|
|
});
|
|
finishPaneUploadStatus(!!(saved && saved.length), files);
|
|
return saved;
|
|
}
|
|
|
|
export async function uploadFiles(files, opts = {}) {
|
|
if (!files || !files.length) return null;
|
|
const fd = new FormData();
|
|
fd.append("path", state.filesPath || "");
|
|
for (const f of files) fd.append("files", f);
|
|
try {
|
|
const data = await new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/v1/files/upload");
|
|
xhr.setRequestHeader("Authorization", "Bearer " + state.token);
|
|
xhr.upload.onprogress = (ev) => {
|
|
if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total);
|
|
};
|
|
xhr.onerror = () => reject(new Error("网络错误,上传失败"));
|
|
xhr.onload = () => {
|
|
let payload = {};
|
|
try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; }
|
|
catch (_) { payload = {}; }
|
|
if (xhr.status < 200 || xhr.status >= 300) {
|
|
const err = new Error(payload.detail || (xhr.status + " 上传失败"));
|
|
err.status = xhr.status;
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(payload);
|
|
};
|
|
if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files));
|
|
xhr.send(fd);
|
|
});
|
|
await loadFiles();
|
|
return data.saved || [];
|
|
} catch (e) {
|
|
if (e.status === 401) { logout(); return null; }
|
|
alert("上传失败:" + e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function uploadSelected() {
|
|
const inp = $("upload-input");
|
|
const files = Array.from(inp.files || []);
|
|
try {
|
|
await uploadFilesWithPaneStatus(files);
|
|
} finally {
|
|
inp.value = ""; // 允许重新选同名文件
|
|
}
|
|
}
|