refactor(dev): 前端模块化 Step 2 — 抽出 files.js(文件面板 + 选入 + 拖拽上传)
右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选 复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。 代码原分散在 main.js 两段非连续区(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 helper,中间夹着 media 段)→ 合并进 files.js(433 行)。 - 导出 loadFiles / scheduleFilesRefresh(SSE 文件事件刷新)/ closeSrcPicker(main Esc 关栈)/ uploadFiles(聊天区粘贴/拖拽复用); 其余入口模块顶层自绑。 - 反向 import openFilePreview(preview)、logout(auth)、main glue downloadFile / selectTask / loadTaskList / loadFolderSuggestions (后三个加 export,后续随 tasks/newtask 模块化再迁)。 - 依赖分析用"段内被调标识符 − 段内定义 − 叶子/全局"全量提取,补回固定 清单漏掉的 loadFolderSuggestions / loadTaskList。 main.js 删至 1619 行。node --check 双过、main 残留 files 私有符号清零、 files 无未导入 glue、静态测试 2 过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9394e065f1
commit
71bac870ed
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-06-06(前端模块化 Step 2:抽出 layout.js / auth.js / preview.js)
|
最后更新:2026-06-06(前端模块化 Step 2:抽出 layout / auth / preview / files.js)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-06-06
|
### 2026-06-06
|
||||||
|
|
||||||
|
- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(1133–1459 文件列表/选入/拖拽 + 1697–1786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 − 段内定义 − 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。
|
||||||
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。
|
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 1687–2048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。
|
||||||
- **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。
|
- **前端模块化 Step 2:抽出 `auth.js`(首个 main↔模块 ES 环)**:登录(邮箱密码 / UUID+PLATFORM_KEY 两 tab)+ 管理员加用户 + 改密码三节(原 main.js 21–227)→ `auth.js`(218 行)。各入口在模块顶层自绑 onclick,只导出 `logout`(供全局 20 处 401 处理)/`closeChpwModal`(供 main 的 Esc 统一关弹窗栈)。反向 import main 的 glue `enterApp`/`embedPostToParent`/`embedShowWaiting`(main 给这三个加 `export`)——**首次引入 main↔auth 循环依赖**:三者皆 hoisted 函数声明、模块实例化即就绪,且只在运行时(点击/401)调用,绝不在顶层求值时触发 → ES live binding 下安全;这是增量拆单体的标准形态,后续 features↔glue 环同理。main.js 删至 2397 行。`node --check` 双过、auth 私有符号在 main 清零、静态测试仍 2 过。**逻辑零改动**。
|
||||||
- **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html` 找 `formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。
|
- **前端模块化 Step 2(起):从 main.js 抽出 `layout.js`**:三栏布局(pane 折叠 rail + 拖拽 splitter + 手机单列视图)是 main.js 里唯一对其他功能节零出边的干净段,用它打样增量剥离。`layout.js`(121 行):import `$` + 4 个 `LS_*_COLLAPSED/WIDTH`,只导出 `mqPhone`/`setMobileView`(后者供 selectTask 在手机宽下选中任务自动切对话面板,是唯一跨模块调用);折叠/splitter/mobile-tab 的顶层事件绑定原样保留(ES module 默认 defer,import 时 DOM 已就绪)。main.js 删 114 行 → 2606 行,加 layout import 并清掉随之不再用的 4 个 `LS_*` import。**逻辑零改动,纯剪切+连线**;`node --check` 过、main 残留 layout 私有符号清零。**顺手修 Step 1 遗留测试失败**:`test_static_vendor` 第二用例原只 grep `dev.html` 找 `formatContextStats`/`context_original_chars`/`cache_hit_tokens`,模块化后这些搬进 `js/*.js` → 改为扫 `dev.html + js/*.js` 合并源,2 测试全过。后续按干净度继续剥(下一个 auth = login+加用户+改密码,会引入 main↔auth 的 ES 环,靠 live binding 解)。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,432 @@
|
||||||
|
// 文件面板:右栏列表浏览/导航/删除/重命名、刷新、"选入"弹框(跨目录勾选复制/移动)、
|
||||||
|
// 拖拽上传 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, selectTask, loadTaskList, loadFolderSuggestions } from "./main.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 = ""; // 允许重新选同名文件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { renderMd, highlightIn } from "./markdown.js";
|
||||||
import { mqPhone, setMobileView } from "./layout.js";
|
import { mqPhone, setMobileView } from "./layout.js";
|
||||||
import { logout, closeChpwModal } from "./auth.js";
|
import { logout, closeChpwModal } from "./auth.js";
|
||||||
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
|
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
|
||||||
|
import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js";
|
||||||
|
|
||||||
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
|
||||||
let _embedInitialTaskHandled = false;
|
let _embedInitialTaskHandled = false;
|
||||||
|
|
@ -98,7 +99,7 @@ async function loadModels() {
|
||||||
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
|
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
|
||||||
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
|
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
|
||||||
let _taskLoadSeq = 0;
|
let _taskLoadSeq = 0;
|
||||||
async function loadTaskList({ append = false } = {}) {
|
export async function loadTaskList({ append = false } = {}) {
|
||||||
if (append && (state.taskLoading || !state.taskHasMore)) return;
|
if (append && (state.taskLoading || !state.taskHasMore)) return;
|
||||||
const mySeq = ++_taskLoadSeq;
|
const mySeq = ++_taskLoadSeq;
|
||||||
const nextPage = append ? state.taskPage + 1 : 1;
|
const nextPage = append ? state.taskPage + 1 : 1;
|
||||||
|
|
@ -253,7 +254,7 @@ const _taskScrollObserver = new IntersectionObserver((entries) => {
|
||||||
_taskScrollObserver.observe($("task-sentinel"));
|
_taskScrollObserver.observe($("task-sentinel"));
|
||||||
|
|
||||||
// ───── select task ─────
|
// ───── select task ─────
|
||||||
async function selectTask(tid) {
|
export async function selectTask(tid) {
|
||||||
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
|
||||||
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
|
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
|
||||||
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
|
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
|
||||||
|
|
@ -1130,334 +1131,6 @@ function exportTask(tid) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
function scheduleFilesRefresh() {
|
|
||||||
clearTimeout(_filesRefreshTimer);
|
|
||||||
_filesRefreshTimer = setTimeout(() => { loadFiles(); }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
|
// ───── artifact 抽取(对话内 chip → 复用文件预览 modal) ─────
|
||||||
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(to_db_path),
|
// task.working_dir 在 DB 是 `workspace/users/<uuid>/<name>` 形态(to_db_path),
|
||||||
// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下
|
// 不是 user_root 相对。这里取最后一段作为 chip 抽取锚点 —— 等价于 user_root 下
|
||||||
|
|
@ -1694,96 +1367,6 @@ document.addEventListener("keydown", (e) => {
|
||||||
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = ""; // 允许重新选同名文件
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───── new task ─────
|
// ───── new task ─────
|
||||||
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
|
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
|
||||||
|
|
@ -1840,7 +1423,7 @@ $("nt-go").onclick = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
|
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
|
||||||
async function loadFolderSuggestions() {
|
export async function loadFolderSuggestions() {
|
||||||
try {
|
try {
|
||||||
const data = await api("GET", "/v1/folders");
|
const data = await api("GET", "/v1/folders");
|
||||||
state.folders = data.folders || [];
|
state.folders = data.folders || [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue