Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e240ad6a | ||
|
|
53987bdf22 |
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;
|
||||
|
||||
@@ -452,9 +452,15 @@
|
||||
resolveStopProductsForView(mapLoadFromApi(plan.value?.load).products, productRows.value)
|
||||
)
|
||||
|
||||
const showStopFeedback = computed(
|
||||
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
|
||||
)
|
||||
const showStopFeedback = computed(() => {
|
||||
const hasStopFeedback = Boolean(
|
||||
(transport.value?.driverStopFeedbacks || []).length ||
|
||||
(transport.value?.siteStopFeedbacks || []).length
|
||||
)
|
||||
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
|
||||
if (hasStopFeedback) return true
|
||||
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
|
||||
})
|
||||
|
||||
const summaryCarrierName = computed(
|
||||
() => transport.value?.carrierName || vehicleBundle.value?.detail?.carrierName || ''
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -133,10 +133,13 @@
|
||||
prefetchedArrange.value = null
|
||||
bootstrappedPlan.value = null
|
||||
|
||||
let data: Plan | null = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
|
||||
if (!data) {
|
||||
// 打开详情始终回源,避免列表 seed 数量/装卸段滞后于库内已修正数据
|
||||
let data: Plan | null = null
|
||||
try {
|
||||
const res = await planApi.get(props.planId)
|
||||
data = pickBxData<Plan>(res) || ((res as { data?: Plan }).data ?? null)
|
||||
} catch {
|
||||
data = props.seedPlan?.id === props.planId ? (props.seedPlan as Plan) : null
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error('运输计划数据无效')
|
||||
|
||||
@@ -779,7 +779,14 @@
|
||||
transport.value?.feedbackRemark ||
|
||||
feedbackImageList.value.length ||
|
||||
transport.value?.feedbackQty != null ||
|
||||
(transport.value?.driverProductFeedbacks || []).length
|
||||
(transport.value?.driverProductFeedbacks || []).length ||
|
||||
(transport.value?.driverStopFeedbacks || []).some(
|
||||
(row) =>
|
||||
(row.attachmentUrls && row.attachmentUrls.length > 0) ||
|
||||
row.remark ||
|
||||
row.feedbackTime ||
|
||||
(row.products && row.products.length > 0)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1406,9 +1413,15 @@
|
||||
)
|
||||
}
|
||||
|
||||
const showStopFeedback = computed(
|
||||
() => plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
|
||||
)
|
||||
const showStopFeedback = computed(() => {
|
||||
const hasStopFeedback = Boolean(
|
||||
(transport.value?.driverStopFeedbacks || []).length ||
|
||||
(transport.value?.siteStopFeedbacks || []).length
|
||||
)
|
||||
// 作废/取消单仍展示已有分段回传;无数据时仅在已安排且未取消时显示空态入口
|
||||
if (hasStopFeedback) return true
|
||||
return plan.value?.transportStatus === 'arranged' && plan.value?.followStatus !== 'cancelled'
|
||||
})
|
||||
|
||||
const feedbackBlocksBind = computed(() => ({
|
||||
feedbackEditable: props.feedbackEditable,
|
||||
@@ -1435,7 +1448,27 @@
|
||||
ioOrderViewFields: ioOrderViewFields.value,
|
||||
ioOrderImageCsv: ioOrderImageCsv.value,
|
||||
siteFeedbackTableRows: siteFeedbackTableRows.value,
|
||||
dibangPlanRows: dibangPlanRows.value
|
||||
dibangPlanRows: dibangPlanRows.value,
|
||||
driverStopFeedbacks: (transport.value?.driverStopFeedbacks || []).map((row) => ({
|
||||
stopType: row.stopType,
|
||||
stopId: row.stopId,
|
||||
label: row.label,
|
||||
customerName: row.customerName,
|
||||
attachmentUrls: row.attachmentUrls,
|
||||
remark: row.remark,
|
||||
feedbackTime: row.feedbackTime
|
||||
})),
|
||||
siteStopFeedbacks: (transport.value?.siteStopFeedbacks || []).map((row) => ({
|
||||
stopType: row.stopType,
|
||||
stopId: row.stopId,
|
||||
label: row.label,
|
||||
customerName: row.customerName,
|
||||
businessType: row.businessType,
|
||||
quantity: row.quantity != null ? Number(row.quantity) : undefined,
|
||||
attachmentUrls: row.attachmentUrls,
|
||||
remark: row.remark,
|
||||
feedbackTime: row.feedbackTime
|
||||
}))
|
||||
}))
|
||||
|
||||
const feedbackBlocksOn = {
|
||||
|
||||
@@ -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