Show upload progress
This commit is contained in:
parent
6e33f07bfb
commit
7a0d03fb29
|
|
@ -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` 不动(运行方式无变化)。
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<button id="btn-refresh-files" class="small">↻</button>
|
||||
<button id="pane-toggle-right" class="small" title="折叠文件列表">›</button>
|
||||
</div>
|
||||
<div id="file-upload-status" class="upload-status"></div>
|
||||
<div id="file-crumbs" class="crumbs muted">加载中…</div>
|
||||
<div id="file-list"></div>
|
||||
<div id="file-droparea">松开以上传到当前目录</div>
|
||||
|
|
@ -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)} <span class="muted">可在右侧文件处查看</span>`;
|
||||
} 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 `<button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button>`;
|
||||
return `<span class="paste-chip-wrap" data-rel="${escapeHtml(rel)}"><button type="button" class="art-chip paste-chip" data-rel="${escapeHtml(rel)}" title="${escapeHtml(rel)} · 点击预览">${escapeHtml(name)}</button><button type="button" class="paste-chip-del" data-rel="${escapeHtml(rel)}" title="删除该粘贴文件">×</button></span>`;
|
||||
}).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 = `<span class="muted">已删除粘贴文件</span>`;
|
||||
}
|
||||
} 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))}<div class="bar"><span style="width:${pct}%"></span></div>`;
|
||||
}
|
||||
function finishPaneUploadStatus(ok, files) {
|
||||
const el = $("file-upload-status");
|
||||
el.classList.add("show");
|
||||
el.innerHTML = ok
|
||||
? `上传完成 · ${escapeHtml(uploadFilesLabel(files))}<div class="bar"><span style="width:100%"></span></div>`
|
||||
: `上传失败 · ${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 = ""; // 允许重新选同名文件
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue