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