diff --git a/PROGRESS.md b/PROGRESS.md index 7fcfdcd..e924081 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-20(working_dir 视为可重生 FS 视图:DELETE task 顺手清空孤儿目录;POST /v1/files/delete 顶层目录去掉 task 引用 409 闸;build_agent resume 也兜底 mkdir) +最后更新:2026-05-20(files API 加 copy/move 跨目录搬动:多选 + 弹框目录选择;move 闸住"顶层目录是某 task working_dir"维持 invariant) --- @@ -23,6 +23,7 @@ ### 2026-05-20 +- **`POST /v1/files/copy` + `/v1/files/move` 跨目录批量搬动 + dev SPA 多选 + 目录选择弹框**:用户要"在文件夹间复制/移动文件"。后端两路由共用 `_validate_transfer` 预检 helper(批量原子校验:源存在、不能等于/含 dest、不在 dest 直接子级、批内重名、target 已存 409,任一失败整批 abort,无 FS 副作用)。**move 加额外闸**:任一源是顶层目录且为某 task `working_dir` → 409(维持"working_dir = 顶层目录"invariant — 允许沉到子目录后,rename 顶层只更新当前层 task 的 DB-aware 逻辑会失效,代码复杂度翻倍才能扛住嵌套场景;用户想归档项目目录:先 DELETE task)。**copy 无此闸**,新副本无 task 关联。dev SPA:`.file-row` 加 `` 列 + 顶栏 `#files-selall` 三态(全/半/无),选中 ≥1 出黄底 toolbar(`复制到…` / `移动到…` / `取消选中`)。目录选择弹框 `#dir-picker-modal` 复用 `/v1/files` 浏览(只列目录,面包屑可点回上层,源目录灰禁),底部按钮文案随 mode 切。`state.selectedFiles` 切 task / 切 filesPath 时清,refresh 后剔除已不存在的 rel 保 view 一致。**部分失败**:沿用现有 rename / delete 单向语义,FS 中途失败抛 500 + 已成功项保留(`shutil.move/copytree` 失败几乎只在跨卷断连 / 磁盘满,workspace 同盘罕见)。**没动**:DESIGN(API 添加非语义变更)、RUN(无 CLI / env 变化)、DB schema。 - **working_dir 视为可重生 FS 视图**:DB 是 source of truth,FS 目录可独立删 / 用户手动 rmtree / 跨机器迁移丢失,**下次跑就自动 mkdir 重建**。三处改:① `DELETE /v1/tasks/{id}` 删完后若同 user 下再无 task 引用此 working_dir 且 FS 目录为空 → best-effort `rmdir` 清孤儿(非空 / 不存在 / 外部 --working-dir 静默跳过)。② `POST /v1/files/delete` 顶层目录去掉「有 task 引用就 409」闸,允许独立删空目录,task.working_dir 字段不动。③ `core/agent_builder.py::build_agent` 把 `working_dir_path.mkdir(parents=True, exist_ok=True)` 从 `if not resume:` 里挪出,resume 也兜底建目录(用户手删 FS 后再 send message 不会炸)。smoke `scripts/smoke_files_rename.py` 增 case 4 (200 + working_dir 不变) / case 8 (DELETE task 空目录自动清) / case 9 (非空目录保留),全 9 pass。**没动**:DB schema、rename 顶层目录的同步 UPDATE 逻辑(rename 是明确改名,和"删后重生"语义不同)、外部 --working-dir(DB 绝对串)的清理(避免误删用户外部项目)。 ### 2026-05-19 @@ -135,11 +136,11 @@ db/migrations/versions/ 0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE 0006_usage_events_v2_and_message_model.py 60 ← messages.model_profile 列 + usage_events v2 表(多态 units jsonb) web/__init__.py 5 -web/app.py ~890 ← /v1 JSON API + user_id 隔离 + run lock + task-level cancel +web/app.py ~1320 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files copy/move web/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引) web/sinks.py 21 -web/static/dev.html ~1700 ← D' dev SPA(3 栏 + 文件预览弹框 + 两 tab 登录) +web/static/dev.html ~2140 ← D' dev SPA(3 栏 + 文件预览弹框 + 两 tab 登录 + 多选 + 目录选择弹框) web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx(office 预览) ───────────────────────────────── Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB) diff --git a/web/app.py b/web/app.py index 07e8df3..70ac7ec 100644 --- a/web/app.py +++ b/web/app.py @@ -183,6 +183,67 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict], return entries, crumbs, exists +def _validate_transfer( + root: Path, paths: list[str], dest_dir: str, +) -> tuple[list[Path], Path]: + """预检批量 transfer:解析所有源 + 目标,任意一项不合法即整批 abort(无 FS 副作用)。 + + 返回 (sources, dest_dir_path)。不区分 copy / move(顶层 working_dir 闸由路由各自加)。 + 校验项: + - paths 非空;每个源在 user_root 内 + 存在;不能是 user_root 本身 + - dest_dir 存在 + 是目录(可以是 user_root) + - 源不能与 dest_dir 相同(自移动) + - dest_dir 不能在源的子树内(不能把 a/ 搬进 a/b/) + - 源不能已是 dest_dir 直接子项(原地移动,no-op) + - 同批次源 leaf 名不能重复(俩 a.txt 会撞 dest/a.txt) + - dest_dir/ 不能已存在(整批 409,不静默覆盖) + """ + if not paths: + raise HTTPException(400, "paths is empty") + dest = _safe_join(root, dest_dir) + if not dest.exists(): + raise HTTPException(404, f"dest_dir not found: {dest_dir!r}") + if not dest.is_dir(): + raise HTTPException(400, f"dest_dir is not a directory: {dest_dir!r}") + dest_r = dest.resolve() + + sources: list[Path] = [] + seen_names: set[str] = set() + for p in paths: + src = _safe_join(root, p) + if not src.exists(): + raise HTTPException(404, f"source not found: {p!r}") + src_r = src.resolve() + if src_r == root.resolve(): + raise HTTPException(400, "cannot transfer user_root") + if src_r == dest_r: + raise HTTPException(400, f"source equals dest_dir: {p!r}") + # dest 在 src 子树内 → 自嵌套 + try: + dest_r.relative_to(src_r) + raise HTTPException( + 400, f"cannot transfer {p!r} into its own subtree" + ) + except ValueError: + pass + # 已是 dest 直接子项 → no-op + if src.parent.resolve() == dest_r: + raise HTTPException( + 400, f"{p!r} already directly under dest_dir" + ) + name = src.name + if name in seen_names: + raise HTTPException(400, f"duplicate source leaf name in batch: {name!r}") + seen_names.add(name) + target = dest / name + if target.exists(): + raise HTTPException( + 409, f"target already exists: {_rel_to(root, target)!r}" + ) + sources.append(src) + return sources, dest + + # ─────────────────── BG run + SSE 帧格式 ─────────────────── def _run_agent_bg(task_id: UUID, user_id: UUID, user_message: str) -> None: @@ -284,6 +345,11 @@ class FileRenameRequest(BaseModel): new_name: str # 新的 leaf 名(不是路径),不含 / \ .. +class FileTransferRequest(BaseModel): + paths: list[str] # 多源,均相对 user_root + dest_dir: str = "" # 目标目录,相对 user_root,空 → user_root + + class LoginRequest(BaseModel): user_id: str platform_key: str @@ -1113,6 +1179,108 @@ def create_app() -> FastAPI: "tasks_updated": len(tids), } + @app.post("/v1/files/copy", tags=["files"]) + def copy_files( + body: FileTransferRequest, + user_id: UUID = Depends(require_user), + ): + """批量拷贝 paths → dest_dir/(目录递归)。 + + - 不覆盖(任一目标已存在 → 409) + - 不能拷到自己 / 自身子树 + - 顶层目录(可能是某 task 的 working_dir)可以拷:新副本无 task 关联,不动 DB + - 部分失败语义:任一 FS 拷贝抛错 → 抛 HTTPException,**前面已成功的拷贝保留** + (无 FS 事务可回滚;预检通过后通常不会失败,失败也是磁盘满 / 权限这类不能恢复的) + """ + import shutil + root = _load_user_root(user_id) + sources, dest = _validate_transfer(root, body.paths, body.dest_dir) + transferred: list[dict] = [] + for src in sources: + target = dest / src.name + try: + if src.is_dir(): + shutil.copytree(src, target) + else: + shutil.copy2(src, target) + except OSError as e: + raise HTTPException( + 500, + f"copy failed at {src.name!r}: {e} " + f"(已成功 {len(transferred)} 项,剩余未处理)", + ) + transferred.append({ + "old": _rel_to(root, src), + "new": _rel_to(root, target), + }) + return {"ok": True, "count": len(transferred), "transferred": transferred} + + @app.post("/v1/files/move", tags=["files"]) + def move_files( + body: FileTransferRequest, + user_id: UUID = Depends(require_user), + ): + """批量移动 paths → dest_dir/。 + + - 不覆盖、不自嵌套(同 /copy) + - **顶层目录是某 task 的 working_dir → 409**,维持 "working_dir = 顶层目录" invariant + (允许的话 task working_dir 沉到子目录会让 rename 顶层的 DB-aware 逻辑失效; + 用户想归档:先 DELETE task) + - 拷贝(`/copy`)无此限制,因为新副本无 task 关联 + - 部分失败:同 /copy,前面成功的不回滚(`shutil.move` 失败几乎只发生在 + 跨卷拷贝中断,workspace 都在同一磁盘下罕见) + """ + import shutil + root = _load_user_root(user_id) + sources, dest = _validate_transfer(root, body.paths, body.dest_dir) + + # 顶层目录-是-某 task.working_dir → 闸 + top_level_dir_srcs = [ + s for s in sources + if s.is_dir() and s.parent.resolve() == root.resolve() + ] + if top_level_dir_srcs: + db_forms = [to_db_path(s) for s in top_level_dir_srcs] + with session_scope() as s: + rows = s.execute( + select(Task.working_dir, func.count()) + .where( + Task.user_id == user_id, + Task.working_dir.in_(db_forms), + ) + .group_by(Task.working_dir) + ).all() + occupied = {wd: n for wd, n in rows} + if occupied: + # 反查 db_form → src.name 给报错文案 + form2name = {to_db_path(s): s.name for s in top_level_dir_srcs} + names = ", ".join( + f"{form2name[wd]!r}({n} 个 task)" + for wd, n in occupied.items() + ) + raise HTTPException( + 409, + f"以下顶层目录正被 task 引用,不能移动:{names};" + f"请先删 task,或改用复制", + ) + + transferred: list[dict] = [] + for src in sources: + target = dest / src.name + try: + shutil.move(str(src), str(target)) + except OSError as e: + raise HTTPException( + 500, + f"move failed at {src.name!r}: {e} " + f"(已成功 {len(transferred)} 项,剩余未处理)", + ) + transferred.append({ + "old": _rel_to(root, src), + "new": _rel_to(root, target), + }) + return {"ok": True, "count": len(transferred), "transferred": transferred} + # ───────────── Export ───────────── @app.get("/v1/tasks/{task_id}/export", tags=["export"]) diff --git a/web/static/dev.html b/web/static/dev.html index 1696c6a..4bbf9e4 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -53,25 +53,86 @@ /* ───── login overlay ───── */ #login { - position: fixed; inset: 0; background: rgba(0,0,0,0.4); - display: flex; align-items: center; justify-content: center; z-index: 100; + position: fixed; inset: 0; z-index: 100; + display: flex; align-items: center; justify-content: center; + background: + radial-gradient(1200px 600px at 15% 10%, rgba(192,57,43,0.10), transparent 60%), + radial-gradient(900px 500px at 85% 95%, rgba(52,73,94,0.10), transparent 60%), + linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%); } #login .card { - background: var(--panel); padding: 24px; border-radius: 6px; - width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.15); + background: var(--panel); + padding: 32px 36px 28px; + border-radius: 12px; + width: 380px; + box-shadow: 0 20px 60px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.04); + border: 1px solid rgba(0,0,0,.04); + animation: login-in .35s cubic-bezier(.2,.7,.2,1); + } + @keyframes login-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + #login .brand { + display: flex; align-items: center; gap: 10px; margin-bottom: 4px; + } + #login .brand .logo { + width: 32px; height: 32px; border-radius: 8px; + background: linear-gradient(135deg, var(--accent), #8e2a20); + color: #fff; font-weight: 700; font-size: 16px; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 10px rgba(192,57,43,.35); + } + #login .brand .name { font-size: 18px; font-weight: 600; letter-spacing: .2px; } + #login h2 { margin: 14px 0 18px; font-size: 15px; font-weight: 500; color: var(--muted); } + #login label { + display: block; margin-top: 12px; margin-bottom: 4px; + font-size: 12px; color: var(--muted); letter-spacing: .2px; + } + #login input { + padding: 9px 12px; border-radius: 6px; + border: 1px solid var(--border); background: #fafafa; + transition: border-color .15s, background .15s, box-shadow .15s; + } + #login input:hover { background: #fff; } + #login input:focus { + outline: none; background: #fff; border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(192,57,43,.12); + } + #login .err { + color: var(--accent); font-size: 12px; margin-top: 12px; + min-height: 1em; transition: opacity .15s; + } + #login .actions { margin-top: 18px; display: flex; gap: 8px; } + #login .actions .primary { + flex: 1; padding: 9px 14px; font-size: 14px; font-weight: 500; + border-radius: 6px; transition: filter .15s, transform .05s, box-shadow .15s; + box-shadow: 0 2px 6px rgba(192,57,43,.25); + } + #login .actions .primary:hover { box-shadow: 0 4px 12px rgba(192,57,43,.35); } + #login .actions .primary:active { transform: translateY(1px); } + #login .tabs { + display: flex; border-bottom: 1px solid var(--border); + margin: 0 0 14px; gap: 4px; } - #login h2 { margin: 0 0 16px; font-size: 18px; } - #login label { display: block; margin-top: 10px; font-size: 12px; color: var(--muted); } - #login .err { color: var(--accent); font-size: 12px; margin-top: 10px; min-height: 1em; } - #login .actions { margin-top: 14px; display: flex; gap: 8px; } - #login .tabs { display: flex; border-bottom: 1px solid var(--border); margin: 0 0 12px; } #login .tabs button { background: none; border: none; border-bottom: 2px solid transparent; - padding: 6px 12px; font-size: 13px; color: var(--muted); cursor: pointer; + padding: 8px 4px; margin-right: 16px; font-size: 13px; + color: var(--muted); cursor: pointer; + transition: color .15s, border-color .15s; } + #login .tabs button:hover { color: var(--text); background: none; } #login .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); } #login .tab-body { display: none; } - #login .tab-body.active { display: block; } + #login .tab-body.active { display: block; animation: tab-in .2s ease-out; } + @keyframes tab-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } + } + #login code { + background: var(--code-bg); padding: 1px 5px; border-radius: 3px; + font-size: 11.5px; + } /* ───── 3-pane layout ───── */ #app { display: none; height: 100vh; } @@ -252,10 +313,69 @@ align-items: center; gap: 8px; } .file-row:hover { background: var(--hover); } + .file-row.selected { background: var(--accent-soft); } + .file-row .row-cb { margin: 0; cursor: pointer; flex-shrink: 0; } .file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; } .ico-dir::before { content: "▸ "; color: var(--accent); } .ico-file::before { content: "· "; color: var(--muted); } + .selall-wrap { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; } + .selall-wrap input { margin: 0; cursor: pointer; } + /* 多选 toolbar:有选中时出,黄色背景区分 */ + #files-bulkbar { + display: none; padding: 6px 12px; gap: 6px; + border-bottom: 1px solid var(--border); background: #fff8d6; + align-items: center; font-size: 12px; + } + #files-bulkbar.show { display: flex; } + #files-bulkbar .count { color: #6a5; font-weight: 500; } + #files-bulkbar .spacer { flex: 1; } + #btn-bulk-copy { color: #1565c0; border-color: #aed6f1; } + #btn-bulk-copy:hover { background: #ebf5fb; } + #btn-bulk-move { color: #c77800; border-color: #f5cba7; } + #btn-bulk-move:hover { background: #fef5e7; } + + /* ───── dir picker modal(复制/移动目标选择) ───── */ + #dir-picker-modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.4); + display: none; align-items: center; justify-content: center; z-index: 95; + } + #dir-picker-modal.show { display: flex; } + #dir-picker-modal .card { + background: var(--panel); border-radius: 6px; + width: 520px; max-height: 80vh; + display: flex; flex-direction: column; + box-shadow: 0 8px 24px rgba(0,0,0,.15); + } + #dir-picker-modal h3 { + margin: 0; padding: 14px 18px; font-size: 16px; + border-bottom: 1px solid var(--border); + } + #dir-picker-modal .hint { + padding: 8px 18px; font-size: 12px; color: var(--muted); + border-bottom: 1px solid var(--border); + } + #dp-crumbs { + padding: 8px 14px; border-bottom: 1px solid var(--border); + font-size: 12px; background: #fafafa; + } + #dp-crumbs a { margin-right: 4px; } + #dp-list { + flex: 1; overflow: auto; + min-height: 220px; max-height: 50vh; + } + #dp-list .dir-row { + padding: 8px 14px; border-bottom: 1px solid var(--border); cursor: pointer; + font-size: 13px; + } + #dp-list .dir-row:hover { background: var(--hover); } + #dp-list .dir-row.disabled { color: var(--muted); cursor: not-allowed; } + #dp-list .dir-row.disabled:hover { background: transparent; } + #dp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; } + #dir-picker-modal .actions { + padding: 12px 18px; border-top: 1px solid var(--border); + display: flex; gap: 8px; justify-content: flex-end; + } /* ───── new task modal ───── */ #new-task-modal { @@ -349,7 +469,11 @@
-

zcbot 登录

+
+ +
zcbot
+
+

登录到控制台

@@ -463,15 +587,40 @@ 文件 +
加载中…
+
+ 已选 0 + + + + +
+ +
+
+

选择目标目录

+
浏览到目标目录,然后点击底部按钮确认
+
+
+
+ + +
+
+
+
@@ -526,6 +675,8 @@ const state = { taskId: null, taskMeta: null, filesPath: "", + // 文件多选状态:rel 路径集合;路径切换 / 复制移动成功后清空,refresh 保留(仅剔除已不存在的) + selectedFiles: new Set(), evtSrc: null, streaming: false, // 当前是否在流式中;true 时显示 stop 按钮 // task list 分页 + 筛选 @@ -917,6 +1068,7 @@ async function selectTask(tid) { // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; state.filesPath = wdName || ""; + state.selectedFiles.clear(); // 切 task 跨 view 重置选中 await loadFiles(); } catch (e) { if (e.status === 401) { logout(); return; } @@ -1289,6 +1441,131 @@ $("btn-upload").onclick = () => $("upload-input").click(); $("chat-upload").onclick = () => $("upload-input").click(); $("upload-input").addEventListener("change", uploadSelected); +// 顶栏全选 checkbox:三态 — 全未选 → 全选;部分/全选 → 清空 +$("files-selall").onchange = () => { + const rels = Object.keys(state.entriesByRel || {}); + if (!rels.length) return; + const allSelected = rels.every((r) => state.selectedFiles.has(r)); + if (allSelected) rels.forEach((r) => state.selectedFiles.delete(r)); + else rels.forEach((r) => state.selectedFiles.add(r)); + // 不需全量重渲染(行已在 DOM),逐行反映 selection + document.querySelectorAll("#file-list .file-row").forEach((row) => { + const sel = state.selectedFiles.has(row.dataset.rel); + row.classList.toggle("selected", sel); + const cb = row.querySelector(".row-cb"); + if (cb) cb.checked = sel; + }); + syncBulkBar(); +}; + +// 多选 toolbar +$("btn-bulk-copy").onclick = () => openDirPicker("copy"); +$("btn-bulk-move").onclick = () => openDirPicker("move"); +$("btn-bulk-clear").onclick = () => { + state.selectedFiles.clear(); + document.querySelectorAll("#file-list .file-row").forEach((row) => { + row.classList.remove("selected"); + const cb = row.querySelector(".row-cb"); + if (cb) cb.checked = false; + }); + syncBulkBar(); +}; + +// ───── 目录选择 modal(复制 / 移动 目标)───── +const dirPicker = { mode: null, sources: [], path: "" }; + +async function openDirPicker(mode) { + const sources = [...state.selectedFiles]; + if (!sources.length) return; + dirPicker.mode = mode; + dirPicker.sources = sources; + dirPicker.path = ""; + const isCopy = mode === "copy"; + $("dp-title").textContent = isCopy ? "复制到…" : "移动到…"; + $("dp-confirm").textContent = isCopy ? "在此处复制" : "在此处移动"; + $("dp-confirm").className = isCopy ? "primary" : "primary"; // 都用 primary;若想区分色再改 + $("dp-hint").textContent = + (isCopy ? "将 " : "将 ") + sources.length + " 项" + + (isCopy ? "复制" : "移动") + + "到下面浏览到的目录;源目录灰禁不可选(不能放到自己里)"; + $("dir-picker-modal").classList.add("show"); + await loadDirPicker(); +} + +function closeDirPicker() { + $("dir-picker-modal").classList.remove("show"); + dirPicker.mode = null; + dirPicker.sources = []; + dirPicker.path = ""; +} + +async function loadDirPicker() { + try { + const qs = dirPicker.path ? "?path=" + encodeURIComponent(dirPicker.path) : ""; + const data = await api("GET", "/v1/files" + qs); + renderDirPicker(data); + } catch (e) { + if (e.status === 401) { logout(); return; } + $("dp-list").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +function renderDirPicker(data) { + // crumbs(可点回上层) + const cr = data.crumbs.map((c, i) => { + const label = i === 0 ? "我的" : c.label; + const isLast = i === data.crumbs.length - 1; + if (isLast) return `${escapeHtml(label)}`; + return `${escapeHtml(label)} /`; + }).join(" "); + $("dp-crumbs").innerHTML = cr || `/`; + $("dp-crumbs").querySelectorAll("a").forEach((a) => { + a.onclick = (e) => { e.preventDefault(); dirPicker.path = a.dataset.rel; loadDirPicker(); }; + }); + // 只列目录;源 dir 本身灰禁(server 也会拒,UI 提前拦更友好) + const dirs = ((data.entries || []).filter((e) => e.is_dir)); + const srcSet = new Set(dirPicker.sources); + if (!dirs.length) { + $("dp-list").innerHTML = `
(无子目录)
`; + } else { + $("dp-list").innerHTML = dirs.map((e) => { + const isSrc = srcSet.has(e.rel); + const cls = "dir-row" + (isSrc ? " disabled" : ""); + const title = isSrc ? "源目录,不能放进自己" : "进入"; + return `
${escapeHtml(e.name)}
`; + }).join(""); + $("dp-list").querySelectorAll(".dir-row:not(.disabled)").forEach((row) => { + row.onclick = () => { dirPicker.path = row.dataset.rel; loadDirPicker(); }; + }); + } +} + +async function confirmDirPicker() { + if (!dirPicker.mode) return; + const endpoint = dirPicker.mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; + const verb = dirPicker.mode === "copy" ? "复制" : "移动"; + try { + const res = await api("POST", endpoint, { + paths: dirPicker.sources, + dest_dir: dirPicker.path, + }); + closeDirPicker(); + state.selectedFiles.clear(); + await loadFiles(); + await loadFolderSuggestions(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert(verb + "失败:" + e.message); + } +} + +$("dp-cancel").onclick = closeDirPicker; +$("dp-confirm").onclick = confirmDirPicker; +$("dir-picker-modal").addEventListener("click", (e) => { + // 点遮罩(card 外)关闭 + if (e.target.id === "dir-picker-modal") closeDirPicker(); +}); + // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API let _filesRefreshTimer = null; function scheduleFilesRefresh() { @@ -1308,6 +1585,13 @@ async function loadFiles() { } } +// 切换文件面板浏览路径:重置多选(选中是 per-view 概念),然后加载 +function navFiles(newPath) { + state.filesPath = newPath || ""; + state.selectedFiles.clear(); + loadFiles(); +} + function renderFiles(data) { // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 const segs = (data.current || "").split("/").filter(Boolean); @@ -1325,22 +1609,33 @@ function renderFiles(data) { }).join(" "); $("file-crumbs").innerHTML = cr || `/`; $("file-crumbs").querySelectorAll("a").forEach((a) => { - a.onclick = (e) => { e.preventDefault(); state.filesPath = a.dataset.rel; loadFiles(); }; + a.onclick = (e) => { e.preventDefault(); navFiles(a.dataset.rel); }; }); if (!data.exists) { $("file-list").innerHTML = `
(目录尚未创建)
`; + state.entriesByRel = {}; + syncBulkBar(); return; } if (!data.entries.length) { $("file-list").innerHTML = `
(空目录)
`; + state.entriesByRel = {}; + syncBulkBar(); return; } state.entriesByRel = {}; for (const e of data.entries) state.entriesByRel[e.rel] = e; + // refresh 后剔除已不在当前视图的选中项(避免幽灵选中跨视图残留) + for (const r of [...state.selectedFiles]) { + if (!(r in state.entriesByRel)) state.selectedFiles.delete(r); + } $("file-list").innerHTML = data.entries.map((e) => { const cls = e.is_dir ? "ico-dir" : "ico-file"; + const sel = state.selectedFiles.has(e.rel); + const rowCls = "file-row" + (sel ? " selected" : ""); return ` -
+
+ ${escapeHtml(e.name)} @@ -1353,7 +1648,7 @@ function renderFiles(data) { el.style.cursor = "pointer"; el.onclick = () => { const rel = el.dataset.rel; - if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); } + if (el.dataset.isdir === "true") { navFiles(rel); } else { openFilePreview(rel); } }; }); @@ -1365,6 +1660,35 @@ function renderFiles(data) { showMenu(btn, fileMenuItems(e)); }; }); + $("file-list").querySelectorAll(".row-cb").forEach((cb) => { + cb.onclick = (ev) => ev.stopPropagation(); // 防 row 命中导航 + cb.onchange = (ev) => { + const rel = cb.dataset.rel; + if (cb.checked) state.selectedFiles.add(rel); + else state.selectedFiles.delete(rel); + const row = cb.closest(".file-row"); + if (row) row.classList.toggle("selected", cb.checked); + syncBulkBar(); + }; + }); + syncBulkBar(); +} + +// 同步多选 toolbar 显隐 + 计数 + 顶栏全选 checkbox(全选 / 半选 / 未选三态) +function syncBulkBar() { + const n = state.selectedFiles.size; + $("files-bulk-count").textContent = String(n); + $("files-bulkbar").classList.toggle("show", n > 0); + const rels = Object.keys(state.entriesByRel || {}); + const cb = $("files-selall"); + if (!rels.length) { + cb.checked = false; cb.indeterminate = false; cb.disabled = true; + } else { + cb.disabled = false; + const selN = rels.reduce((acc, r) => acc + (state.selectedFiles.has(r) ? 1 : 0), 0); + cb.checked = selN === rels.length; + cb.indeterminate = selN > 0 && selN < rels.length; + } } function fileMenuItems(e) { @@ -1671,9 +1995,10 @@ $("file-preview-modal").addEventListener("click", (e) => { if (e.target.id === "file-preview-modal") closeFilePreview(); }); document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) { - closeFilePreview(); - } + if (e.key !== "Escape") return; + // 多模态共存:优先关靠前栈顶 — 目录选择(z 95)→ 文件预览(z 90)→ 新任务(z 80) + if ($("dir-picker-modal").classList.contains("show")) { closeDirPicker(); return; } + if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } }); async function uploadSelected() {