表格传图 · 批量贴凭证
> 【使用前必读】请先在 SkillHub 下载并安装本技能,装完才能在对话里触发:
> 搜索「表格传图」点「安装」,或执行 skillhub install row-click-paste-upload
点这一行,Ctrl+V,截图就贴进这家店的格子里——不用每格找上传按钮,也不会贴错行。
适用什么场景
- 对账 / 台账表格里,每家门店、每条记录要挂截图(扣款凭证、签收单、聊天记录)
- 几十行数据要逐行贴图,每格一个「上传」按钮点到手酸
- 用户习惯用微信 / QQ 截图,复制完在网页里找不到地方贴
- 表格已经上线,因为「贴错行」被投诉过,要补防护
- 想给已有表格加图片附件能力,又不想大改表格结构
解决什么问题
- 贴错行:A 店的凭证贴到 B 店那一格,对账时两头懵——资金凭证贴错是事故,不是小事
- 热区太小:几十像素的「上传」按钮混在密密麻麻的数据里,每次都要瞄准,手抖点不准
- 截图无处安放:微信 / QQ 截图复制到剪贴板,网页里只有文件选择器,进不去
- 图进得去出不来:贴进去的图想再发给门店,还得去本机文件夹翻原图
每格一个上传按钮的默认做法,这四条一条都解决不了。
优势点
| 对比项 | 每格一个「上传」按钮 | 用本方法 |
|---|---|---|
| 点击热区 | 几十像素的小按钮 | 整行任意位置 |
| 贴截图 | 只能翻文件夹选文件 | Ctrl+V 直接贴 |
| 贴错行 | 靠人不出错 | 粘贴目标只由点击决定,不跟鼠标划过 |
| 选中可见 | 无 | 目标行高亮 + 提示「已选中:某某」 |
| 取回图片 | 去文件夹翻原图 | 一键复制回剪贴板,直接发微信 |
独有的一条:讲文件上传的资料很多,但几乎没人讲粘贴目标该由什么决定——鼠标划过是「路过」,点击才是「到达」,这一条决定了凭证会不会贴错行。
怎么调用 / 触发
第一步:先拿到它
在 SkillHub 搜「表格传图」,点「安装」;或命令行:
skillhub install row-click-paste-upload
装完常驻在你的技能目录,以后在对话里说话就能触发,不用每次重装。
第二步:在对话里直接说
- 「表格里怎么让用户一行一行贴截图」
- 「点一行就能 Ctrl+V 贴图,怎么做」
- 「上传按钮太小,用户点不准」
- 「怎么防止凭证贴错行」
AI 会加载本技能,依次给出:交互原则 → 可直接抄的事件委托代码 → 5 个必踩的坑 → 一个双击即用的演示页。
第三步:装了和没装差在哪
不装,AI 也能写个点击上传的监听,但「划过是路过、点击才是到达」这条原则它想不到——用户鼠标扫过整行,凭证就贴到了最后划过的那一行上,而且通常要等到对账才发现贴错了。
装了,直接拿走判断规则、事件委托代码、5 个坑的清单,以及一个双击就能打开的演示页。
边界
- 只解决前端交互(怎么选目标、怎么贴、怎么防错),不含后端存储与鉴权
- 依赖浏览器的粘贴 / 拖拽接口(clipboardData、dataTransfer),现代浏览器均支持
- 「复制出剪贴板」需要 clipboard 接口,非 HTTPS 的局域网地址下可能被浏览器限制
- 演示页的上传接口是 stub,接自家后端只需替换一处
实施细节(给 AI 与开发者)
要解决的真实问题
对账/台账类系统里,最常见的录入动作之一是:把截图贴到表格里对应的那一行(扣款凭证、签收单、聊天记录截图……)。默认实现——每格一个「上传」小按钮——有四个体验死角:
- 热区太小:一个几十像素的按钮混在一行密密麻麻的数据里,每次都要瞄准
- 剪贴板截图无处安放:用户用微信/QQ 截图后 Ctrl+C,网页里只有文件选择器,剪贴板里的图根本进不去
- 贴错行:上传完不知道进了谁的格子;或者粘贴目标跟着鼠标走,划过别的行就贴错 —— 资金凭证贴错门店是事故,用户发现不了、对账时两头懵
- 图进得去出不来:想把这图发给门店,还得去本机文件夹翻原图
解法是三个动作全部降低门槛:点行选中(任意位置)→ Ctrl+V 粘贴 → 拖拽入格,再配上防贴错和「复制出剪贴板」。
交互设计原则
原则 1:粘贴目标只能由「点击」决定,绝不跟随 hover
// ❌ mouseover 设目标 —— 鼠标从 A 行划到 B 行的途中划过 C 行,目标就变成 C
row.onmouseover = () => evTarget = row.dataset.store;
// ✅ click 设目标 —— 用户明确点了才算
row.addEventListener("click", () => { evTarget = row.dataset.store; focusRow(evTarget); });
这是整个交互里最重要的规则。hover 是「路过」,click 是「到达」。凭证归属错了,钱的归属就错了。
原则 2:整行都是热区
点门店名、点金额、点备注——任何一个单元格都把该行设为粘贴目标。用户的注意力在哪一行,热区就应该覆盖哪一行,而不是逼他瞄准某个按钮。
原则 3:选中状态必须可见
目标行高亮(边框/底色),页面提示文案里明确「已选中:某某」。用户按 Ctrl+V 前一眼就能确认「现在贴给谁」。
原则 4:没选中时给出可操作的提示
if (!evTarget) return toast("请先点一下目标门店这一行(整行任意位置都可以),再按 Ctrl+V 粘贴截图");
别只说「请先选择」,要告诉他怎么选(整行任意位置都可以)——这半句话能省掉一轮支持成本。
核心代码(事件委托版,可直接抄)
行点击选中 + 按钮不被误伤
const scope = document.getElementById("ev-scope"); // 表格容器
scope.onclick = e => {
const t = e.target.closest("[data-act]");
if (!t) {
// 没点到任何按钮 → 检查是否点了某一行,是则设为粘贴目标
const row = e.target.closest("tr[data-store]");
if (row) { evTarget = row.dataset.store; focusRow(evTarget); }
return;
}
// 点到了操作按钮 → 走按钮逻辑,不要改变选中目标
const act = t.dataset.act;
if (act === "upload") pickFile(t.dataset.store);
else if (act === "copy") copyImage(t.dataset.id);
else if (act === "del") deleteImage(t.dataset.id);
else if (act === "zoom") lightbox(url(t.dataset.id));
};
注意顺序:先查 closest("[data-act]")(按钮),再查行。否则点「删除」按钮会先触发行选中,行为打架。事件委托挂在容器上,重渲染行也不需要重绑。
全局 paste 监听(限定范围 + preventDefault)
document.addEventListener("paste", e => {
// 范围限定:只在目标页签激活 + 月份已选时接管,其他页面粘贴不受影响
const tab = document.getElementById("tab-settle");
if (!tab || !tab.classList.contains("active") || !evMonth) return;
const items = [...((e.clipboardData && e.clipboardData.items) || [])]
.filter(i => i.kind === "file" && i.type && i.type.startsWith("image/"));
if (!items.length) return; // 不是图片 → 放行(别拦截文本粘贴)
e.preventDefault(); // 关键:阻止浏览器默认行为(如打开图片)
if (!evTarget) return toast("请先点一下目标门店这一行(整行任意位置都可以),再按 Ctrl+V 粘贴截图");
const files = items.map(i => i.getAsFile()).filter(Boolean);
if (files.length) upload(files, evTarget);
});
拖拽入格
scope.querySelectorAll(".ev-cell").forEach(cell => {
cell.addEventListener("dragover", e => { e.preventDefault(); cell.classList.add("over"); });
cell.addEventListener("dragleave", () => cell.classList.remove("over"));
cell.addEventListener("drop", e => {
e.preventDefault();
cell.classList.remove("over");
const fs = [...e.dataTransfer.files].filter(f => f.type && f.type.startsWith("image/"));
if (fs.length) upload(fs, cell.dataset.store); // 拖到谁头上就归谁
else toast("请拖入图片文件(png / jpg / webp)");
});
});
拖拽是三种入口里唯一天然防贴错的:图拖到哪格就归哪格,不存在目标状态。
选中行失效回退
重渲染或切月后,之前选中的门店可能已经不在列表里。必须校验回退,否则 Ctrl+V 会静默贴给一个不存在的店:
const stores = cells.map(c => c.dataset.store);
if (!stores.includes(evTarget)) evTarget = stores[0] || "";
focusRow(evTarget);
附件格子的配套功能
| 功能 | 实现要点 |
|---|---|
| 缩略图列表 | 按门店聚合渲染,<img> 直接指向后端文件接口,title 带文件名+上传时间 |
| 放大预览 | 点缩略图开全屏灯箱(黑底大图 + 点击空白关闭),核对截图细节必备 |
| 复制出剪贴板 | 每张图一个「复制」按钮 → fetch 图片 → navigator.clipboard.write —— 贴进来的图能直接 Ctrl+V 到微信发给门店,不用去文件夹翻原图 |
| 删除 | 必须 confirm("确定删除这张凭证截图?(不可恢复)")——凭证删错没有回收站 |
| 上传按钮兜底 | 「+截图」按钮保留,走文件选择器(有些人就想翻文件夹);title 里写清楚三种上传方式 |
必踩的 5 个坑
- hover 跟随目标:上面说了,粘贴目标必须 click 决定。这是贴错行的头号根源。
- paste 监听全局泄漏:不做页签/状态限定,用户在别的输入框粘贴文本也会触发上传逻辑。限定了范围之后,
items里没有图片要直接 return 放行——别拦截正常文本粘贴。 - 漏掉
e.preventDefault():不阻止默认行为,浏览器可能把截图当文件打开或插入到焦点元素,表现成「贴了但没反应」。 - 事件委托顺序:
closest("[data-act]")要在行选中之前判断,否则点按钮会同时触发行选中,两种行为打架。 - 选中行失效:切月/重渲染后 evTarget 指向已消失的门店,粘贴静默失败或贴错。渲染完必须校验回退。
提示文案的写法
所有提示都要带下一步动作:
- ❌「请先选择门店」
- ✅「请先点一下目标门店这一行(整行任意位置都可以),再按 Ctrl+V 粘贴截图」
- ❌「上传失败」
- ✅「这张不是图片文件,请贴 png / jpg 截图,或拖图片文件进格子」
演示页
demo_row_upload.html 双击即可在浏览器打开:3 行示例数据 + 完整交互(点行选中/高亮、Ctrl+V 粘贴、拖拽入格、缩略图预览、删除确认)。上传在后端接口处 stub 掉,交互链路完整可体验。改造时把这个文件的 JS 搬进你的表格即可。
自查清单
- [ ] 粘贴目标只由 click 决定,全文件搜一遍确认没有 mouseover/mouseenter 设目标
- [ ] paste 监听限定了页签与状态,非图片粘贴放行
- [ ] preventDefault 在有图片时才调用
- [ ] 按钮判断先于行选中
- [ ] 渲染后校验 evTarget,失效回退到第一行
- [ ] 选中行高亮可见,提示文案带操作方法
- [ ] 删除有 confirm,复制出剪贴板可用