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:
parent
0c5dd3b176
commit
337b8896a6
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `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
|
||||
|
||||
- **`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 绝对串)的清理(避免误删用户外部项目)。
|
||||
|
||||
### 2026-05-19
|
||||
|
|
|
|||
|
|
@ -43,10 +43,12 @@
|
|||
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
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);
|
||||
padding: 5px 8px; border-radius: 4px; width: 100%;
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] { cursor: pointer; }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
|
@ -313,69 +315,82 @@
|
|||
align-items: center; gap: 8px;
|
||||
}
|
||||
.file-row:hover { background: var(--hover); }
|
||||
.file-row.selected { background: var(--accent-soft); }
|
||||
.file-row .row-cb { margin: 0; cursor: pointer; flex-shrink: 0; }
|
||||
.file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-row .size { font-size: 11px; color: var(--muted); font-family: monospace; }
|
||||
.ico-dir::before { content: "▸ "; color: var(--accent); }
|
||||
.ico-file::before { content: "· "; color: var(--muted); }
|
||||
.selall-wrap { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; }
|
||||
.selall-wrap input { margin: 0; cursor: pointer; }
|
||||
/* 多选 toolbar:有选中时出,黄色背景区分 */
|
||||
#files-bulkbar {
|
||||
display: none; padding: 6px 12px; gap: 6px;
|
||||
border-bottom: 1px solid var(--border); background: #fff8d6;
|
||||
align-items: center; font-size: 12px;
|
||||
}
|
||||
#files-bulkbar.show { display: flex; }
|
||||
#files-bulkbar .count { color: #6a5; font-weight: 500; }
|
||||
#files-bulkbar .spacer { flex: 1; }
|
||||
#btn-bulk-copy { color: #1565c0; border-color: #aed6f1; }
|
||||
#btn-bulk-copy:hover { background: #ebf5fb; }
|
||||
#btn-bulk-move { color: #c77800; border-color: #f5cba7; }
|
||||
#btn-bulk-move:hover { background: #fef5e7; }
|
||||
|
||||
/* ───── dir picker modal(复制/移动目标选择) ───── */
|
||||
#dir-picker-modal {
|
||||
/* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */
|
||||
#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);
|
||||
display: none; align-items: center; justify-content: center; z-index: 95;
|
||||
}
|
||||
#dir-picker-modal.show { display: flex; }
|
||||
#dir-picker-modal .card {
|
||||
#src-picker-modal.show { display: flex; }
|
||||
#src-picker-modal .card {
|
||||
background: var(--panel); border-radius: 6px;
|
||||
width: 520px; max-height: 80vh;
|
||||
width: 560px; max-height: 82vh;
|
||||
display: flex; flex-direction: column;
|
||||
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;
|
||||
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);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#dp-crumbs {
|
||||
#sp-crumbs {
|
||||
padding: 8px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; background: #fafafa;
|
||||
}
|
||||
#dp-crumbs a { margin-right: 4px; }
|
||||
#dp-list {
|
||||
#sp-crumbs a { margin-right: 4px; }
|
||||
#sp-list {
|
||||
flex: 1; overflow: auto;
|
||||
min-height: 220px; max-height: 50vh;
|
||||
min-height: 240px; max-height: 50vh;
|
||||
}
|
||||
#dp-list .dir-row {
|
||||
padding: 8px 14px; border-bottom: 1px solid var(--border); cursor: pointer;
|
||||
#sp-list .sp-row {
|
||||
padding: 6px 14px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
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;
|
||||
#sp-list .sp-row:hover { background: var(--hover); }
|
||||
#sp-list .sp-row .sp-cb { flex-shrink: 0; margin: 0; }
|
||||
#sp-list .sp-row .sp-name {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
#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 {
|
||||
|
|
@ -587,36 +602,32 @@
|
|||
<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 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-src-pick" class="small" title="从其他目录勾选文件 / 目录,复制或移动到当前主目录">选入…</button>
|
||||
<button id="btn-upload" class="small" title="上传文件到当前目录(也可直接把文件拖到本面板)">⬆</button>
|
||||
<button id="btn-refresh-files" class="small">↻</button>
|
||||
</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-droparea">松开以上传到当前目录</div>
|
||||
<input type="file" id="upload-input" multiple style="display:none;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ───── dir picker modal(复制/移动目标选择) ───── -->
|
||||
<div id="dir-picker-modal">
|
||||
<!-- ───── source picker modal(选入文件:勾源 → 复制/移动到主区当前目录) ───── -->
|
||||
<div id="src-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>
|
||||
<h3>
|
||||
<span>选入到</span>
|
||||
<span class="dest" id="sp-dest" title=""></span>
|
||||
</h3>
|
||||
<div class="hint">勾选要带入的文件 / 目录(可跨目录,选择跨切换保留);底部按钮把它们复制或移动到此处。</div>
|
||||
<div id="sp-crumbs"></div>
|
||||
<div id="sp-list"></div>
|
||||
<div class="actions">
|
||||
<button id="dp-cancel">取消</button>
|
||||
<button class="primary" id="dp-confirm">在此处确认</button>
|
||||
<span class="count">已选 <span id="sp-count">0</span> 项</span>
|
||||
<button id="sp-cancel">取消</button>
|
||||
<button id="sp-copy" disabled title="复制(新副本,源保留)">复制到此处</button>
|
||||
<button id="sp-move" disabled title="移动(源消失;working_dir 顶层目录不可移)">移动到此处</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -675,8 +686,6 @@ const state = {
|
|||
taskId: null,
|
||||
taskMeta: null,
|
||||
filesPath: "",
|
||||
// 文件多选状态:rel 路径集合;路径切换 / 复制移动成功后清空,refresh 保留(仅剔除已不存在的)
|
||||
selectedFiles: new Set(),
|
||||
evtSrc: null,
|
||||
streaming: false, // 当前是否在流式中;true 时显示 stop 按钮
|
||||
// task list 分页 + 筛选
|
||||
|
|
@ -1068,7 +1077,6 @@ async function selectTask(tid) {
|
|||
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
|
||||
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
|
||||
state.filesPath = wdName || "";
|
||||
state.selectedFiles.clear(); // 切 task 跨 view 重置选中
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
if (e.status === 401) { logout(); return; }
|
||||
|
|
@ -1441,116 +1449,110 @@ $("btn-upload").onclick = () => $("upload-input").click();
|
|||
$("chat-upload").onclick = () => $("upload-input").click();
|
||||
$("upload-input").addEventListener("change", uploadSelected);
|
||||
|
||||
// 顶栏全选 checkbox:三态 — 全未选 → 全选;部分/全选 → 清空
|
||||
$("files-selall").onchange = () => {
|
||||
const rels = Object.keys(state.entriesByRel || {});
|
||||
if (!rels.length) return;
|
||||
const allSelected = rels.every((r) => state.selectedFiles.has(r));
|
||||
if (allSelected) rels.forEach((r) => state.selectedFiles.delete(r));
|
||||
else rels.forEach((r) => state.selectedFiles.add(r));
|
||||
// 不需全量重渲染(行已在 DOM),逐行反映 selection
|
||||
document.querySelectorAll("#file-list .file-row").forEach((row) => {
|
||||
const sel = state.selectedFiles.has(row.dataset.rel);
|
||||
row.classList.toggle("selected", sel);
|
||||
const cb = row.querySelector(".row-cb");
|
||||
if (cb) cb.checked = sel;
|
||||
});
|
||||
syncBulkBar();
|
||||
};
|
||||
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
|
||||
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
|
||||
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。
|
||||
const srcPicker = { path: "", selected: new Set() };
|
||||
|
||||
// 多选 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();
|
||||
async function openSrcPicker() {
|
||||
srcPicker.path = "";
|
||||
srcPicker.selected.clear();
|
||||
const destLabel = state.filesPath ? "我的 / " + state.filesPath : "我的 (根目录)";
|
||||
$("sp-dest").textContent = destLabel;
|
||||
$("sp-dest").title = destLabel;
|
||||
syncSrcCount();
|
||||
$("src-picker-modal").classList.add("show");
|
||||
await loadSrcPicker();
|
||||
}
|
||||
|
||||
function closeDirPicker() {
|
||||
$("dir-picker-modal").classList.remove("show");
|
||||
dirPicker.mode = null;
|
||||
dirPicker.sources = [];
|
||||
dirPicker.path = "";
|
||||
function closeSrcPicker() {
|
||||
$("src-picker-modal").classList.remove("show");
|
||||
srcPicker.path = "";
|
||||
srcPicker.selected.clear();
|
||||
}
|
||||
|
||||
async function loadDirPicker() {
|
||||
async function loadSrcPicker() {
|
||||
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);
|
||||
renderDirPicker(data);
|
||||
renderSrcPicker(data);
|
||||
} catch (e) {
|
||||
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) {
|
||||
// crumbs(可点回上层)
|
||||
function renderSrcPicker(data) {
|
||||
const cr = data.crumbs.map((c, i) => {
|
||||
const label = i === 0 ? "我的" : c.label;
|
||||
const isLast = i === data.crumbs.length - 1;
|
||||
if (isLast) return `<span>${escapeHtml(label)}</span>`;
|
||||
return `<a href="#" data-rel="${escapeHtml(c.rel)}">${escapeHtml(label)}</a> /`;
|
||||
}).join(" ");
|
||||
$("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(); };
|
||||
$("sp-crumbs").innerHTML = cr || `<span class="muted">/</span>`;
|
||||
$("sp-crumbs").querySelectorAll("a").forEach((a) => {
|
||||
a.onclick = (e) => { e.preventDefault(); srcPicker.path = a.dataset.rel; loadSrcPicker(); };
|
||||
});
|
||||
const entries = data.entries || [];
|
||||
if (!data.exists) {
|
||||
$("sp-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
||||
return;
|
||||
}
|
||||
if (!entries.length) {
|
||||
$("sp-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
||||
return;
|
||||
}
|
||||
// 闸:当前浏览路径 == 主区目的地 → 同目录内勾选无意义(同名 409),全行 disabled
|
||||
const destPath = state.filesPath || "";
|
||||
const sameAsDest = srcPicker.path === destPath;
|
||||
$("sp-list").innerHTML = entries.map((e) => {
|
||||
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
||||
const checked = srcPicker.selected.has(e.rel) ? " checked" : "";
|
||||
const disabled = sameAsDest ? " disabled" : "";
|
||||
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() {
|
||||
if (!dirPicker.mode) return;
|
||||
const endpoint = dirPicker.mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
|
||||
const verb = dirPicker.mode === "copy" ? "复制" : "移动";
|
||||
function syncSrcCount() {
|
||||
const n = srcPicker.selected.size;
|
||||
$("sp-count").textContent = String(n);
|
||||
$("sp-copy").disabled = n === 0;
|
||||
$("sp-move").disabled = n === 0;
|
||||
}
|
||||
|
||||
async function doSrcTransfer(mode) {
|
||||
const sources = [...srcPicker.selected];
|
||||
if (!sources.length) return;
|
||||
const endpoint = mode === "copy" ? "/v1/files/copy" : "/v1/files/move";
|
||||
const verb = mode === "copy" ? "复制" : "移动";
|
||||
try {
|
||||
const res = await api("POST", endpoint, {
|
||||
paths: dirPicker.sources,
|
||||
dest_dir: dirPicker.path,
|
||||
await api("POST", endpoint, {
|
||||
paths: sources,
|
||||
dest_dir: state.filesPath || "",
|
||||
});
|
||||
closeDirPicker();
|
||||
state.selectedFiles.clear();
|
||||
closeSrcPicker();
|
||||
await loadFiles();
|
||||
await loadFolderSuggestions();
|
||||
} catch (e) {
|
||||
|
|
@ -1559,11 +1561,47 @@ async function confirmDirPicker() {
|
|||
}
|
||||
}
|
||||
|
||||
$("dp-cancel").onclick = closeDirPicker;
|
||||
$("dp-confirm").onclick = confirmDirPicker;
|
||||
$("dir-picker-modal").addEventListener("click", (e) => {
|
||||
// 点遮罩(card 外)关闭
|
||||
if (e.target.id === "dir-picker-modal") closeDirPicker();
|
||||
$("btn-src-pick").onclick = openSrcPicker;
|
||||
$("sp-cancel").onclick = closeSrcPicker;
|
||||
$("sp-copy").onclick = () => doSrcTransfer("copy");
|
||||
$("sp-move").onclick = () => doSrcTransfer("move");
|
||||
$("src-picker-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "src-picker-modal") closeSrcPicker();
|
||||
});
|
||||
|
||||
// ───── 拖拽上传到主区(目的地 = state.filesPath)─────
|
||||
// 用 enter/leave 计数避免子元素冒泡时 overlay 闪烁。
|
||||
let _dragDepth = 0;
|
||||
function _hasFiles(ev) {
|
||||
const t = ev.dataTransfer;
|
||||
if (!t) return false;
|
||||
if (t.types && [...t.types].includes("Files")) return true;
|
||||
return false;
|
||||
}
|
||||
$("pane-right").addEventListener("dragenter", (e) => {
|
||||
if (!_hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
_dragDepth++;
|
||||
$("file-droparea").classList.add("show");
|
||||
});
|
||||
$("pane-right").addEventListener("dragover", (e) => {
|
||||
if (!_hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
});
|
||||
$("pane-right").addEventListener("dragleave", (e) => {
|
||||
if (!_hasFiles(e)) return;
|
||||
_dragDepth = Math.max(0, _dragDepth - 1);
|
||||
if (_dragDepth === 0) $("file-droparea").classList.remove("show");
|
||||
});
|
||||
$("pane-right").addEventListener("drop", async (e) => {
|
||||
if (!_hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
_dragDepth = 0;
|
||||
$("file-droparea").classList.remove("show");
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (!files.length) return;
|
||||
await uploadFiles(files);
|
||||
});
|
||||
|
||||
// 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API
|
||||
|
|
@ -1585,10 +1623,9 @@ async function loadFiles() {
|
|||
}
|
||||
}
|
||||
|
||||
// 切换文件面板浏览路径:重置多选(选中是 per-view 概念),然后加载
|
||||
// 切换文件面板浏览路径
|
||||
function navFiles(newPath) {
|
||||
state.filesPath = newPath || "";
|
||||
state.selectedFiles.clear();
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
|
|
@ -1614,28 +1651,19 @@ function renderFiles(data) {
|
|||
if (!data.exists) {
|
||||
$("file-list").innerHTML = `<div class="empty">(目录尚未创建)</div>`;
|
||||
state.entriesByRel = {};
|
||||
syncBulkBar();
|
||||
return;
|
||||
}
|
||||
if (!data.entries.length) {
|
||||
$("file-list").innerHTML = `<div class="empty">(空目录)</div>`;
|
||||
state.entriesByRel = {};
|
||||
syncBulkBar();
|
||||
return;
|
||||
}
|
||||
state.entriesByRel = {};
|
||||
for (const e of data.entries) state.entriesByRel[e.rel] = e;
|
||||
// refresh 后剔除已不在当前视图的选中项(避免幽灵选中跨视图残留)
|
||||
for (const r of [...state.selectedFiles]) {
|
||||
if (!(r in state.entriesByRel)) state.selectedFiles.delete(r);
|
||||
}
|
||||
$("file-list").innerHTML = data.entries.map((e) => {
|
||||
const cls = e.is_dir ? "ico-dir" : "ico-file";
|
||||
const sel = state.selectedFiles.has(e.rel);
|
||||
const rowCls = "file-row" + (sel ? " selected" : "");
|
||||
return `
|
||||
<div class="${rowCls}" data-rel="${escapeHtml(e.rel)}">
|
||||
<input type="checkbox" class="row-cb" data-rel="${escapeHtml(e.rel)}"${sel ? " checked" : ""} />
|
||||
<div class="file-row" data-rel="${escapeHtml(e.rel)}">
|
||||
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}">
|
||||
${escapeHtml(e.name)}
|
||||
</span>
|
||||
|
|
@ -1660,35 +1688,6 @@ function renderFiles(data) {
|
|||
showMenu(btn, fileMenuItems(e));
|
||||
};
|
||||
});
|
||||
$("file-list").querySelectorAll(".row-cb").forEach((cb) => {
|
||||
cb.onclick = (ev) => ev.stopPropagation(); // 防 row 命中导航
|
||||
cb.onchange = (ev) => {
|
||||
const rel = cb.dataset.rel;
|
||||
if (cb.checked) state.selectedFiles.add(rel);
|
||||
else state.selectedFiles.delete(rel);
|
||||
const row = cb.closest(".file-row");
|
||||
if (row) row.classList.toggle("selected", cb.checked);
|
||||
syncBulkBar();
|
||||
};
|
||||
});
|
||||
syncBulkBar();
|
||||
}
|
||||
|
||||
// 同步多选 toolbar 显隐 + 计数 + 顶栏全选 checkbox(全选 / 半选 / 未选三态)
|
||||
function syncBulkBar() {
|
||||
const n = state.selectedFiles.size;
|
||||
$("files-bulk-count").textContent = String(n);
|
||||
$("files-bulkbar").classList.toggle("show", n > 0);
|
||||
const rels = Object.keys(state.entriesByRel || {});
|
||||
const cb = $("files-selall");
|
||||
if (!rels.length) {
|
||||
cb.checked = false; cb.indeterminate = false; cb.disabled = true;
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
const selN = rels.reduce((acc, r) => acc + (state.selectedFiles.has(r) ? 1 : 0), 0);
|
||||
cb.checked = selN === rels.length;
|
||||
cb.indeterminate = selN > 0 && selN < rels.length;
|
||||
}
|
||||
}
|
||||
|
||||
function fileMenuItems(e) {
|
||||
|
|
@ -1996,15 +1995,13 @@ $("file-preview-modal").addEventListener("click", (e) => {
|
|||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// 多模态共存:优先关靠前栈顶 — 目录选择(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||
if ($("dir-picker-modal").classList.contains("show")) { closeDirPicker(); return; }
|
||||
// 多模态共存:优先关靠前栈顶 — 选入(z 95)→ 文件预览(z 90)→ 新任务(z 80)
|
||||
if ($("src-picker-modal").classList.contains("show")) { closeSrcPicker(); return; }
|
||||
if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; }
|
||||
});
|
||||
|
||||
async function uploadSelected() {
|
||||
const inp = $("upload-input");
|
||||
const files = Array.from(inp.files || []);
|
||||
if (!files.length) return;
|
||||
async function uploadFiles(files) {
|
||||
if (!files || !files.length) return;
|
||||
const fd = new FormData();
|
||||
fd.append("path", state.filesPath || "");
|
||||
for (const f of files) fd.append("files", f);
|
||||
|
|
@ -2021,6 +2018,14 @@ async function uploadSelected() {
|
|||
await loadFiles();
|
||||
} catch (e) {
|
||||
alert("上传失败:" + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSelected() {
|
||||
const inp = $("upload-input");
|
||||
const files = Array.from(inp.files || []);
|
||||
try {
|
||||
await uploadFiles(files);
|
||||
} finally {
|
||||
inp.value = ""; // 允许重新选同名文件
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue