files(dev SPA): /v1/files/copy + /move 跨目录批量搬动 + 多选 + 目录选择弹框

后端两路由共用 _validate_transfer 预检 helper(批量原子校验:同名 409 不
覆盖、不自嵌套、不重名、target 已存);move 加闸"顶层目录是某 task
working_dir → 409"维持 working_dir = top-level invariant,copy 无此闸
(新副本无 task 关联)。dev SPA 文件行加 checkbox + 顶栏全选三态 + 黄底
toolbar(复制到/移动到/取消),目录选择弹框复用 /v1/files 浏览。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 09:29:59 +08:00
parent 7925dcef54
commit 0c5dd3b176
3 changed files with 515 additions and 21 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff` > 配合 `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 ### 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` 加 `<input type=checkbox class=row-cb>` 列 + 顶栏 `#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 绝对串)的清理(避免误删用户外部项目)。 - **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 ### 2026-05-19
@ -135,11 +136,11 @@ db/migrations/versions/
0005_users_email_unique.py 28 ← 0005 一日游 invites 已撤,接 users.email UNIQUE 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) 0006_usage_events_v2_and_message_model.py 60 ← messages.model_profile 列 + usage_events v2 表(多态 units jsonb)
web/__init__.py 5 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/auth.py ~190 ← D' 过渡:邮箱密码 + platform_key → JWT
web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引) web/broker.py 121 ← in-process pub/sub + cancel signal(全 task_id 索引)
web/sinks.py 21 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 预览) web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx(office 预览)
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB) Python 合计 ~3400 行(+ dev.html 1700 静态 + vendor 1MB)

View File

@ -183,6 +183,67 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
return entries, crumbs, exists 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/<name> 不能已存在(整批 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 帧格式 ─────────────────── # ─────────────────── BG run + SSE 帧格式 ───────────────────
def _run_agent_bg(task_id: UUID, user_id: UUID, user_message: str) -> None: 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 名(不是路径),不含 / \ .. new_name: str # 新的 leaf 名(不是路径),不含 / \ ..
class FileTransferRequest(BaseModel):
paths: list[str] # 多源,均相对 user_root
dest_dir: str = "" # 目标目录,相对 user_root,空 → user_root
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
user_id: str user_id: str
platform_key: str platform_key: str
@ -1113,6 +1179,108 @@ def create_app() -> FastAPI:
"tasks_updated": len(tids), "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/<name>(目录递归)。
- 不覆盖(任一目标已存在 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/<name>。
- 不覆盖不自嵌套( /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 ───────────── # ───────────── Export ─────────────
@app.get("/v1/tasks/{task_id}/export", tags=["export"]) @app.get("/v1/tasks/{task_id}/export", tags=["export"])

View File

@ -53,25 +53,86 @@
/* ───── login overlay ───── */ /* ───── login overlay ───── */
#login { #login {
position: fixed; inset: 0; background: rgba(0,0,0,0.4); position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center; 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 { #login .card {
background: var(--panel); padding: 24px; border-radius: 6px; background: var(--panel);
width: 360px; box-shadow: 0 8px 24px rgba(0,0,0,.15); 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 { #login .tabs button {
background: none; border: none; border-bottom: 2px solid transparent; 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 .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
#login .tab-body { display: none; } #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 ───── */ /* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; } #app { display: none; height: 100vh; }
@ -252,10 +313,69 @@
align-items: center; gap: 8px; align-items: center; gap: 8px;
} }
.file-row:hover { background: var(--hover); } .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 .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; } .file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; }
.ico-dir::before { content: "▸ "; color: var(--accent); } .ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); } .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 ───── */
#new-task-modal { #new-task-modal {
@ -349,7 +469,11 @@
<!-- ───── login overlay ───── --> <!-- ───── login overlay ───── -->
<div id="login"> <div id="login">
<div class="card"> <div class="card">
<h2>zcbot 登录</h2> <div class="brand">
<div class="logo">Z</div>
<div class="name">zcbot</div>
</div>
<h2>登录到控制台</h2>
<div class="tabs"> <div class="tabs">
<button data-tab="pw" class="active" id="tab-pw">邮箱密码</button> <button data-tab="pw" class="active" id="tab-pw">邮箱密码</button>
<button data-tab="key" id="tab-key">UUID + PLATFORM_KEY</button> <button data-tab="key" id="tab-key">UUID + PLATFORM_KEY</button>
@ -463,15 +587,40 @@
<span class="label">文件</span> <span class="label">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span> <span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span>
<span class="spacer"></span> <span class="spacer"></span>
<label class="selall-wrap small" title="全选/取消全选当前目录的可见条目">
<input type="checkbox" id="files-selall" />
<span class="muted" style="font-size:11px;">全选</span>
</label>
<button id="btn-upload" class="small" title="上传文件到当前目录"></button> <button id="btn-upload" class="small" title="上传文件到当前目录"></button>
<button id="btn-refresh-files" class="small"></button> <button id="btn-refresh-files" class="small"></button>
</div> </div>
<div id="file-crumbs" class="crumbs muted">加载中…</div> <div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="files-bulkbar">
<span class="count">已选 <span id="files-bulk-count">0</span></span>
<span class="spacer"></span>
<button class="small" id="btn-bulk-copy" title="复制选中项到指定目录">复制到…</button>
<button class="small" id="btn-bulk-move" title="移动选中项到指定目录">移动到…</button>
<button class="small" id="btn-bulk-clear" title="清空选中">取消选中</button>
</div>
<div id="file-list"></div> <div id="file-list"></div>
<input type="file" id="upload-input" multiple style="display:none;" /> <input type="file" id="upload-input" multiple style="display:none;" />
</div> </div>
</div> </div>
<!-- ───── dir picker modal(复制/移动目标选择) ───── -->
<div id="dir-picker-modal">
<div class="card">
<h3 id="dp-title">选择目标目录</h3>
<div class="hint" id="dp-hint">浏览到目标目录,然后点击底部按钮确认</div>
<div id="dp-crumbs"></div>
<div id="dp-list"></div>
<div class="actions">
<button id="dp-cancel">取消</button>
<button class="primary" id="dp-confirm">在此处确认</button>
</div>
</div>
</div>
<!-- ───── floating dropdown menu (single instance) ───── --> <!-- ───── floating dropdown menu (single instance) ───── -->
<div id="floating-menu"></div> <div id="floating-menu"></div>
@ -526,6 +675,8 @@ const state = {
taskId: null, taskId: null,
taskMeta: null, taskMeta: null,
filesPath: "", filesPath: "",
// 文件多选状态:rel 路径集合;路径切换 / 复制移动成功后清空,refresh 保留(仅剔除已不存在的)
selectedFiles: new Set(),
evtSrc: null, evtSrc: null,
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮 streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
// task list 分页 + 筛选 // task list 分页 + 筛选
@ -917,6 +1068,7 @@ async function selectTask(tid) {
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
state.filesPath = wdName || ""; state.filesPath = wdName || "";
state.selectedFiles.clear(); // 切 task 跨 view 重置选中
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -1289,6 +1441,131 @@ $("btn-upload").onclick = () => $("upload-input").click();
$("chat-upload").onclick = () => $("upload-input").click(); $("chat-upload").onclick = () => $("upload-input").click();
$("upload-input").addEventListener("change", uploadSelected); $("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 = `<div class="empty">${escapeHtml(e.message)}</div>`;
}
}
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 `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" ");
$("dp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("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 = `<div class="empty">(无子目录)</div>`;
} else {
$("dp-list").innerHTML = dirs.map((e) => {
const isSrc = srcSet.has(e.rel);
const cls = "dir-row" + (isSrc ? " disabled" : "");
const title = isSrc ? "源目录,不能放进自己" : "进入";
return `<div class="${cls}" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(title)}"><span class="ico-dir">${escapeHtml(e.name)}</span></div>`;
}).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 // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
let _filesRefreshTimer = null; let _filesRefreshTimer = null;
function scheduleFilesRefresh() { function scheduleFilesRefresh() {
@ -1308,6 +1585,13 @@ async function loadFiles() {
} }
} }
// 切换文件面板浏览路径:重置多选(选中是 per-view 概念),然后加载
function navFiles(newPath) {
state.filesPath = newPath || "";
state.selectedFiles.clear();
loadFiles();
}
function renderFiles(data) { function renderFiles(data) {
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean); const segs = (data.current || "").split("/").filter(Boolean);
@ -1325,22 +1609,33 @@ function renderFiles(data) {
}).join(" "); }).join(" ");
$("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`; $("file-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("file-crumbs").querySelectorAll("a").forEach((a) => { $("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) { if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`; $("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {};
syncBulkBar();
return; return;
} }
if (!data.entries.length) { if (!data.entries.length) {
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`; $("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
state.entriesByRel = {};
syncBulkBar();
return; return;
} }
state.entriesByRel = {}; state.entriesByRel = {};
for (const e of data.entries) state.entriesByRel[e.rel] = e; 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) => { $("file-list").innerHTML = data.entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file"; const cls = e.is_dir ? "ico-dir" : "ico-file";
const sel = state.selectedFiles.has(e.rel);
const rowCls = "file-row" + (sel ? " selected" : "");
return ` return `
<div class="file-row"> <div class="${rowCls}" data-rel="${escapeHtml(e.rel)}">
<input type="checkbox" class="row-cb" data-rel="${escapeHtml(e.rel)}"${sel ? " checked" : ""} />
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}"> <span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
${escapeHtml(e.name)} ${escapeHtml(e.name)}
</span> </span>
@ -1353,7 +1648,7 @@ function renderFiles(data) {
el.style.cursor = "pointer"; el.style.cursor = "pointer";
el.onclick = () => { el.onclick = () => {
const rel = el.dataset.rel; const rel = el.dataset.rel;
if (el.dataset.isdir === "true") { state.filesPath = rel; loadFiles(); } if (el.dataset.isdir === "true") { navFiles(rel); }
else { openFilePreview(rel); } else { openFilePreview(rel); }
}; };
}); });
@ -1365,6 +1660,35 @@ function renderFiles(data) {
showMenu(btn, fileMenuItems(e)); 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) { function fileMenuItems(e) {
@ -1671,9 +1995,10 @@ $("file-preview-modal").addEventListener("click", (e) => {
if (e.target.id === "file-preview-modal") closeFilePreview(); if (e.target.id === "file-preview-modal") closeFilePreview();
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("file-preview-modal").classList.contains("show")) { if (e.key !== "Escape") return;
closeFilePreview(); // 多模态共存:优先关靠前栈顶 — 目录选择(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() { async function uploadSelected() {