files(dev SPA): UX 翻面 — 主区去 checkbox / 黄 bar,改 [选入到此处] 弹框 + 拖拽上传 + 修全局 input width bug

主区从 select-then-pick-dest 改 at-dest-pull-sources:用户切任务时主
区已自动跳 working_dir,destination-first 比 source-first 少一次心智
切换。pane-head 加 [选入…] → 弹框跨目录勾源(Set<rel> 切换路径保留)
→ 底部 [复制到此处] / [移动到此处] 落到主区当前 state.filesPath;弹
框浏览 == 主区路径时同目录 checkbox 灰禁(挡 409)。整个 pane-right
成 drop zone,Files 类型才响应 + dragenter/leave 计数防子元素冒泡闪
烁,落点用 state.filesPath 沿用 /v1/files/upload。

根因 bug:全局 input{ width: 100% } 把新加的行 checkbox 撑成全行宽,
.name(flex:1; flex-basis:0)被挤成 0 宽 — 用户报"看不到文字"的元
凶。修法 selector 排除 [type=checkbox]/[type=radio]/[type=file]。

按 CLAUDE.md 不留兼容:state.selectedFiles / syncBulkBar / dirPicker
/ files-selall / files-bulkbar / row-cb / .file-row.selected 整套删
干净。后端 /v1/files/copy /v1/files/move 一行没动,前端用同样的
{paths, dest_dir}。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 09:59:21 +08:00
parent 0c5dd3b176
commit 337b8896a6
2 changed files with 207 additions and 201 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(files API 加 copy/move 跨目录搬动:多选 + 弹框目录选择;move 闸住"顶层目录是某 task working_dir"维持 invariant) 最后更新:2026-05-20(files SPA UX 翻面:主区干掉行 checkbox / 黄 bar / 全选,改成"选入到此处"弹框 — 目的地是主区当前路径,源在弹框里跨目录勾;附拖拽上传 + 修一处 `input[type=checkbox]` 被全局 `width:100%` 撑爆的 layout bug)
--- ---
@ -23,7 +23,8 @@
### 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。 - **files SPA UX 翻面 + 拖拽上传 + 修 checkbox 全局 width bug**:沿用上条新加的两路由,但前端 UX 整套换。**原模型**(select-then-pick-dest):主区行带 checkbox + 顶栏全选三态 + 黄 bar(复制到 / 移动到 / 取消)→ 弹框选目标目录。**新模型**(at-dest-pull-sources):主区只读浏览,顶栏加 `[选入…]` 按钮 → 弹框内浏览任意目录 + 跨目录勾文件 / 子目录(`Set<rel>` 跨切换保留)+ 底部 `[复制到此处]` `[移动到此处]` 两按钮直接落到主区当前 `state.filesPath`。**理由**:用户切任务时主区自动跳 task working_dir,绝大多数操作是"把外面素材喂进当前 working_dir",destination-first 比 source-first 少一次心智切换,且主区干净。**附带**:① 主区 `<input type=checkbox class=row-cb>` 被全局 `input{ width:100%; }` 撑成全行宽 → 把 `.name`(`flex: 1; flex-basis: 0`)挤成 0 宽,行里只剩看不见的文字 + 居中的 checkbox(用户报"看不到文字"),根因不修永远埋雷,改 selector 排除 checkbox/radio/file。② 拖拽上传:`#pane-right` 监听 dragenter/over/leave/drop,有 `Files` 才响应(忽略文本拖拽),`#file-droparea` 红色虚线 overlay,落点 = `state.filesPath`,沿用 `/v1/files/upload`。**删了**:`state.selectedFiles` + `syncBulkBar` + `dirPicker` 模块 + 顶栏 selall + 黄 bar 整块 + 行 checkbox 渲染(按 CLAUDE.md 不留旧 UX)。**没动**:后端 `/v1/files/copy` `/v1/files/move`(同样的 `paths` + `dest_dir`)、DESIGN、RUN。
- **`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

View File

@ -43,10 +43,12 @@
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary:hover { filter: brightness(1.08); } button.primary:hover { filter: brightness(1.08); }
button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } button.danger:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
input, textarea, select { input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea, select {
background: #fff; border: 1px solid var(--border); background: #fff; border: 1px solid var(--border);
padding: 5px 8px; border-radius: 4px; width: 100%; padding: 5px 8px; border-radius: 4px; width: 100%;
} }
input[type="checkbox"], input[type="radio"] { cursor: pointer; }
textarea { resize: vertical; min-height: 60px; } textarea { resize: vertical; min-height: 60px; }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
@ -313,69 +315,82 @@
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(复制/移动目标选择) ───── */ /* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */
#dir-picker-modal { #pane-right { position: relative; }
#file-droparea {
position: absolute; inset: 0; pointer-events: none;
display: none; align-items: center; justify-content: center;
background: rgba(192,57,43,0.06); border: 2px dashed var(--accent);
color: var(--accent); font-size: 14px; font-weight: 500;
z-index: 10;
}
#file-droparea.show { display: flex; }
/* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */
#src-picker-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.4); position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: none; align-items: center; justify-content: center; z-index: 95; display: none; align-items: center; justify-content: center; z-index: 95;
} }
#dir-picker-modal.show { display: flex; } #src-picker-modal.show { display: flex; }
#dir-picker-modal .card { #src-picker-modal .card {
background: var(--panel); border-radius: 6px; background: var(--panel); border-radius: 6px;
width: 520px; max-height: 80vh; width: 560px; max-height: 82vh;
display: flex; flex-direction: column; display: flex; flex-direction: column;
box-shadow: 0 8px 24px rgba(0,0,0,.15); box-shadow: 0 8px 24px rgba(0,0,0,.15);
} }
#dir-picker-modal h3 { #src-picker-modal h3 {
margin: 0; padding: 14px 18px; font-size: 16px; margin: 0; padding: 14px 18px; font-size: 16px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
} }
#dir-picker-modal .hint { #src-picker-modal h3 .dest {
font-size: 12px; color: var(--muted); font-weight: 400;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#src-picker-modal .hint {
padding: 8px 18px; font-size: 12px; color: var(--muted); padding: 8px 18px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
#dp-crumbs { #sp-crumbs {
padding: 8px 14px; border-bottom: 1px solid var(--border); padding: 8px 14px; border-bottom: 1px solid var(--border);
font-size: 12px; background: #fafafa; font-size: 12px; background: #fafafa;
} }
#dp-crumbs a { margin-right: 4px; } #sp-crumbs a { margin-right: 4px; }
#dp-list { #sp-list {
flex: 1; overflow: auto; flex: 1; overflow: auto;
min-height: 220px; max-height: 50vh; min-height: 240px; max-height: 50vh;
} }
#dp-list .dir-row { #sp-list .sp-row {
padding: 8px 14px; border-bottom: 1px solid var(--border); cursor: pointer; padding: 6px 14px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-size: 13px;
} }
#dp-list .dir-row:hover { background: var(--hover); } #sp-list .sp-row:hover { background: var(--hover); }
#dp-list .dir-row.disabled { color: var(--muted); cursor: not-allowed; } #sp-list .sp-row .sp-cb { flex-shrink: 0; margin: 0; }
#dp-list .dir-row.disabled:hover { background: transparent; } #sp-list .sp-row .sp-name {
#dp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; } flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
#dir-picker-modal .actions { cursor: pointer;
padding: 12px 18px; border-top: 1px solid var(--border);
display: flex; gap: 8px; justify-content: flex-end;
} }
#sp-list .sp-row.disabled .sp-name { color: var(--muted); cursor: not-allowed; }
#sp-list .sp-row .sp-size { font-size: 11px; color: var(--muted); font-family: monospace; }
#sp-list .empty { padding: 18px; color: var(--muted); text-align: center; font-size: 12px; }
#src-picker-modal .actions {
padding: 12px 18px; border-top: 1px solid var(--border);
display: flex; gap: 8px; align-items: center;
}
#src-picker-modal .actions .count {
flex: 1; font-size: 12px; color: var(--muted);
}
#sp-copy { color: #1565c0; border-color: #aed6f1; }
#sp-copy:hover:not(:disabled) { background: #ebf5fb; }
#sp-move { color: #c77800; border-color: #f5cba7; }
#sp-move:hover:not(:disabled) { background: #fef5e7; }
#sp-copy:disabled, #sp-move:disabled { opacity: 0.4; cursor: not-allowed; }
/* ───── new task modal ───── */ /* ───── new task modal ───── */
#new-task-modal { #new-task-modal {
@ -587,36 +602,32 @@
<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="全选/取消全选当前目录的可见条目"> <button id="btn-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
<input type="checkbox" id="files-selall" /> <button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)"></button>
<span class="muted" style="font-size:11px;">全选</span>
</label>
<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>
<div id="file-droparea">松开以上传到当前目录</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(复制/移动目标选择) ───── --> <!-- ───── source picker modal(选入文件:勾源 → 复制/移动到主区当前目录) ───── -->
<div id="dir-picker-modal"> <div id="src-picker-modal">
<div class="card"> <div class="card">
<h3 id="dp-title">选择目标目录</h3> <h3>
<div class="hint" id="dp-hint">浏览到目标目录,然后点击底部按钮确认</div> <span>选入到</span>
<div id="dp-crumbs"></div> <span class="dest" id="sp-dest" title=""></span>
<div id="dp-list"></div> </h3>
<div class="hint">勾选要带入的文件 / 目录(可跨目录,选择跨切换保留);底部按钮把它们复制或移动到此处。</div>
<div id="sp-crumbs"></div>
<div id="sp-list"></div>
<div class="actions"> <div class="actions">
<button id="dp-cancel">取消</button> <span class="count">已选 <span id="sp-count">0</span></span>
<button class="primary" id="dp-confirm">在此处确认</button> <button id="sp-cancel">取消</button>
<button id="sp-copy" disabled title="复制(新副本,源保留)">复制到此处</button>
<button id="sp-move" disabled title="移动(源消失;working_dir 顶层目录不可移)">移动到此处</button>
</div> </div>
</div> </div>
</div> </div>
@ -675,8 +686,6 @@ 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 分页 + 筛选
@ -1068,7 +1077,6 @@ 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; }
@ -1441,116 +1449,110 @@ $("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:三态 — 全未选 → 全选;部分/全选 → 清空 // ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
$("files-selall").onchange = () => { // 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
const rels = Object.keys(state.entriesByRel || {}); // 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
if (!rels.length) return; const srcPicker = { path: "", selected: new Set() };
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 async function openSrcPicker() {
$("btn-bulk-copy").onclick = () => openDirPicker("copy"); srcPicker.path = "";
$("btn-bulk-move").onclick = () => openDirPicker("move"); srcPicker.selected.clear();
$("btn-bulk-clear").onclick = () => { const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
state.selectedFiles.clear(); $("sp-dest").textContent = destLabel;
document.querySelectorAll("#file-list .file-row").forEach((row) => { $("sp-dest").title = destLabel;
row.classList.remove("selected"); syncSrcCount();
const cb = row.querySelector(".row-cb"); $("src-picker-modal").classList.add("show");
if (cb) cb.checked = false; await loadSrcPicker();
});
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() { function closeSrcPicker() {
$("dir-picker-modal").classList.remove("show"); $("src-picker-modal").classList.remove("show");
dirPicker.mode = null; srcPicker.path = "";
dirPicker.sources = []; srcPicker.selected.clear();
dirPicker.path = "";
} }
async function loadDirPicker() { async function loadSrcPicker() {
try { try {
const qs = dirPicker.path ? "?path=" + encodeURIComponent(dirPicker.path) : ""; const qs = srcPicker.path ? "?path=" + encodeURIComponent(srcPicker.path) : "";
const data = await api("GET", "/v1/files" + qs); const data = await api("GET", "/v1/files" + qs);
renderDirPicker(data); renderSrcPicker(data);
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
$("dp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`; $("sp-list").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
} }
} }
function renderDirPicker(data) { function renderSrcPicker(data) {
// crumbs(可点回上层)
const cr = data.crumbs.map((c, i) => { const cr = data.crumbs.map((c, i) => {
const label = i === 0 ? "我的" : c.label; const label = i === 0 ? "我的" : c.label;
const isLast = i === data.crumbs.length - 1; const isLast = i === data.crumbs.length - 1;
if (isLast) return `<span>${escapeHtml(label)}</span>`; if (isLast) return `<span>${escapeHtml(label)}</span>`;
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`; return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
}).join(" "); }).join(" ");
$("dp-crumbs").innerHTML = cr || `<span class="muted">/</span>`; $("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
$("dp-crumbs").querySelectorAll("a").forEach((a) => { $("sp-crumbs").querySelectorAll("a").forEach((a) => {
a.onclick = (e) => { e.preventDefault(); dirPicker.path = a.dataset.rel; loadDirPicker(); }; a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
});
// 只列目录;源 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(); };
}); });
const entries = data.entries || [];
if (!data.exists) {
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
return;
} }
if (!entries.length) {
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
return;
}
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
const destPath = state.filesPath || "";
const sameAsDest = srcPicker.path === destPath;
$("sp-list").innerHTML = entries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file";
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
const disabled = sameAsDest ? " disabled" : "";
return `
<div class="sp-row${disabled}" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
<input type="checkbox" class="sp-cb" data-rel="${escapeHtml(e.rel)}"${checked}${sameAsDest ? " disabled" : ""} />
<span class="${cls} sp-name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">${escapeHtml(e.name)}</span>
<span class="sp-size">${humanSize(e.size)}</span>
</div>
`;
}).join("");
$("sp-list").querySelectorAll(".sp-name").forEach((el) => {
el.onclick = () => {
if (el.dataset.isdir === "true") {
srcPicker.path = el.dataset.rel;
loadSrcPicker();
}
};
});
$("sp-list").querySelectorAll(".sp-cb").forEach((cb) => {
cb.onchange = () => {
const rel = cb.dataset.rel;
if (cb.checked) srcPicker.selected.add(rel);
else srcPicker.selected.delete(rel);
syncSrcCount();
};
});
} }
async function confirmDirPicker() { function syncSrcCount() {
if (!dirPicker.mode) return; const n = srcPicker.selected.size;
const endpoint = dirPicker.mode === "copy" ? "/v1/files/copy" : "/v1/files/move"; $("sp-count").textContent = String(n);
const verb = dirPicker.mode === "copy" ? "复制" : "移动"; $("sp-copy").disabled = n === 0;
$("sp-move").disabled = n === 0;
}
async function doSrcTransfer(mode) {
const sources = [...srcPicker.selected];
if (!sources.length) return;
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
const verb = mode === "copy" ? "复制" : "移动";
try { try {
const res = await api("POST", endpoint, { await api("POST", endpoint, {
paths: dirPicker.sources, paths: sources,
dest_dir: dirPicker.path, dest_dir: state.filesPath || "",
}); });
closeDirPicker(); closeSrcPicker();
state.selectedFiles.clear();
await loadFiles(); await loadFiles();
await loadFolderSuggestions(); await loadFolderSuggestions();
} catch (e) { } catch (e) {
@ -1559,11 +1561,47 @@ async function confirmDirPicker() {
} }
} }
$("dp-cancel").onclick = closeDirPicker; $("btn-src-pick").onclick = openSrcPicker;
$("dp-confirm").onclick = confirmDirPicker; $("sp-cancel").onclick = closeSrcPicker;
$("dir-picker-modal").addEventListener("click", (e) => { $("sp-copy").onclick = () => doSrcTransfer("copy");
// 点遮罩(card 外)关闭 $("sp-move").onclick = () => doSrcTransfer("move");
if (e.target.id === "dir-picker-modal") closeDirPicker(); $("src-picker-modal").addEventListener("click", (e) => {
if (e.target.id === "src-picker-modal") closeSrcPicker();
});
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
let _dragDepth = 0;
function _hasFiles(ev) {
const t = ev.dataTransfer;
if (!t) return false;
if (t.types && [...t.types].includes("Files")) return true;
return false;
}
$("pane-right").addEventListener("dragenter", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth++;
$("file-droparea").classList.add("show");
});
$("pane-right").addEventListener("dragover", (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
$("pane-right").addEventListener("dragleave", (e) => {
if (!_hasFiles(e)) return;
_dragDepth = Math.max(0, _dragDepth - 1);
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
});
$("pane-right").addEventListener("drop", async (e) => {
if (!_hasFiles(e)) return;
e.preventDefault();
_dragDepth = 0;
$("file-droparea").classList.remove("show");
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
await uploadFiles(files);
}); });
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
@ -1585,10 +1623,9 @@ async function loadFiles() {
} }
} }
// 切换文件面板浏览路径:重置多选(选中是 per-view 概念),然后加载 // 切换文件面板浏览路径
function navFiles(newPath) { function navFiles(newPath) {
state.filesPath = newPath || ""; state.filesPath = newPath || "";
state.selectedFiles.clear();
loadFiles(); loadFiles();
} }
@ -1614,28 +1651,19 @@ function renderFiles(data) {
if (!data.exists) { if (!data.exists) {
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`; $("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
state.entriesByRel = {}; 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 = {}; 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="${rowCls}" data-rel="${escapeHtml(e.rel)}"> <div class="file-row" 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>
@ -1660,35 +1688,6 @@ 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) {
@ -1996,15 +1995,13 @@ $("file-preview-modal").addEventListener("click", (e) => {
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
// 多模态共存:优先关靠前栈顶 — 目录选择(z 95)→ 文件预览(z 90)→ 新任务(z 80) // 多模态共存:优先关靠前栈顶 — 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
if ($("dir-picker-modal").classList.contains("show")) { closeDirPicker(); return; } if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
}); });
async function uploadSelected() { async function uploadFiles(files) {
const inp = $("upload-input"); if (!files || !files.length) return;
const files = Array.from(inp.files || []);
if (!files.length) return;
const fd = new FormData(); const fd = new FormData();
fd.append("path", state.filesPath || ""); fd.append("path", state.filesPath || "");
for (const f of files) fd.append("files", f); for (const f of files) fd.append("files", f);
@ -2021,6 +2018,14 @@ async function uploadSelected() {
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
alert("上传失败:" + e.message); alert("上传失败:" + e.message);
}
}
async function uploadSelected() {
const inp = $("upload-input");
const files = Array.from(inp.files || []);
try {
await uploadFiles(files);
} finally { } finally {
inp.value = ""; // 允许重新选同名文件 inp.value = ""; // 允许重新选同名文件
} }