zcbot/web/static/js/files.js

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 = ""; // 允许重新选同名文件
}
}