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:
wuzilongandCursor committed 2026-11-14 23:18:34 +08:00
1 parent 53987bdf22
commit 43e240ad6a
11 files changed
+699 -14

No files matched your search

@@ -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;
+116
View File
@@ -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)
})
})
+85
View File
@@ -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
}
}