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:
wuzilongandCursor committed 2026-09-23 14:29:03 +08:00
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;
}