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:
parent
7925dcef54
commit
0c5dd3b176
|
|
@ -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)
|
||||||
|
|
|
||||||
168
web/app.py
168
web/app.py
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue