From 7a0d03fb29dc1d90ed78190ae5cfb8b4b3192556 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 25 May 2026 08:54:06 +0800 Subject: [PATCH] Show upload progress --- PROGRESS.md | 4 +- web/static/dev.html | 144 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 1b3723f..6142e32 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-05-25(dev SPA 粘贴上传文件显示可预览 chip) +最后更新:2026-05-25(dev SPA 上传入口显示进度) --- @@ -23,6 +23,8 @@ ### 2026-05-25 +- **dev SPA 三类上传入口显示进度**:`web/static/dev.html` 上传底层从 `fetch` 改为 `XMLHttpRequest` 以使用 `xhr.upload.onprogress`,保留 `/v1/files/upload` 后端 API 不变。`uploadFiles(files,{onProgress})` 统一服务 Ctrl+V 粘贴、右侧上传按钮、右侧拖拽上传;粘贴时 `#chat-hint` 显示 `上传中 N% · 文件名 · 已传/总量`,完成后仍切到可预览/可删除 chip;右侧上传按钮和拖拽入口共用 `#file-upload-status` 状态条,显示总进度条和完成/失败短提示,成功后刷新文件列表。`DESIGN.md` 不动(纯 dev SPA 上传交互);`RUN.md` 不动(运行方式无变化)。 +- **dev SPA Ctrl+V 粘贴上传 chip 支持删除**:`web/static/dev.html` 粘贴上传成功后的 chip 改成 `paste-chip-wrap` 组合控件:文件名按钮继续预览,右侧 `×` 调 `POST /v1/files/delete` 删除该上传文件(`recursive:false`),删除后移除对应 chip、刷新右侧文件栏;若主/小预览当前正打开这个 rel,同步关闭对应预览。全部 chip 删除完后 `chat-hint` 显示"已删除粘贴文件"。`DESIGN.md` 不动(纯 dev SPA 交互);`RUN.md` 不动(运行方式无变化)。 - **dev SPA Ctrl+V 粘贴上传反馈改成可预览 chip**:`web/static/dev.html` `uploadFiles()` 成功时返回 `/v1/files/upload` 的 `saved[]` 元数据,粘贴文件后 `#chat-hint` 显示"已粘贴" + `.art-chip` 文件 chip + "可在右侧文件处查看",不再 4s 自动消失,下一次发送时由原有"发送中…"状态覆盖。chip 点击复用 `openFilePreview`;若主文件预览框已打开,改开新增的 `#mini-preview-modal` 小预览窗(支持 image/video/pdf/text/md,其它格式给下载兜底),避免覆盖用户当前正在看的主预览。`DESIGN.md` 不动(纯 dev SPA 交互);`RUN.md` 不动(运行方式无变化)。 - **dev SPA 三栏支持右文件栏折叠 + 左右分隔线拖拽调宽**:`web/static/dev.html` 主布局从 3 列 grid 改为 5 列 grid(任务栏 / 左 splitter / 对话栏 / 右 splitter / 文件栏),新增 `#split-left` / `#split-right` 两条 6px 拖拽分隔线,拖动时分别调整 `--left-pane-width` / `--right-pane-width` 并持久化到 localStorage(`zcbot.left-width` / `zcbot.right-width`)。右侧文件栏新增 `#pane-toggle-right`,折叠态复用左栏 rail 范式:列宽 40px,只保留展开按钮,状态持久化到 `zcbot.right-collapsed`;手机端继续走三 tab 单列,隐藏折叠按钮和 splitter,避免与移动端导航冲突。`DESIGN.md` 不动(纯 dev SPA 布局交互);`RUN.md` 不动(运行方式无变化)。 - **dev SPA 右侧文件列表长名称 hover 显示全路径**:`web/static/dev.html` 在右 pane 文件行 `.file-row .name` 和"选入…"源文件列表 `.sp-row .sp-name` 上补 `title`,内容取 `e.rel || e.name`,保留现有 ellipsis 截断视觉,鼠标悬停可看完整相对路径/名称。`DESIGN.md` 不动(无架构/心智模型变化);`RUN.md` 不动(运行方式无变化)。 diff --git a/web/static/dev.html b/web/static/dev.html index 6d7b618..a29cf50 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -422,6 +422,20 @@ .art-chip::before { content: "📄"; font-size: 11px; } .art-chip:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } #chat-hint .art-chip { margin: 0 2px; vertical-align: middle; font-family: var(--mono); } + .paste-chip-wrap { + display: inline-flex; align-items: center; max-width: 280px; margin: 0 2px; + vertical-align: middle; + } + .paste-chip-wrap .art-chip { + margin: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; + max-width: 230px; + } + .paste-chip-del { + border: 1px solid var(--border); border-left: 0; background: #fff; color: var(--muted); + border-radius: 0 999px 999px 0; padding: 2px 7px; line-height: 1.4; + font-size: 11px; cursor: pointer; transition: var(--t); + } + .paste-chip-del:hover { background: var(--c-red-bg); border-color: var(--c-red-bd); color: var(--c-red); } /* 内联图片/视频:产物 chip 替代,fetch 完直接展示 */ .art-media { border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; @@ -473,6 +487,19 @@ z-index: 10; } #file-droparea.show { display: flex; } + .upload-status { + display: none; padding: 6px 12px; border-bottom: 1px solid var(--border-soft); + background: #fff; color: var(--muted); font-size: 12px; + } + .upload-status.show { display: block; } + .upload-status .bar { + height: 4px; margin-top: 4px; background: var(--border-soft); + border-radius: 999px; overflow: hidden; + } + .upload-status .bar > span { + display: block; height: 100%; width: 0%; + background: var(--accent); transition: width .12s linear; + } /* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */ #src-picker-modal { z-index: 95; } @@ -901,6 +928,7 @@ +
加载中…
松开以上传到当前目录
@@ -1969,7 +1997,11 @@ $("chat-input").addEventListener("paste", async (e) => { const hint = $("chat-hint"); const prevHint = hint.textContent; hint.textContent = files.length === 1 ? `上传中:${files[0].name}…` : `上传中:${files.length} 个文件…`; - const saved = await uploadFiles(files); + const saved = await uploadFiles(files, { + onProgress: (loaded, total) => { + hint.textContent = formatUploadProgress(files, loaded, total); + }, + }); if (saved && saved.length) { hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} 可在右侧文件处查看`; } else { @@ -1981,17 +2013,44 @@ function renderPasteFileChips(saved) { return (saved || []).map((f) => { const rel = f.rel || f.name || ""; const name = f.name || (rel.split("/").pop() || rel); - return ``; + return ``; }).join(""); } $("chat-hint").addEventListener("click", (e) => { + const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]"); + if (del) { + e.stopPropagation(); + deletePastedFile(del.dataset.rel, del.closest(".paste-chip-wrap")); + return; + } const chip = e.target.closest && e.target.closest(".paste-chip[data-rel]"); if (!chip) return; const rel = chip.dataset.rel; if (rel) openPasteFilePreview(rel); }); +async function deletePastedFile(rel, wrap) { + if (!rel || !wrap) return; + const btn = wrap.querySelector(".paste-chip-del"); + if (btn) btn.disabled = true; + try { + await api("POST", "/v1/files/delete", { path: rel, recursive: false }); + if (_fpCurrentRel === rel) closeFilePreview(); + if (_mpCurrentRel === rel) closeMiniPreview(); + wrap.remove(); + await loadFiles(); + const hint = $("chat-hint"); + if (!hint.querySelector(".paste-chip-wrap")) { + hint.innerHTML = `已删除粘贴文件`; + } + } catch (e) { + if (btn) btn.disabled = false; + if (e.status === 401) { logout(); return; } + alert("删除失败:" + e.message); + } +} + // 润色:同步调后端,把 textarea 内容替成优化后文本。用 execCommand('insertText') // 接 textarea 原生 undo 栈 — Ctrl+Z 一次回到原文。streaming 期间允许并行(后端 // 不与主对话 run 互斥,各跑各的 LLM)。 @@ -2544,7 +2603,7 @@ $("pane-right").addEventListener("drop", async (e) => { $("file-droparea").classList.remove("show"); const files = Array.from(e.dataTransfer.files || []); if (!files.length) return; - await uploadFiles(files); + await uploadFilesWithPaneStatus(files); }); // 工具调用返回时,右侧文件可能有新增/修改 — debounce 500ms 刷新,避免每次 tool_result 都 hit API @@ -3282,25 +3341,82 @@ document.addEventListener("keydown", (e) => { if ($("file-preview-modal").classList.contains("show")) { closeFilePreview(); return; } }); -async function uploadFiles(files) { +function uploadTotalBytes(files) { + return (files || []).reduce((sum, f) => sum + (f.size || 0), 0); +} +function uploadFilesLabel(files) { + if (!files || !files.length) return ""; + return files.length === 1 ? files[0].name : `${files[0].name} 等 ${files.length} 个文件`; +} +function formatUploadProgress(files, loaded, total) { + const denom = total || uploadTotalBytes(files); + const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; + const sizeText = denom ? ` · ${humanSize(Math.min(loaded, denom))}/${humanSize(denom)}` : ""; + return `上传中 ${pct}% · ${uploadFilesLabel(files)}${sizeText}`; +} +function setPaneUploadStatus(files, loaded, total) { + const el = $("file-upload-status"); + const denom = total || uploadTotalBytes(files); + const pct = denom ? Math.min(100, Math.max(0, Math.round((loaded / denom) * 100))) : 0; + el.classList.add("show"); + el.innerHTML = `${escapeHtml(formatUploadProgress(files, loaded, total))}
`; +} +function finishPaneUploadStatus(ok, files) { + const el = $("file-upload-status"); + el.classList.add("show"); + el.innerHTML = ok + ? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}
` + : `上传失败 · ${escapeHtml(uploadFilesLabel(files))}`; + setTimeout(() => { + if (el.textContent.startsWith(ok ? "上传完成" : "上传失败")) { + el.classList.remove("show"); + el.innerHTML = ""; + } + }, 3500); +} +async function uploadFilesWithPaneStatus(files) { + if (!files || !files.length) return null; + setPaneUploadStatus(files, 0, uploadTotalBytes(files)); + const saved = await uploadFiles(files, { + onProgress: (loaded, total) => setPaneUploadStatus(files, loaded, total), + }); + finishPaneUploadStatus(!!(saved && saved.length), files); + return saved; +} + +async function uploadFiles(files, opts = {}) { if (!files || !files.length) return null; const fd = new FormData(); fd.append("path", state.filesPath || ""); for (const f of files) fd.append("files", f); try { - const r = await fetch("/v1/files/upload", { - method: "POST", - headers: { "Authorization": "Bearer " + state.token }, - body: fd, + const data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/v1/files/upload"); + xhr.setRequestHeader("Authorization", "Bearer " + state.token); + xhr.upload.onprogress = (ev) => { + if (opts.onProgress && ev.lengthComputable) opts.onProgress(ev.loaded, ev.total); + }; + xhr.onerror = () => reject(new Error("网络错误,上传失败")); + xhr.onload = () => { + let payload = {}; + try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } + catch (_) { payload = {}; } + if (xhr.status < 200 || xhr.status >= 300) { + const err = new Error(payload.detail || (xhr.status + " 上传失败")); + err.status = xhr.status; + reject(err); + return; + } + resolve(payload); + }; + if (opts.onProgress) opts.onProgress(0, uploadTotalBytes(files)); + xhr.send(fd); }); - if (!r.ok) { - const d = await r.json().catch(() => ({})); - throw new Error(d.detail || (r.status + " 上传失败")); - } - const data = await r.json(); await loadFiles(); return data.saved || []; } catch (e) { + if (e.status === 401) { logout(); return null; } alert("上传失败:" + e.message); return null; } @@ -3310,7 +3426,7 @@ async function uploadSelected() { const inp = $("upload-input"); const files = Array.from(inp.files || []); try { - await uploadFiles(files); + await uploadFilesWithPaneStatus(files); } finally { inp.value = ""; // 允许重新选同名文件 }