feat(web-admin): add copy-to-clipboard for attachment images
Let users copy images next to download on shared attachment cards, preview toolbars, and list cells so they can paste into chat or docs without saving files first. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
53987bdf22
commit
43e240ad6a
11 files changed
+699
-14
No files matched your search
@@ -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<boolean>`(`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`),成功「已复制链接」;预览弹窗非图片无复制按钮
|
||||
- 失败:统一「复制失败,请允许剪贴板权限或改用下载」
|
||||
@@ -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<boolean>`
|
||||
- 优先 `ClipboardItem` 写入;若类型非 `image/png`,经 canvas/`createImageBitmap` 转为 PNG 再写(兼容 JPEG 等)
|
||||
- 无 Clipboard API / 非安全上下文 / 权限拒绝 → 返回 false
|
||||
- `copyAttachmentImageToClipboard(file, options?): Promise<boolean>`
|
||||
- 内部:`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,复制会失败(下载走 `<a download>` 可能仍可用)→ 失败提示引导改用下载;必要时再评估代理拉取。
|
||||
- 少数未走公共组件的页面可能暂无按钮 → 后续按反馈补齐。
|
||||
@@ -45,6 +45,16 @@
|
||||
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<span class="preview-toolbar-sep" aria-hidden="true" />
|
||||
<el-button
|
||||
:size="mpBtnSize"
|
||||
:loading="copyBusy"
|
||||
:disabled="!props.fileUrl || copyBusy"
|
||||
@click="handleCopyImage"
|
||||
>
|
||||
<el-icon v-if="!isMpPreviewToolbar && !copyBusy"><DocumentCopy /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
<span class="preview-status-chip">{{ previewStatusText }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -98,17 +108,20 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Document,
|
||||
DocumentCopy,
|
||||
Refresh,
|
||||
RefreshLeft,
|
||||
RefreshRight,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
detectAttachmentPreviewKind,
|
||||
resolveAttachmentPreviewUrl,
|
||||
type AttachmentPreviewKind
|
||||
} from '@/utils/download-attachment'
|
||||
import { copyAttachmentImageToClipboard } from '@/utils/clipboard'
|
||||
import {
|
||||
IMAGE_PREVIEW_ZOOM_MIN,
|
||||
IMAGE_PREVIEW_ZOOM_MAX,
|
||||
@@ -159,6 +172,7 @@
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const copyBusy = ref(false)
|
||||
const pdfSrc = ref('')
|
||||
const previewStageRef = ref<HTMLElement>()
|
||||
const previewImgRef = ref<HTMLImageElement>()
|
||||
@@ -229,6 +243,21 @@
|
||||
nextTick(() => refreshPreviewFitBase())
|
||||
}
|
||||
|
||||
async function handleCopyImage() {
|
||||
if (kind.value !== 'image' || !props.fileUrl || copyBusy.value) return
|
||||
copyBusy.value = true
|
||||
try {
|
||||
const ok = await copyAttachmentImageToClipboard(
|
||||
{ fileUrl: props.fileUrl, fileName: props.fileName },
|
||||
{ fullFileUrl: props.resolveUrl }
|
||||
)
|
||||
if (ok) ElMessage.success('已复制到剪贴板')
|
||||
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
|
||||
} finally {
|
||||
copyBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetPreviewZoomPan() {
|
||||
previewZoom.value = 1
|
||||
previewOffsetX.value = 0
|
||||
|
||||
@@ -59,6 +59,20 @@
|
||||
>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="file.fileUrl && !file._uploading"
|
||||
type="button"
|
||||
class="att-icon att-icon--copy"
|
||||
:aria-label="
|
||||
isImageFile(file)
|
||||
? `复制图片:${file.fileName || '未命名附件'}`
|
||||
: `复制链接:${file.fileName || '未命名附件'}`
|
||||
"
|
||||
:disabled="copyBusyId === (file.id || String(index))"
|
||||
@click.stop="handleCopyAttachment(file)"
|
||||
>
|
||||
<span aria-hidden="true">⧉</span>
|
||||
</button>
|
||||
<button
|
||||
class="att-preview"
|
||||
type="button"
|
||||
@@ -145,6 +159,16 @@
|
||||
>
|
||||
<el-icon :size="12"><Delete /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="compact && disabled && file.fileUrl"
|
||||
type="button"
|
||||
class="attachment-compact-copy"
|
||||
:title="isImageFile(file) ? '复制图片' : '复制链接'"
|
||||
:disabled="copyBusyId === (file.id || String(index))"
|
||||
@click.stop="handleCopyAttachment(file)"
|
||||
>
|
||||
<el-icon :size="12"><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="compact && disabled && file.fileUrl"
|
||||
type="button"
|
||||
@@ -157,6 +181,13 @@
|
||||
<div class="attachment-footer">
|
||||
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
|
||||
<div v-if="!disabled" class="attachment-actions">
|
||||
<el-icon
|
||||
class="action-btn"
|
||||
:title="isImageFile(file) ? '复制图片' : '复制链接'"
|
||||
@click="handleCopyAttachment(file)"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
|
||||
<Download />
|
||||
</el-icon>
|
||||
@@ -169,6 +200,13 @@
|
||||
</el-icon>
|
||||
</div>
|
||||
<div v-else class="attachment-actions">
|
||||
<el-icon
|
||||
class="action-btn"
|
||||
:title="isImageFile(file) ? '复制图片' : '复制链接'"
|
||||
@click="handleCopyAttachment(file)"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
|
||||
<Download />
|
||||
</el-icon>
|
||||
@@ -222,6 +260,16 @@
|
||||
<el-icon v-if="!isMpPreviewToolbar"><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<span class="preview-toolbar-sep" aria-hidden="true" />
|
||||
<el-button
|
||||
:size="mpBtnSize"
|
||||
:loading="previewCopyBusy"
|
||||
:disabled="!previewFile?.fileUrl || previewCopyBusy"
|
||||
@click="handleCopyPreviewImage"
|
||||
>
|
||||
<el-icon v-if="!isMpPreviewToolbar && !previewCopyBusy"><DocumentCopy /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
<span class="preview-status-chip">{{ previewStatusText }}</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -278,6 +326,7 @@ import {
|
||||
Loading,
|
||||
VideoPlay,
|
||||
Document,
|
||||
DocumentCopy,
|
||||
Download,
|
||||
Delete,
|
||||
RefreshLeft,
|
||||
@@ -293,6 +342,10 @@ import {
|
||||
downloadAttachmentFile,
|
||||
resolveAttachmentPreviewUrl
|
||||
} from '@/utils/download-attachment'
|
||||
import {
|
||||
copyAttachmentImageToClipboard,
|
||||
copyTextToClipboard
|
||||
} from '@/utils/clipboard'
|
||||
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
|
||||
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
|
||||
import {
|
||||
@@ -798,6 +851,47 @@ function handleDownloadAttachment(file: AttachmentItem) {
|
||||
downloadAttachmentFile(file, { fullFileUrl: resolveFileUrl })
|
||||
}
|
||||
|
||||
const copyBusyId = ref('')
|
||||
const previewCopyBusy = ref(false)
|
||||
|
||||
async function handleCopyAttachment(file: AttachmentItem) {
|
||||
const url = String(file.fileUrl || '').trim()
|
||||
if (!url) {
|
||||
ElMessage.warning('暂无可复制内容')
|
||||
return
|
||||
}
|
||||
const busyKey = file.id || url
|
||||
if (copyBusyId.value === busyKey) return
|
||||
copyBusyId.value = busyKey
|
||||
try {
|
||||
if (isImageFile(file)) {
|
||||
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
|
||||
if (ok) ElMessage.success('已复制到剪贴板')
|
||||
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
|
||||
return
|
||||
}
|
||||
const href = resolveFileUrl(url)
|
||||
const ok = await copyTextToClipboard(href || url)
|
||||
if (ok) ElMessage.success('已复制链接')
|
||||
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
|
||||
} finally {
|
||||
if (copyBusyId.value === busyKey) copyBusyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyPreviewImage() {
|
||||
const file = previewFile.value
|
||||
if (!file?.fileUrl || previewCopyBusy.value) return
|
||||
previewCopyBusy.value = true
|
||||
try {
|
||||
const ok = await copyAttachmentImageToClipboard(file, { fullFileUrl: resolveFileUrl })
|
||||
if (ok) ElMessage.success('已复制到剪贴板')
|
||||
else ElMessage.warning('复制失败,请允许剪贴板权限或改用下载')
|
||||
} finally {
|
||||
previewCopyBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewAttachment(file: AttachmentItem) {
|
||||
if (!canPreview(file)) return
|
||||
previewFile.value = file
|
||||
@@ -1269,10 +1363,10 @@ defineExpose({ hasUploading })
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.attachment-compact-download {
|
||||
.attachment-compact-download,
|
||||
.attachment-compact-copy {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1290,6 +1384,19 @@ defineExpose({ hasUploading })
|
||||
&:hover {
|
||||
background: #1ca851;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-compact-download {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.attachment-compact-copy {
|
||||
right: 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-icon
|
||||
class="pl-carrier-list-attachment-cell__copy"
|
||||
:title="item.isImage ? '复制图片' : '复制链接'"
|
||||
@click.stop="handleCopy(item)"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="pl-carrier-list-attachment-cell__download"
|
||||
title="下载"
|
||||
@@ -50,7 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Document, Download } from '@element-plus/icons-vue'
|
||||
import { Document, DocumentCopy, Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Carrier } from '@/api/pl'
|
||||
import {
|
||||
@@ -58,6 +65,10 @@
|
||||
normalizeAttachmentUrls,
|
||||
openAttachmentPreviewInNewTab
|
||||
} from '@/utils/download-attachment'
|
||||
import {
|
||||
copyAttachmentImageToClipboard,
|
||||
copyTextToClipboard
|
||||
} from '@/utils/clipboard'
|
||||
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -160,6 +171,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<ItemDef[]>(() => {
|
||||
const defs = [
|
||||
{
|
||||
@@ -286,9 +317,9 @@
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.pl-carrier-list-attachment-cell__download {
|
||||
.pl-carrier-list-attachment-cell__download,
|
||||
.pl-carrier-list-attachment-cell__copy {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
z-index: 2;
|
||||
width: 18px;
|
||||
@@ -306,6 +337,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pl-carrier-list-attachment-cell__download {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.pl-carrier-list-attachment-cell__copy {
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.pl-carrier-list-attachment-cell__empty-text {
|
||||
margin-bottom: 14px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-icon
|
||||
class="pl-cert-list-attachment-cell__copy"
|
||||
:title="item.isImage ? '复制图片' : '复制链接'"
|
||||
@click.stop="handleCopy(item)"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="pl-cert-list-attachment-cell__download"
|
||||
title="下载"
|
||||
@@ -60,7 +67,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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<ItemDef[]>(() => {
|
||||
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;
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-icon
|
||||
class="pl-plan-document-attachment-cell__copy"
|
||||
:title="item.isImage ? '复制图片' : '复制链接'"
|
||||
@click.stop="handleCopy(item)"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="pl-plan-document-attachment-cell__download"
|
||||
title="下载"
|
||||
@@ -63,7 +70,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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<ItemDef[]>(() => {
|
||||
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;
|
||||
|
||||
@@ -142,6 +142,17 @@
|
||||
<el-icon :size="20"><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-icon
|
||||
class="pl-plan-list-cert-cell__copy"
|
||||
:title="
|
||||
isCertImageUrl(getPrimaryProductCertUrl(product, slot.key))
|
||||
? '复制图片'
|
||||
: '复制链接'
|
||||
"
|
||||
@click.stop="handleCopyCert(getPrimaryProductCertUrl(product, slot.key))"
|
||||
>
|
||||
<DocumentCopy />
|
||||
</el-icon>
|
||||
<el-icon
|
||||
class="pl-plan-list-cert-cell__download"
|
||||
title="下载"
|
||||
@@ -167,7 +178,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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('复制失败,请允许剪贴板权限或改用下载')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -420,10 +456,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pl-plan-list-cert-cell__download {
|
||||
.pl-plan-list-cert-cell__download,
|
||||
.pl-plan-list-cert-cell__copy {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 2;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -440,6 +476,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pl-plan-list-cert-cell__download {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
.pl-plan-list-cert-cell__copy {
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.pl-plan-list-cert-cell__cert-caption {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -407,6 +407,14 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mp-crm-att-grid .att-icon--copy {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mp-crm-att-grid .att-icon > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -426,6 +434,10 @@
|
||||
background: #087f57;
|
||||
}
|
||||
|
||||
.mp-crm-att-grid .att-icon--copy > span {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.mp-crm-att-grid .att-uploading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, before, describe, it } from 'node:test'
|
||||
import {
|
||||
copyAttachmentImageToClipboard,
|
||||
copyImageBlobToClipboard,
|
||||
ensurePngBlob
|
||||
} from './clipboard'
|
||||
|
||||
class FakeClipboardItem {
|
||||
readonly types: string[]
|
||||
private readonly data: Record<string, Blob>
|
||||
constructor(items: Record<string, Blob>) {
|
||||
this.data = items
|
||||
this.types = Object.keys(items)
|
||||
}
|
||||
async getType(type: string) {
|
||||
return this.data[type]
|
||||
}
|
||||
}
|
||||
|
||||
before(() => {
|
||||
if (typeof globalThis.ClipboardItem === 'undefined') {
|
||||
;(globalThis as unknown as { ClipboardItem: typeof FakeClipboardItem }).ClipboardItem =
|
||||
FakeClipboardItem
|
||||
}
|
||||
if (typeof globalThis.navigator === 'undefined') {
|
||||
;(globalThis as unknown as { navigator: Navigator }).navigator = {} as Navigator
|
||||
}
|
||||
})
|
||||
|
||||
const originalClipboard = globalThis.navigator?.clipboard
|
||||
|
||||
afterEach(() => {
|
||||
if (typeof globalThis.navigator === 'undefined') return
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensurePngBlob', () => {
|
||||
it('returns the same blob when type is already image/png', async () => {
|
||||
const png = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
|
||||
const out = await ensurePngBlob(png)
|
||||
assert.equal(out, png)
|
||||
assert.equal(out.type, 'image/png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyImageBlobToClipboard', () => {
|
||||
it('writes a ClipboardItem with image/png and returns true', async () => {
|
||||
const written: ClipboardItem[] = []
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
write: async (items: ClipboardItem[]) => {
|
||||
written.push(...items)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const png = new Blob([new Uint8Array([9])], { type: 'image/png' })
|
||||
const ok = await copyImageBlobToClipboard(png)
|
||||
assert.equal(ok, true)
|
||||
assert.equal(written.length, 1)
|
||||
const types = written[0].types
|
||||
assert.ok(types.includes('image/png'))
|
||||
})
|
||||
|
||||
it('returns false when clipboard.write is unavailable', async () => {
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {}
|
||||
})
|
||||
const png = new Blob([new Uint8Array([1])], { type: 'image/png' })
|
||||
assert.equal(await copyImageBlobToClipboard(png), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyAttachmentImageToClipboard', () => {
|
||||
it('returns false for non-image attachments without fetching', async () => {
|
||||
let fetched = false
|
||||
const ok = await copyAttachmentImageToClipboard(
|
||||
{ fileUrl: 'https://example.com/a.pdf', fileName: 'a.pdf' },
|
||||
{
|
||||
fetchBlob: async () => {
|
||||
fetched = true
|
||||
return new Blob(['x'], { type: 'application/pdf' })
|
||||
}
|
||||
}
|
||||
)
|
||||
assert.equal(ok, false)
|
||||
assert.equal(fetched, false)
|
||||
})
|
||||
|
||||
it('fetches image blob and copies via clipboard', async () => {
|
||||
const written: ClipboardItem[] = []
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
write: async (items: ClipboardItem[]) => {
|
||||
written.push(...items)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ok = await copyAttachmentImageToClipboard(
|
||||
{ fileUrl: 'https://example.com/a.jpg', fileName: 'a.jpg' },
|
||||
{
|
||||
fetchBlob: async () => new Blob([new Uint8Array([7])], { type: 'image/png' })
|
||||
}
|
||||
)
|
||||
assert.equal(ok, true)
|
||||
assert.equal(written.length, 1)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,15 @@
|
||||
* 复制文本到剪贴板。
|
||||
* 小程序 web-view / H5 iframe 常被 Permissions Policy 拦截 Clipboard API,
|
||||
* 需回退到 execCommand,必要时再交给壳页复制。
|
||||
* 另提供图片 Blob / 附件图片写入系统剪贴板(ClipboardItem)。
|
||||
*/
|
||||
import { MP_WEBVIEW_COPY_TEXT } from '@/utils/mp-webview'
|
||||
|
||||
export type AttachmentClipboardItem = {
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
function fallbackExecCommandCopy(text: string): boolean {
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
@@ -64,3 +70,82 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
|
||||
return tryCopyViaMpWebviewHost(value)
|
||||
}
|
||||
|
||||
/** 与 download-attachment 图片判定对齐,避免测试静态拉入整条 API 依赖链 */
|
||||
function isImageAttachment(file: AttachmentClipboardItem): boolean {
|
||||
const name = String(file.fileName || '').toLowerCase()
|
||||
const url = String(file.fileUrl || '')
|
||||
.split('?')[0]
|
||||
.toLowerCase()
|
||||
return (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name) ||
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
|
||||
)
|
||||
}
|
||||
|
||||
/** 已是 PNG 则原样返回;否则经 canvas 转为 image/png。 */
|
||||
export async function ensurePngBlob(blob: Blob): Promise<Blob> {
|
||||
if (blob.type === 'image/png') return blob
|
||||
|
||||
if (typeof createImageBitmap === 'function' && typeof document !== 'undefined') {
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = bitmap.width
|
||||
canvas.height = bitmap.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('canvas 2d unavailable')
|
||||
ctx.drawImage(bitmap, 0, 0)
|
||||
const png = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
|
||||
})
|
||||
return png
|
||||
} finally {
|
||||
bitmap.close?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 无 canvas 环境时仍尝试按原类型写入(部分浏览器接受 image/jpeg)
|
||||
return blob
|
||||
}
|
||||
|
||||
/** 将图片 Blob 写入系统剪贴板;失败返回 false。 */
|
||||
export async function copyImageBlobToClipboard(blob: Blob): Promise<boolean> {
|
||||
if (!blob || blob.size <= 0) return false
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.write) return false
|
||||
if (typeof ClipboardItem === 'undefined') return false
|
||||
const png = await ensurePngBlob(blob)
|
||||
const type = png.type || 'image/png'
|
||||
await navigator.clipboard.write([new ClipboardItem({ [type]: png })])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type CopyAttachmentImageOptions = {
|
||||
fullFileUrl?: (url: string) => string
|
||||
/** 测试可注入;默认走预签名下载 Blob */
|
||||
fetchBlob?: (
|
||||
file: AttachmentClipboardItem,
|
||||
options?: { fullFileUrl?: (url: string) => string }
|
||||
) => Promise<Blob>
|
||||
}
|
||||
|
||||
/** 仅图片附件:拉取 Blob 后写入剪贴板;非图片返回 false。 */
|
||||
export async function copyAttachmentImageToClipboard(
|
||||
file: AttachmentClipboardItem,
|
||||
options?: CopyAttachmentImageOptions
|
||||
): Promise<boolean> {
|
||||
if (!isImageAttachment(file)) return false
|
||||
try {
|
||||
const fetchBlob =
|
||||
options?.fetchBlob ||
|
||||
(await import('@/utils/download-attachment')).fetchAttachmentBlob
|
||||
const blob = await fetchBlob(file, { fullFileUrl: options?.fullFileUrl })
|
||||
return await copyImageBlobToClipboard(blob)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user