feat(web-admin): add rotate/zoom/pan to attachment image preview.
Enhance AttachmentUploadField image preview dialog with toolbar controls, wheel zoom, and drag pan; fit-full at 100% zoom after rotate. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0af792f535
commit
45327b3acb
1 file changed
+255
-7
@@ -127,12 +127,53 @@
|
||||
@closed="handlePreviewClosed"
|
||||
>
|
||||
<div v-loading="previewLoading" class="preview-body">
|
||||
<img
|
||||
v-if="previewFile && isImageFile(previewFile)"
|
||||
:src="resolveFileUrl(previewFile.fileUrl)"
|
||||
:alt="previewFile.fileName"
|
||||
class="preview-media"
|
||||
/>
|
||||
<template v-if="previewFile && isImageFile(previewFile)">
|
||||
<div class="preview-toolbar">
|
||||
<el-button @click="rotatePreview(-90)">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
左转
|
||||
</el-button>
|
||||
<el-button @click="rotatePreview(90)">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
右转
|
||||
</el-button>
|
||||
<span class="preview-toolbar-sep" aria-hidden="true" />
|
||||
<el-button :disabled="previewZoom <= IMAGE_PREVIEW_ZOOM_MIN" @click="nudgePreviewZoom(-1)">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
缩小
|
||||
</el-button>
|
||||
<el-button :disabled="previewZoom >= IMAGE_PREVIEW_ZOOM_MAX" @click="nudgePreviewZoom(1)">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
放大
|
||||
</el-button>
|
||||
<el-button @click="resetPreviewZoomPan">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<span class="preview-status-chip">{{ previewStatusText }}</span>
|
||||
</div>
|
||||
<div
|
||||
ref="previewStageRef"
|
||||
class="preview-stage"
|
||||
:class="{ 'is-dragging': previewDragging }"
|
||||
title="滚轮缩放 · 按住拖拽平移"
|
||||
@wheel.prevent="handlePreviewWheel"
|
||||
@pointerdown="handlePreviewPointerDown"
|
||||
@pointermove="handlePreviewPointerMove"
|
||||
@pointerup="handlePreviewPointerUp"
|
||||
@pointercancel="handlePreviewPointerUp"
|
||||
>
|
||||
<img
|
||||
ref="previewImgRef"
|
||||
:src="resolveFileUrl(previewFile.fileUrl)"
|
||||
:alt="previewFile.fileName"
|
||||
class="preview-stage-img"
|
||||
:style="{ transform: previewTransformCss }"
|
||||
@load="handlePreviewImageLoad"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<video
|
||||
v-else-if="previewFile && isVideoFile(previewFile)"
|
||||
:src="resolveFileUrl(previewFile.fileUrl)"
|
||||
@@ -165,7 +206,12 @@ import {
|
||||
VideoPlay,
|
||||
Document,
|
||||
Download,
|
||||
Delete
|
||||
Delete,
|
||||
RefreshLeft,
|
||||
RefreshRight,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -175,6 +221,14 @@ import {
|
||||
resolveAttachmentPreviewUrl
|
||||
} from '@/utils/download-attachment'
|
||||
import { buildAttachmentUploadFormData, type CrmUploadBizType } from '@/utils/crm-upload'
|
||||
import {
|
||||
IMAGE_PREVIEW_ZOOM_MIN,
|
||||
IMAGE_PREVIEW_ZOOM_MAX,
|
||||
IMAGE_PREVIEW_ZOOM_STEP,
|
||||
clampPreviewZoom,
|
||||
computeFitBaseScale,
|
||||
buildPreviewTransformCss
|
||||
} from '@/utils/image-preview-transform.js'
|
||||
|
||||
type AttachmentItem = {
|
||||
id: string
|
||||
@@ -222,15 +276,41 @@ const previewVisible = ref(false)
|
||||
const previewFile = ref<AttachmentItem | null>(null)
|
||||
const previewPdfSrc = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const previewStageRef = ref<HTMLElement>()
|
||||
const previewImgRef = ref<HTMLImageElement>()
|
||||
const previewAngle = ref(0)
|
||||
const previewZoom = ref(1)
|
||||
const previewOffsetX = ref(0)
|
||||
const previewOffsetY = ref(0)
|
||||
const previewDragging = ref(false)
|
||||
const previewFitBase = ref(1)
|
||||
const isPointerInside = ref(false)
|
||||
const isDragOver = ref(false)
|
||||
let dragEnterDepth = 0
|
||||
let previewDragStartX = 0
|
||||
let previewDragStartY = 0
|
||||
let previewDragOriginX = 0
|
||||
let previewDragOriginY = 0
|
||||
|
||||
const previewIconSize = computed(() => {
|
||||
if (!props.compact) return 28
|
||||
return props.disabled ? 14 : 20
|
||||
})
|
||||
|
||||
const previewTransformCss = computed(() =>
|
||||
buildPreviewTransformCss(
|
||||
previewOffsetX.value,
|
||||
previewOffsetY.value,
|
||||
previewAngle.value,
|
||||
previewFitBase.value * previewZoom.value
|
||||
)
|
||||
)
|
||||
|
||||
const previewStatusText = computed(() => {
|
||||
const deg = ((previewAngle.value % 360) + 360) % 360
|
||||
return `${deg}° · ${Math.round(previewZoom.value * 100)}%`
|
||||
})
|
||||
|
||||
function resolveFileUrl(url: string) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (!trimmed) return ''
|
||||
@@ -512,12 +592,118 @@ async function handleDocumentPaste(event: ClipboardEvent) {
|
||||
await uploadFiles(files)
|
||||
}
|
||||
|
||||
const PREVIEW_STAGE_PAD = 32
|
||||
|
||||
function refreshPreviewFitBase() {
|
||||
const stage = previewStageRef.value
|
||||
const img = previewImgRef.value
|
||||
if (!stage || !img) return
|
||||
const stageW = Math.max(stage.clientWidth - PREVIEW_STAGE_PAD, 1)
|
||||
const stageH = Math.max(stage.clientHeight - PREVIEW_STAGE_PAD, 1)
|
||||
previewFitBase.value = computeFitBaseScale(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
previewAngle.value,
|
||||
stageW,
|
||||
stageH
|
||||
)
|
||||
// 以自然尺寸驱动,避免 max-width 裁切
|
||||
img.style.width = `${img.naturalWidth || 0}px`
|
||||
img.style.height = `${img.naturalHeight || 0}px`
|
||||
}
|
||||
|
||||
function resetAllPreviewView() {
|
||||
previewAngle.value = 0
|
||||
previewZoom.value = 1
|
||||
previewOffsetX.value = 0
|
||||
previewOffsetY.value = 0
|
||||
previewDragging.value = false
|
||||
nextTick(() => refreshPreviewFitBase())
|
||||
}
|
||||
|
||||
function resetPreviewZoomPan() {
|
||||
previewZoom.value = 1
|
||||
previewOffsetX.value = 0
|
||||
previewOffsetY.value = 0
|
||||
nextTick(() => refreshPreviewFitBase())
|
||||
}
|
||||
|
||||
function rotatePreview(delta: number) {
|
||||
previewAngle.value += delta
|
||||
nextTick(() => refreshPreviewFitBase())
|
||||
}
|
||||
|
||||
function nudgePreviewZoom(direction: 1 | -1) {
|
||||
const next = clampPreviewZoom(previewZoom.value + direction * IMAGE_PREVIEW_ZOOM_STEP)
|
||||
previewZoom.value = next
|
||||
if (next <= 1) {
|
||||
previewOffsetX.value = 0
|
||||
previewOffsetY.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewImageLoad() {
|
||||
refreshPreviewFitBase()
|
||||
}
|
||||
|
||||
function handlePreviewWheel(e: WheelEvent) {
|
||||
const delta = e.deltaY > 0 ? -IMAGE_PREVIEW_ZOOM_STEP : IMAGE_PREVIEW_ZOOM_STEP
|
||||
nudgePreviewZoom(delta > 0 ? 1 : -1)
|
||||
}
|
||||
|
||||
function handlePreviewPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
const stage = previewStageRef.value
|
||||
if (!stage) return
|
||||
previewDragging.value = true
|
||||
previewDragStartX = e.clientX
|
||||
previewDragStartY = e.clientY
|
||||
previewDragOriginX = previewOffsetX.value
|
||||
previewDragOriginY = previewOffsetY.value
|
||||
stage.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function handlePreviewPointerMove(e: PointerEvent) {
|
||||
if (!previewDragging.value) return
|
||||
previewOffsetX.value = previewDragOriginX + (e.clientX - previewDragStartX)
|
||||
previewOffsetY.value = previewDragOriginY + (e.clientY - previewDragStartY)
|
||||
}
|
||||
|
||||
function handlePreviewPointerUp(e: PointerEvent) {
|
||||
if (!previewDragging.value) return
|
||||
previewDragging.value = false
|
||||
const stage = previewStageRef.value
|
||||
try {
|
||||
stage?.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewKeydown(e: KeyboardEvent) {
|
||||
if (!previewVisible.value || !previewFile.value || !isImageFile(previewFile.value)) return
|
||||
if (e.key === 'ArrowLeft') rotatePreview(-90)
|
||||
if (e.key === 'ArrowRight') rotatePreview(90)
|
||||
if (e.key === '+' || e.key === '=') nudgePreviewZoom(1)
|
||||
if (e.key === '-' || e.key === '_') nudgePreviewZoom(-1)
|
||||
if (e.key === '0') resetPreviewZoomPan()
|
||||
}
|
||||
|
||||
function handlePreviewWindowResize() {
|
||||
if (!previewVisible.value) return
|
||||
refreshPreviewFitBase()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocumentPaste, true)
|
||||
document.addEventListener('keydown', handlePreviewKeydown)
|
||||
window.addEventListener('resize', handlePreviewWindowResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('paste', handleDocumentPaste, true)
|
||||
document.removeEventListener('keydown', handlePreviewKeydown)
|
||||
window.removeEventListener('resize', handlePreviewWindowResize)
|
||||
})
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
@@ -533,6 +719,7 @@ function handlePreviewAttachment(file: AttachmentItem) {
|
||||
if (!canPreview(file)) return
|
||||
previewFile.value = file
|
||||
previewPdfSrc.value = ''
|
||||
resetAllPreviewView()
|
||||
previewVisible.value = true
|
||||
|
||||
if (!isPdfFile(file)) return
|
||||
@@ -566,6 +753,7 @@ function handlePreviewClosed() {
|
||||
previewFile.value = null
|
||||
previewPdfSrc.value = ''
|
||||
previewLoading.value = false
|
||||
resetAllPreviewView()
|
||||
}
|
||||
|
||||
function hasUploading() {
|
||||
@@ -790,6 +978,66 @@ defineExpose({ hasUploading })
|
||||
}
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: var(--mint-border, #d9e8e2);
|
||||
}
|
||||
|
||||
.preview-status-chip {
|
||||
min-width: 96px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: min(62vh, 520px);
|
||||
min-height: 320px;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--mint-border, #d9e8e2);
|
||||
border-radius: 8px;
|
||||
|
||||
&.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-stage-img {
|
||||
display: block;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border-radius: 4px;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.15s ease;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.preview-stage.is-dragging .preview-stage-img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user