feat(web): 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)

附件 chip 拆出独立托盘 #chat-attach,与状态文字解耦:append+按 rel 去重,
上传进度只写 #chat-hint,不再互相覆盖。整个 #chat-form 加 dragenter/over/
leave/drop(计数防闪烁,只认文件拖拽,微信镜像只读不接收),复用 uploadFiles。
takePastedRels/删除/预览改查托盘;切 task 清残留未发送 chip。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-26 14:38:58 +08:00
parent d235cb7564
commit e49ff641f9
4 changed files with 105 additions and 25 deletions

View File

@ -21,6 +21,12 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-26 / 消息框支持拖拽文件 + 修多次粘贴互相顶掉(bump 0.31.3)
- 现象:① 消息框只能粘贴文件不能拖拽;② 连粘多个文件,后一个把前一个的 chip 顶掉,只剩一个。
- 根因:粘贴附件 chip 和状态文字共用 `#chat-hint`,每次粘贴用 `innerHTML =` 整体重建只塞最新一批,且上传进度回调写 `hint.textContent` 也会清掉已有 chip——附件与状态文字抢同一个容器。
- 修复(`web/static/dev.html` + `web/static/js/chat.js`):① 新增独立 chip 托盘 `#chat-attach`(textarea 与按钮行之间),chip 累积靠 append + 按 `rel` 去重,状态进度只写 `#chat-hint`,从根上解耦;② 给整个 `#chat-form``dragenter/over/leave/drop`(enter/leave 计数防闪烁,`_dragHasFiles` 只认文件拖拽,微信镜像只读时不接收),复用 `uploadFiles` + 同一托盘;`takePastedRels` / 删除 / 预览三处改查托盘。
### 2026-06-26 / 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2) ### 2026-06-26 / 消息目录圆点错位再修(点击竞态 + 触底兜底)(bump 0.31.2)
- 现象(0.20.4 后仍残留):① 点圆点,被点的圆点不变红、活跃态跑到途经轮次(尤其点 #1 跳到 #2);② 点最后一个 / 滚到底,倒数第二个变红。 - 现象(0.20.4 后仍残留):① 点圆点,被点的圆点不变红、活跃态跑到途经轮次(尤其点 #1 跳到 #2);② 点最后一个 / 滚到底,倒数第二个变红。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.31.2" __version__ = "0.31.3"

View File

@ -855,6 +855,12 @@
/* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */ /* 微信渠道 task 只读镜像:输入框置灰禁手输,引导去微信对话 */
#chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; } #chat-input.readonly-locked { background: #f0f0f0; color: var(--muted); cursor: not-allowed; }
#chat-form .hint { font-size: 11px; color: var(--muted); } #chat-form .hint { font-size: 11px; color: var(--muted); }
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
#chat-attach.show { display: flex; }
/* 拖拽文件悬停整个输入区:虚线高亮提示可落点 */
#chat-form.drag-over { outline: 2px dashed var(--accent); outline-offset: -4px; background: var(--accent-soft); }
#chat-form.drag-over * { pointer-events: none; }
/* ───── files ───── */ /* ───── files ───── */
.crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; } .crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
@ -1505,7 +1511,8 @@
<!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 --> <!-- 消息目录:悬浮在对话右缘的圆点轨道,每点 = 一轮提问;hover 出标题,点击定位 -->
<div id="msg-outline-rail" style="display:none;"></div> <div id="msg-outline-rail" style="display:none;"></div>
<form id="chat-form" style="display:none;"> <form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea> <textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea>
<span id="chat-attach"></span>
<div class="row"> <div class="row">
<span class="hint" id="chat-hint">就绪</span> <span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>

View File

@ -330,7 +330,7 @@ export async function selectTask(tid) {
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
// 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同, // 切 task 清掉上个 task 累积的 inline media blob URL — 新 task 的 rel 不同,
// 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。 // 旧 URL 留着只占内存。同 task 切回(tid === state.taskId)不算切换,跳过。
if (state.taskId && state.taskId !== tid) _flushMediaArtifactCache(); if (state.taskId && state.taskId !== tid) { _flushMediaArtifactCache(); clearAttachTray(); }
state.taskId = tid; state.taskId = tid;
document.querySelectorAll(".task-row").forEach((el) => { document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid); el.classList.toggle("active", el.dataset.tid === tid);
@ -1034,14 +1034,55 @@ $("chat-input").addEventListener("keydown", (e) => {
} }
}); });
$("chat-input").addEventListener("input", syncOptimizeBtn); $("chat-input").addEventListener("input", syncOptimizeBtn);
// 粘贴含文件 → 直接上传到当前目录(复用拖拽通路);纯文本走默认 // 粘贴 / 拖拽含文件 → 上传到当前目录,chip 累积进 #chat-attach 托盘(与状态文字解耦,
// 反馈走 chat-hint:上传中 → 已粘贴 + chip;下一次发送会自然覆盖为"发送中…" // 避免上传进度 / 下一次粘贴把已有 chip 顶掉)。状态反馈仍走 #chat-hint;纯文本粘贴走默认
$("chat-input").addEventListener("paste", async (e) => { $("chat-input").addEventListener("paste", (e) => {
const files = Array.from(e.clipboardData?.files || []); const files = Array.from(e.clipboardData?.files || []);
if (!files.length) return; if (!files.length) return;
e.preventDefault(); e.preventDefault();
uploadAttachFiles(files);
});
// 拖拽落点 = 整个 #chat-form(命中面积大);用 enter/leave 计数防子元素冒泡时高亮闪烁。
// 只认文件拖拽(_hasFiles),只读镜像(微信渠道)不接收。
let _composerDragDepth = 0;
function _dragHasFiles(ev) {
const t = ev.dataTransfer;
return !!(t && t.types && [...t.types].includes("Files"));
}
function _composerLocked() {
const input = $("chat-input");
return !!(input && input.readOnly);
}
$("chat-form").addEventListener("dragenter", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
_composerDragDepth++;
$("chat-form").classList.add("drag-over");
});
$("chat-form").addEventListener("dragover", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
$("chat-form").addEventListener("dragleave", (e) => {
if (!_dragHasFiles(e)) return;
_composerDragDepth = Math.max(0, _composerDragDepth - 1);
if (_composerDragDepth === 0) $("chat-form").classList.remove("drag-over");
});
$("chat-form").addEventListener("drop", (e) => {
if (_composerLocked() || !_dragHasFiles(e)) return;
e.preventDefault();
_composerDragDepth = 0;
$("chat-form").classList.remove("drag-over");
const files = Array.from(e.dataTransfer.files || []);
if (files.length) uploadAttachFiles(files);
});
// 上传一批文件并把结果 chip 追加进托盘。状态写 #chat-hint(进度 / 已上传),不碰 chip。
async function uploadAttachFiles(files) {
if (!files || !files.length) return;
const hint = $("chat-hint"); const hint = $("chat-hint");
const prevHint = hint.textContent;
hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`; hint.textContent = files.length === 1 ? `上传中:${files[0].name}` : `上传中:${files.length} 个文件…`;
const saved = await uploadFiles(files, { const saved = await uploadFiles(files, {
onProgress: (loaded, total) => { onProgress: (loaded, total) => {
@ -1049,21 +1090,45 @@ $("chat-input").addEventListener("paste", async (e) => {
}, },
}); });
if (saved && saved.length) { if (saved && saved.length) {
hint.innerHTML = `已粘贴 ${renderPasteFileChips(saved)} <span class="muted">可在右侧文件处查看</span>`; addAttachChips(saved);
} else { const n = attachCount();
hint.textContent = prevHint; // 失败 alert 已弹,hint 回原 hint.innerHTML = `已添加 ${saved.length} 个文件,共 ${n} 个待发送 <span class="muted">可在右侧文件处查看</span>`;
} }
}); // 失败时 uploadFiles 内部已 alert;hint 保留"上传中…"文字也无碍(下次发送会覆盖)
function renderPasteFileChips(saved) {
return (saved || []).map((f) => {
const rel = f.rel || f.name || "";
const name = f.name || (rel.split("/").pop() || rel);
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) => { function attachTray() { return $("chat-attach"); }
function attachWraps() {
const tray = attachTray();
return tray ? Array.from(tray.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
}
function attachCount() { return attachWraps().length; }
// 切 task 时清掉上个 task 残留的未发送 chip(它们指向上个 task_dir,新 task 用不上)。
// 只清 DOM,不删已上传的文件(用户可能切回去发,文件还在原目录)。
function clearAttachTray() {
const tray = attachTray();
if (!tray) return;
tray.innerHTML = "";
tray.classList.remove("show");
}
// 追加 chip,按 rel 去重(同一文件重复粘贴/拖拽只保留一个),并显示托盘。
function addAttachChips(saved) {
const tray = attachTray();
if (!tray) return;
const existing = new Set(attachWraps().map((w) => w.dataset.rel));
for (const f of saved || []) {
const rel = f.rel || f.name || "";
if (!rel || existing.has(rel)) continue;
existing.add(rel);
const name = f.name || (rel.split("/").pop() || rel);
tray.insertAdjacentHTML("beforeend",
`<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>`);
}
tray.classList.toggle("show", attachCount() > 0);
}
attachTray().addEventListener("click", (e) => {
const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]"); const del = e.target.closest && e.target.closest(".paste-chip-del[data-rel]");
if (del) { if (del) {
e.stopPropagation(); e.stopPropagation();
@ -1085,9 +1150,10 @@ async function deletePastedFile(rel, wrap) {
closePreviewIfShowing(rel); closePreviewIfShowing(rel);
wrap.remove(); wrap.remove();
await loadFiles(); await loadFiles();
const hint = $("chat-hint"); const tray = attachTray();
if (!hint.querySelector(".paste-chip-wrap")) { if (tray) tray.classList.toggle("show", attachCount() > 0);
hint.innerHTML = `<span class="muted">已删除粘贴文件</span>`; if (attachCount() === 0) {
$("chat-hint").innerHTML = `<span class="muted">已删除文件</span>`;
} }
} catch (e) { } catch (e) {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
@ -1109,7 +1175,7 @@ function applyChannelComposerLock(meta) {
input.classList.toggle("readonly-locked", !!cfg); input.classList.toggle("readonly-locked", !!cfg);
input.placeholder = cfg input.placeholder = cfg
? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史` ? `${cfg.label}对话请在${cfg.label}里进行 — web 端为只读镜像,可查看历史`
: "输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"; : "输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)";
if (cfg) { if (cfg) {
const opt = $("chat-optimize"); const opt = $("chat-optimize");
if (opt) opt.disabled = true; if (opt) opt.disabled = true;
@ -1227,10 +1293,11 @@ async function postMessageWithRetry(taskId, body) {
// 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图 // 返回路径数组并清掉 chip —— 这些路径要随消息正文发给模型,否则模型不知道用户贴了哪张图
// (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。 // (改图 / 看图都靠它定位)。只在「从输入框发送」时取,ask_user 选项点击(overrideText)不带附件。
function takePastedRels() { function takePastedRels() {
const hint = $("chat-hint"); const wraps = attachWraps();
const wraps = hint ? Array.from(hint.querySelectorAll(".paste-chip-wrap[data-rel]")) : [];
const rels = wraps.map((w) => w.dataset.rel).filter(Boolean); const rels = wraps.map((w) => w.dataset.rel).filter(Boolean);
wraps.forEach((w) => w.remove()); wraps.forEach((w) => w.remove());
const tray = attachTray();
if (tray) tray.classList.remove("show");
return rels; return rels;
} }