Bläddra i källkod

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>
wuzilong 1 månad sedan
förälder
incheckning
45327b3acb
1 ändrade filer med 255 tillägg och 7 borttagningar
  1. 255 7
      web-admin/src/components/business/attachment-upload-field/index.vue

+ 255 - 7
web-admin/src/components/business/attachment-upload-field/index.vue

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