diff --git a/docs/superpowers/plans/2026-11-14-web-attachment-copy-clipboard.md b/docs/superpowers/plans/2026-11-14-web-attachment-copy-clipboard.md new file mode 100644 index 000000000..9057e2ed5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-14-web-attachment-copy-clipboard.md @@ -0,0 +1,77 @@ +# Web 附件图片复制到剪贴板 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Web 管理端公共附件展示处提供「复制」按钮,将图片写入系统剪贴板。 + +**Architecture:** 复用 `fetchAttachmentBlob` 取得图片 Blob,经 Clipboard API(`ClipboardItem` / PNG)写入剪贴板;在 `attachment-upload-field`、`attachment-preview-dialog` 及主要列表附件单元格与「下载」同级增加显式按钮。不改后端、不改小程序。 + +**Tech Stack:** Vue 3、TypeScript、Element Plus、现有 `web-admin/src/utils/download-attachment.ts` / `clipboard.ts` + +**Spec:** `docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md` +**Issue:** https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28 + +--- + +## File map + +| 文件 | 职责 | +| --- | --- | +| `web-admin/src/utils/clipboard.ts` | 新增图片 Blob / 附件图片复制 API;保留现有文本复制 | +| `web-admin/src/utils/clipboard.image.test.ts`(或同目录既有测试风格) | 工具函数单测(mock clipboard / fetch) | +| `web-admin/src/components/business/attachment-upload-field/index.vue` | 卡片操作区 + 预览工具栏「复制」 | +| `web-admin/src/components/business/attachment-preview-dialog/index.vue` | 预览工具栏「复制」 | +| `web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue` 等列表单元格 | 下载旁图片复制(按现有下载模式) | + +--- + +### Task 1: 剪贴板图片工具函数 + +**Files:** +- Modify: `web-admin/src/utils/clipboard.ts` +- Create/Modify: 同目录或 `web-admin` 既有测试约定下的单测文件 + +- [x] **Step 1:** 实现 `ensurePngBlob(blob)`(已是 png 则直通,否则 ImageBitmap/canvas → PNG) +- [x] **Step 2:** 实现 `copyImageBlobToClipboard(blob): Promise`(`navigator.clipboard.write` + `ClipboardItem`) +- [x] **Step 3:** 实现 `copyAttachmentImageToClipboard(file, options?)`:非图片返回 false;否则 `fetchAttachmentBlob` → `copyImageBlobToClipboard` +- [x] **Step 4:** 补充单元测试(mock `navigator.clipboard.write`、非图片短路、PNG 直通) +- [x] **Step 5:** 运行相关测试通过 + +### Task 2: 公共附件卡与内嵌预览 + +**Files:** +- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue` + +- [x] **Step 1:** 卡片 footer / compact 下载旁增加复制按钮(图片用图片复制;非图片可用 `copyTextToClipboard` 复制 URL 或隐藏复制) +- [x] **Step 2:** 图片预览工具栏增加「复制」按钮与 loading/禁用态 +- [x] **Step 3:** 成功/失败 `ElMessage` 文案与设计一致 + +### Task 3: 独立预览弹窗 + +**Files:** +- Modify: `web-admin/src/components/business/attachment-preview-dialog/index.vue` + +- [x] **Step 1:** 图片工具栏增加「复制」,调用同一工具函数 +- [x] **Step 2:** 仅 `kind === 'image'` 显示 + +### Task 4: 主要列表附件单元格 + +**Files:** +- Modify: `pl-plan-document-attachment-cell`、`pl-cert-list-attachment-cell`、`pl-carrier-list-attachment-cell`、`pl-plan-list-cert-cell`(有下载角标处) + +- [x] **Step 1:** 图片项在下载旁增加复制,`@click.stop` +- [x] **Step 2:** 非图片不显示图片复制(或仅复制链接,与 Task 2 策略一致) + +### Task 5: 验证 + +- [x] **Step 1:** `pnpm exec tsx --test src/utils/clipboard.image.test.ts` — 5 pass +- [x] **Step 2:** `vue-tsc --noEmit`:本次改动文件无新增相关错误;全仓仍有 workflow 等既有错误与缺失 `.auto-import.json` 导致的 `reactive` 误报 +- [ ] **Step 3:** 手工:预览弹窗复制图片后粘贴到微信/Word(待本机验收) + +--- + +## 统一交互策略(实现锁定) + +- 图片:复制位图到剪贴板;成功「已复制到剪贴板」 +- 非图片:卡片/列表显示「复制链接」(`copyTextToClipboard`),成功「已复制链接」;预览弹窗非图片无复制按钮 +- 失败:统一「复制失败,请允许剪贴板权限或改用下载」 diff --git a/docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md b/docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md new file mode 100644 index 000000000..d9a104205 --- /dev/null +++ b/docs/superpowers/specs/2026-11-14-web-attachment-copy-clipboard-design.md @@ -0,0 +1,98 @@ +# Web 附件图片复制到剪贴板 + +工单:https://gitea.aichengyan.com/cykj/shanxia2.0/issues/28 + +## 背景与问题 + +系统多处图片/文件展示提供「下载」,用户希望再提供「复制」,将图片快速写入操作系统剪贴板,便于粘贴到微信、Word 等。场景分布在列表缩略图、表单附件卡、详情只读与预览弹窗,入口需统一,避免逐页发明交互。 + +## 已确认决策 + +| 项 | 结论 | 依据 | +| --- | --- | --- | +| 修改端 | 仅 `web-admin`(桌面 Web;移动 H5 若复用同一附件组件则自动获得) | 用户 2026-11-14 确认「只修改 web 端,不改小程序端」 | +| 主入口 | 与「下载」同级的显式「复制」按钮;预览弹窗工具栏必有 | 对话方案获用户认可 | +| 图片行为 | 写入系统剪贴板为图片(`image/png` 优先) | Clipboard API + 用户诉求 | +| 非图片 | 不写入文件本体;可「复制链接」或仅保留下载 | 浏览器对任意文件剪贴板支持差 | +| 右键 / 快捷键 | 本期不做主入口(可不做) | 发现成本高、易与表格/输入焦点冲突 | +| 小程序 | 本期不改 | 用户明确排除;且无法位图复制 | +| Figma 原型 | 免除,按现有下载旁按钮样式实现 | 用户 2026-11-14 选择「1」;此前推荐亦到期可采用 | + +## 方案对比 + +| 方案 | 做法 | 优点 | 缺点 | +| --- | --- | --- | --- | +| A(推荐) | 公共工具函数 + 公共附件/预览组件加按钮 | 一处覆盖列表/表单/详情多数场景;与下载同发现路径 | 少数自建附件 UI 需二次补齐 | +| B | 各业务页单独加复制 | 可控精细 | 遗漏多、风格易漂 | +| C | 仅右键 / 快捷键 | UI 干净 | B 端发现差,触控与 H5 弱 | + +**采用方案 A。** + +## 范围 + +**包含** + +- `web-admin`:剪贴板工具(图片 Blob → `navigator.clipboard.write`) +- 复用现有 `fetchAttachmentBlob`(`download-attachment.ts`)取内容 +- 公共组件入口: + - `attachment-upload-field`:卡片操作区 / compact 下载旁;图片预览工具栏 + - `attachment-preview-dialog`:图片预览工具栏 + - 主要列表附件单元格(如 `pl-*-attachment-cell`、证书列表单元格等已有下载角标处)按同一模式补「复制」(仅图片显示图片复制;非图片可选复制链接或隐藏) +- 成功 / 失败 `ElMessage` 提示;安全上下文或权限失败时明确提示 + +**不包含** + +- `miniprogram/` 任何改动 +- 后端 API / schema 变更(沿用现有预签名下载) +- 右键菜单、全局快捷键作为主入口 +- PDF/Office 等「复制文件本体」进剪贴板 +- 强制改造所有业务页内联重复附件 UI(优先公共组件;遗漏页可后续补) + +## 前端设计 + +### 工具函数 + +在 `web-admin/src/utils/clipboard.ts`(或同目录新建 `copy-attachment.ts` 并由 clipboard 导出)增加: + +- `copyImageBlobToClipboard(blob: Blob): Promise` + - 优先 `ClipboardItem` 写入;若类型非 `image/png`,经 canvas/`createImageBitmap` 转为 PNG 再写(兼容 JPEG 等) + - 无 Clipboard API / 非安全上下文 / 权限拒绝 → 返回 false +- `copyAttachmentImageToClipboard(file, options?): Promise` + - 内部:`detectAttachmentPreviewKind` 非 image 则失败或降级复制 URL 文本(由调用方决定文案) + - `fetchAttachmentBlob` → `copyImageBlobToClipboard` + +非图片「复制链接」可复用已有 `copyTextToClipboard`。 + +### UI 放置 + +1. **附件卡片**(`attachment-upload-field`):`下载` 旁增加复制图标按钮;仅当判定为图片时执行图片复制,其它类型可复制链接或只显示下载。 +2. **预览弹窗工具栏**(upload-field 内嵌预览 + `attachment-preview-dialog`):在重置等按钮后增加「复制」;仅 `kind === 'image'` 时显示。 +3. **列表缩略图角标**:已有下载角标的单元格,图片项增加复制角标或与下载并列,点击 `@click.stop`,避免触发行预览。 + +按钮样式、图标遵循 Fresh Mint / 现有 `action-btn` 与预览工具栏按钮规范(图标 + title 或短文案「复制」)。 + +### 数据与网络 + +- 剪贴板需要 Blob,不能只拷 DOM。 +- 使用现有预签名下载路径取 Blob;常命中浏览器缓存,用户体感可接受。 +- 不新增后端接口。 + +### 兼容与降级 + +| 情况 | 行为 | +| --- | --- | +| 现代桌面 Chrome / Edge | 图片写入剪贴板,成功提示「已复制到剪贴板」 | +| 权限 / 非 HTTPS / 旧浏览器 | 失败提示「复制失败,请允许剪贴板权限或改用下载」;可选降级复制图片 URL | +| 移动 H5(同组件) | 尽力而为;失败走同一提示,不单独做小程序方案 | + +## 验收条件 + +1. Web 管理端:在公共附件卡、图片预览弹窗可见「复制」,点击后可将图片粘贴到本机支持图片剪贴板的应用(如预览/Word/微信桌面,视 OS 而定)。 +2. 非图片附件:不出现误导性的「复制图片」成功;链接复制或仅下载,行为与提示一致。 +3. 小程序目录无本次改动。 +4. 无后端变更;相关前端 lint / typecheck 通过(按实施计划执行)。 + +## 风险与后续 + +- MinIO / 预签名 CORS 若阻止 `fetch` Blob,复制会失败(下载走 `` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。 +- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。 diff --git a/web-admin/src/components/business/attachment-preview-dialog/index.vue b/web-admin/src/components/business/attachment-preview-dialog/index.vue index 728a4d021..73ef65a53 100644 --- a/web-admin/src/components/business/attachment-preview-dialog/index.vue +++ b/web-admin/src/components/business/attachment-preview-dialog/index.vue @@ -45,6 +45,16 @@ 重置 + + + + import { computed } from 'vue' - import { Document, Download, Picture } from '@element-plus/icons-vue' + import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import type { CertRead } from '@/api/pl' import { @@ -68,6 +75,10 @@ normalizeAttachmentUrls, openAttachmentPreviewInNewTab } from '@/utils/download-attachment' + import { + copyAttachmentImageToClipboard, + copyTextToClipboard + } from '@/utils/clipboard' import { resolvePhotoDisplayUrl } from '@/utils/pl-photo' import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert' @@ -181,6 +192,26 @@ } } + async function handleCopy(item: ItemDef) { + if (!item.url) { + ElMessage.warning('暂无可复制内容') + return + } + const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) } + if (item.isImage) { + const ok = await copyAttachmentImageToClipboard(file, { + fullFileUrl: resolvePhotoDisplayUrl + }) + if (ok) ElMessage.success('已复制到剪贴板') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + return + } + const href = resolvePhotoDisplayUrl(item.url) || item.url + const ok = await copyTextToClipboard(href) + if (ok) ElMessage.success('已复制链接') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + } + const items = computed(() => { const certs = props.certList || [] if (!certs.length) return [] @@ -305,9 +336,9 @@ color: #0284c7; } - .pl-cert-list-attachment-cell__download { + .pl-cert-list-attachment-cell__download, + .pl-cert-list-attachment-cell__copy { position: absolute; - right: 2px; top: 2px; z-index: 2; width: 18px; @@ -325,6 +356,14 @@ } } + .pl-cert-list-attachment-cell__download { + right: 2px; + } + + .pl-cert-list-attachment-cell__copy { + right: 22px; + } + .pl-cert-list-attachment-cell__empty-text { margin-bottom: 14px; font-size: 11px; diff --git a/web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue b/web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue index f2fe25ff1..186164728 100644 --- a/web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue +++ b/web-admin/src/components/business/pl-plan-document-attachment-cell/index.vue @@ -40,6 +40,13 @@ }} + + + import { computed, ref } from 'vue' - import { Document, Download, Picture } from '@element-plus/icons-vue' + import { Document, DocumentCopy, Download, Picture } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import type { PlanDocumentAttachmentItem } from '@/api/pl' import AttachmentPreviewDialog from '@/components/business/attachment-preview-dialog/index.vue' @@ -71,6 +78,10 @@ downloadAttachmentFile, openAttachmentPreviewInNewTab } from '@/utils/download-attachment' + import { + copyAttachmentImageToClipboard, + copyTextToClipboard + } from '@/utils/clipboard' import { resolvePhotoDisplayUrl } from '@/utils/pl-photo' const props = withDefaults( @@ -170,6 +181,26 @@ } } + async function handleCopy(item: ItemDef) { + if (!item.url) { + ElMessage.warning('暂无可复制内容') + return + } + const file = { fileUrl: item.url, fileName: fileNameFromUrl(item.url) } + if (item.isImage) { + const ok = await copyAttachmentImageToClipboard(file, { + fullFileUrl: resolvePhotoDisplayUrl + }) + if (ok) ElMessage.success('已复制到剪贴板') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + return + } + const href = resolvePhotoDisplayUrl(item.url) || item.url + const ok = await copyTextToClipboard(href) + if (ok) ElMessage.success('已复制链接') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + } + const items = computed(() => { const list = props.attachments || [] return list @@ -260,9 +291,9 @@ color: #64748b; } - .pl-plan-document-attachment-cell__download { + .pl-plan-document-attachment-cell__download, + .pl-plan-document-attachment-cell__copy { position: absolute; - right: 2px; top: 2px; z-index: 2; width: 18px; @@ -281,6 +312,14 @@ } } + .pl-plan-document-attachment-cell__download { + right: 2px; + } + + .pl-plan-document-attachment-cell__copy { + right: 22px; + } + .pl-plan-document-attachment-cell__caption { position: absolute; right: 0; diff --git a/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue b/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue index 1bc126e01..52044a72d 100644 --- a/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue +++ b/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue @@ -142,6 +142,17 @@ + + + import { computed } from 'vue' - import { Document, Download, Upload } from '@element-plus/icons-vue' + import { Document, DocumentCopy, Download, Upload } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus' import type { PlanProduct } from '@/api/pl' import { @@ -183,6 +194,10 @@ downloadAttachmentFile, openAttachmentPreviewInNewTab } from '@/utils/download-attachment' + import { + copyAttachmentImageToClipboard, + copyTextToClipboard + } from '@/utils/clipboard' import { resolvePhotoDisplayUrl } from '@/utils/pl-photo' const props = withDefaults( @@ -295,6 +310,27 @@ ElMessage.error('下载失败,请稍后重试') } } + + async function handleCopyCert(url: string) { + const fileUrl = String(url || '').trim() + if (!fileUrl) { + ElMessage.warning('暂无可复制内容') + return + } + const file = { fileUrl, fileName: getCertFileName(fileUrl) || '附件' } + if (isCertImageUrl(fileUrl)) { + const ok = await copyAttachmentImageToClipboard(file, { + fullFileUrl: resolveCertFileUrl + }) + if (ok) ElMessage.success('已复制到剪贴板') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + return + } + const href = resolveCertFileUrl(fileUrl) || fileUrl + const ok = await copyTextToClipboard(href) + if (ok) ElMessage.success('已复制链接') + else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载') + }