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
@@ -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