Przeglądaj źródła

docs: add attachment image preview rotate/zoom design and demo.

Capture the approved AttachmentUploadField preview enhancements (rotate, fit-full, wheel/button zoom, drag pan) before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 miesiąc temu
rodzic
commit
3583e6456b

+ 653 - 0
docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html

@@ -0,0 +1,653 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>附件图片预览 · 左转 / 右转 Demo</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com" />
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --text-muted: #64748b;
+      --border: #d9e8e2;
+      --bg-page: #f8fafc;
+      --surface: #ffffff;
+      --radius: 10px;
+      --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+    }
+
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+
+    body {
+      min-height: 100vh;
+      font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      color: var(--text);
+      background: var(--bg-page);
+      padding: 28px 20px 48px;
+    }
+
+    .page {
+      max-width: 920px;
+      margin: 0 auto;
+    }
+
+    .intro h1 {
+      font-size: 18px;
+      font-weight: 700;
+      margin-bottom: 6px;
+    }
+    .intro p {
+      font-size: 13px;
+      color: var(--text-muted);
+      margin-bottom: 4px;
+    }
+    .intro code {
+      font-size: 12px;
+      background: #fff;
+      border: 1px solid #cbd5e1;
+      border-radius: 4px;
+      padding: 1px 6px;
+    }
+    .legend {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin: 14px 0 20px;
+    }
+    .legend span {
+      font-size: 12px;
+      background: #fff;
+      border: 1px solid #cbd5e1;
+      border-radius: 999px;
+      padding: 4px 10px;
+      color: #475569;
+    }
+
+    .panel {
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      padding: 20px;
+    }
+    .panel-top {
+      margin-bottom: 14px;
+    }
+    .panel-top h2 {
+      position: relative;
+      display: inline-flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 15px;
+      font-weight: 600;
+      padding-left: 10px;
+    }
+    .panel-top h2::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 2px;
+      bottom: 2px;
+      width: 3px;
+      border-radius: 2px;
+      background: var(--primary);
+    }
+    .panel-tip {
+      margin-top: 6px;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+
+    .attachment-list {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 12px;
+      align-items: flex-start;
+    }
+
+    .upload-card {
+      width: 140px;
+      height: 96px;
+      border: 1px dashed #a7f3d0;
+      background: #f0fdf4;
+      border-radius: 8px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 6px;
+      color: var(--primary);
+      font-size: 12px;
+      font-weight: 500;
+      cursor: default;
+      user-select: none;
+    }
+    .upload-card .plus {
+      font-size: 28px;
+      line-height: 1;
+      font-weight: 400;
+    }
+
+    .attachment-item {
+      width: 140px;
+    }
+    .attachment-preview {
+      width: 140px;
+      height: 96px;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      overflow: hidden;
+      background: #f8fafc;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+    .attachment-preview:hover {
+      border-color: var(--primary);
+    }
+    .attachment-preview img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+    .attachment-preview.is-doc {
+      flex-direction: column;
+      gap: 4px;
+      color: var(--text-muted);
+      font-size: 12px;
+      cursor: default;
+    }
+    .attachment-name {
+      display: block;
+      margin-top: 6px;
+      font-size: 12px;
+      color: var(--text);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    /* 预览弹窗 */
+    .mask {
+      display: none;
+      position: fixed;
+      inset: 0;
+      background: rgba(15, 23, 42, 0.45);
+      backdrop-filter: none;
+      z-index: 1000;
+      align-items: center;
+      justify-content: center;
+      padding: 24px;
+    }
+    .mask.show { display: flex; }
+
+    .dialog {
+      width: min(860px, 100%);
+      background: #fff;
+      border-radius: 10px;
+      box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      max-height: calc(100vh - 48px);
+    }
+    .dialog-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+      padding: 14px 18px;
+      border-bottom: 1px solid var(--border);
+    }
+    .dialog-title {
+      font-size: 15px;
+      font-weight: 600;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .dialog-close {
+      width: 28px;
+      height: 28px;
+      border: none;
+      background: transparent;
+      border-radius: 6px;
+      color: var(--text-muted);
+      font-size: 18px;
+      cursor: pointer;
+      line-height: 1;
+    }
+    .dialog-close:hover {
+      background: #f1f5f9;
+      color: var(--text);
+    }
+
+    .dialog-body {
+      padding: 16px 18px 18px;
+      overflow: auto;
+    }
+
+    .preview-toolbar {
+      display: flex;
+      flex-wrap: wrap;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+      margin-bottom: 12px;
+    }
+    .toolbar-sep {
+      width: 1px;
+      height: 20px;
+      background: var(--border);
+      margin: 0 4px;
+    }
+    .btn {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 36px;
+      padding: 0 14px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 13px;
+      font-weight: 500;
+      cursor: pointer;
+      font-family: inherit;
+    }
+    .btn:hover {
+      border-color: var(--primary);
+      color: var(--primary-dark);
+      background: var(--primary-bg);
+    }
+    .btn:active { transform: translateY(1px); }
+    .btn:disabled {
+      opacity: 0.45;
+      cursor: not-allowed;
+      pointer-events: none;
+    }
+    .btn svg {
+      width: 16px;
+      height: 16px;
+      flex-shrink: 0;
+    }
+    .status-chip {
+      min-width: 108px;
+      text-align: center;
+      font-size: 12px;
+      color: var(--text-muted);
+      font-variant-numeric: tabular-nums;
+    }
+
+    .preview-stage {
+      height: min(62vh, 520px);
+      min-height: 320px;
+      background: #f8fafc;
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      overflow: hidden;
+      padding: 16px;
+      cursor: grab;
+      touch-action: none;
+    }
+    .preview-stage.is-dragging {
+      cursor: grabbing;
+    }
+    .preview-stage img {
+      width: auto;
+      height: auto;
+      max-width: none;
+      max-height: none;
+      border-radius: 4px;
+      transition: transform 0.15s ease;
+      transform-origin: center center;
+      user-select: none;
+      -webkit-user-drag: none;
+      will-change: transform;
+      pointer-events: none;
+    }
+    .preview-stage.is-zooming img,
+    .preview-stage.is-dragging img {
+      transition: none;
+    }
+
+    .note {
+      margin-top: 16px;
+      padding: 12px 14px;
+      background: var(--primary-bg);
+      border: 1px solid #a7f3d0;
+      border-radius: 8px;
+      font-size: 12px;
+      color: #065f46;
+    }
+    .note strong { font-weight: 600; }
+  </style>
+</head>
+<body>
+  <div class="page">
+    <div class="intro">
+      <h1>附件图片预览 · 旋转 + 缩放</h1>
+      <p>对齐 <code>AttachmentUploadField</code>:缩略图点击打开预览弹窗;仅图片显示工具条。</p>
+      <p>交互:左转 / 右转 · 放大 / 缩小 / 重置 · 滚轮缩放 · 拖拽平移 · 关闭后重置。</p>
+      <p><strong>完整可见:</strong>缩放倍率 = 1 时,旋转后按外接矩形完整装入预览区;放大后可拖拽查看细节。</p>
+      <div class="legend">
+        <span>Fresh Mint · #10b981</span>
+        <span>弹窗 860px</span>
+        <span>滚轮缩放</span>
+        <span>仅预览态(不写回文件)</span>
+      </div>
+    </div>
+
+    <div class="panel">
+      <div class="panel-top">
+        <h2>协议附件(示意)</h2>
+        <p class="panel-tip">点击下方图片缩略图体验预览旋转;PDF 卡片不可预览旋转。</p>
+      </div>
+      <div class="attachment-list">
+        <div class="upload-card" title="示意:正式页为上传入口">
+          <span class="plus">+</span>
+          <span>上传附件</span>
+        </div>
+
+        <div class="attachment-item">
+          <div class="attachment-preview" id="thumb-a" title="点击预览">
+            <img id="thumb-a-img" alt="现场照片-01.jpg" />
+          </div>
+          <span class="attachment-name">现场照片-01.jpg</span>
+        </div>
+
+        <div class="attachment-item">
+          <div class="attachment-preview" id="thumb-b" title="点击预览">
+            <img id="thumb-b-img" alt="证件扫描.jpg" />
+          </div>
+          <span class="attachment-name">证件扫描.jpg</span>
+        </div>
+
+        <div class="attachment-item">
+          <div class="attachment-preview is-doc" title="文档:本 Demo 不演示旋转">
+            <span style="font-size:22px">📄</span>
+            <span>PDF</span>
+          </div>
+          <span class="attachment-name">协议正文.pdf</span>
+        </div>
+      </div>
+
+      <div class="note">
+        <strong>推荐落点:</strong>工具条 = 左转 / 右转 · 缩小 / 放大 / 重置;
+        预览区滚轮缩放 + 按住拖拽平移。
+        倍率 1 = 完整装入。全部仅预览态,不改 MinIO 原文件。
+      </div>
+    </div>
+  </div>
+
+  <div class="mask" id="mask" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
+    <div class="dialog">
+      <div class="dialog-header">
+        <div class="dialog-title" id="dialog-title">附件预览</div>
+        <button type="button" class="dialog-close" id="btn-close" aria-label="关闭">×</button>
+      </div>
+      <div class="dialog-body">
+        <div class="preview-toolbar">
+          <button type="button" class="btn" id="btn-left" title="逆时针 90°">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
+              <path d="M3 3v5h5" />
+            </svg>
+            左转
+          </button>
+          <button type="button" class="btn" id="btn-right" title="顺时针 90°">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+              <path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
+              <path d="M21 3v5h-5" />
+            </svg>
+            右转
+          </button>
+          <span class="toolbar-sep" aria-hidden="true"></span>
+          <button type="button" class="btn" id="btn-zoom-out" title="缩小">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
+              <circle cx="11" cy="11" r="7" />
+              <path d="M21 21l-4.3-4.3M8 11h6" />
+            </svg>
+            缩小
+          </button>
+          <button type="button" class="btn" id="btn-zoom-in" title="放大">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
+              <circle cx="11" cy="11" r="7" />
+              <path d="M21 21l-4.3-4.3M11 8v6M8 11h6" />
+            </svg>
+            放大
+          </button>
+          <button type="button" class="btn" id="btn-zoom-reset" title="重置为完整可见">
+            重置
+          </button>
+          <span class="status-chip" id="status-chip">0° · 100%</span>
+        </div>
+        <div class="preview-stage" id="preview-stage" title="滚轮缩放 · 按住拖拽平移">
+          <img id="preview-img" alt="预览" />
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    /** 生成带方向标记的示意图(无需外网) */
+    function makeDemoImage(label, hue, w, h) {
+      const svg =
+        `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
+        `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
+        `<stop stop-color="hsl(${hue},45%,88%)"/><stop offset="1" stop-color="hsl(${hue},40%,72%)"/>` +
+        `</linearGradient></defs>` +
+        `<rect width="100%" height="100%" fill="url(#g)"/>` +
+        `<rect x="24" y="24" width="120" height="36" rx="6" fill="#059669"/>` +
+        `<text x="36" y="48" fill="#fff" font-size="16" font-family="Inter,sans-serif" font-weight="600">TOP</text>` +
+        `<text x="${w / 2}" y="${h / 2}" text-anchor="middle" fill="#1e293b" font-size="28" font-family="Inter,sans-serif" font-weight="600">${label}</text>` +
+        `<text x="${w / 2}" y="${h / 2 + 36}" text-anchor="middle" fill="#64748b" font-size="14" font-family="Inter,sans-serif">${w}×${h} · 旋转 · 滚轮 · 拖拽</text>` +
+        `<polygon points="${w / 2 - 16},${h - 56} ${w / 2 + 16},${h - 56} ${w / 2},${h - 28}" fill="#059669"/>` +
+        `<text x="${w / 2}" y="${h - 12}" text-anchor="middle" fill="#059669" font-size="12" font-family="Inter,sans-serif">BOTTOM</text>` +
+        `</svg>`
+      return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
+    }
+
+    const files = {
+      a: { name: '现场照片-01.jpg', src: makeDemoImage('宽图现场照 A', 150, 960, 360) },
+      b: { name: '证件扫描.jpg', src: makeDemoImage('竖图证件 B', 175, 420, 640) }
+    }
+
+    document.getElementById('thumb-a-img').src = files.a.src
+    document.getElementById('thumb-b-img').src = files.b.src
+
+    const ZOOM_MIN = 0.5
+    const ZOOM_MAX = 5
+    const ZOOM_STEP = 0.15
+
+    const mask = document.getElementById('mask')
+    const stage = document.getElementById('preview-stage')
+    const previewImg = document.getElementById('preview-img')
+    const dialogTitle = document.getElementById('dialog-title')
+    const statusChip = document.getElementById('status-chip')
+    const btnZoomOut = document.getElementById('btn-zoom-out')
+    const btnZoomIn = document.getElementById('btn-zoom-in')
+
+    let angle = 0
+    /** 相对「完整装入」的倍率:1 = 旋转后整图可见 */
+    let zoom = 1
+    let offsetX = 0
+    let offsetY = 0
+    let dragging = false
+    let dragStartX = 0
+    let dragStartY = 0
+    let dragOriginX = 0
+    let dragOriginY = 0
+
+    function clampZoom(v) {
+      return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100))
+    }
+
+    function fitBaseScale() {
+      const padX = 32
+      const padY = 32
+      const stageW = Math.max(stage.clientWidth - padX, 1)
+      const stageH = Math.max(stage.clientHeight - padY, 1)
+      const natW = previewImg.naturalWidth || 1
+      const natH = previewImg.naturalHeight || 1
+      const rad = (angle * Math.PI) / 180
+      const cos = Math.abs(Math.cos(rad))
+      const sin = Math.abs(Math.sin(rad))
+      const boundW = natW * cos + natH * sin
+      const boundH = natW * sin + natH * cos
+      return Math.min(stageW / boundW, stageH / boundH)
+    }
+
+    function applyTransform() {
+      const natW = previewImg.naturalWidth || 1
+      const natH = previewImg.naturalHeight || 1
+      const base = fitBaseScale()
+      const scale = base * zoom
+      previewImg.style.width = natW + 'px'
+      previewImg.style.height = natH + 'px'
+      previewImg.style.transform =
+        'translate(' + offsetX + 'px,' + offsetY + 'px) rotate(' + angle + 'deg) scale(' + scale + ')'
+      const deg = ((angle % 360) + 360) % 360
+      statusChip.textContent = deg + '° · ' + Math.round(zoom * 100) + '%'
+      btnZoomOut.disabled = zoom <= ZOOM_MIN
+      btnZoomIn.disabled = zoom >= ZOOM_MAX
+    }
+
+    function resetView(keepAngle) {
+      if (!keepAngle) angle = 0
+      zoom = 1
+      offsetX = 0
+      offsetY = 0
+      applyTransform()
+    }
+
+    function setZoom(next, fromWheel) {
+      zoom = clampZoom(next)
+      if (zoom <= 1) {
+        offsetX = 0
+        offsetY = 0
+      }
+      if (fromWheel) stage.classList.add('is-zooming')
+      applyTransform()
+      if (fromWheel) {
+        requestAnimationFrame(function () {
+          stage.classList.remove('is-zooming')
+        })
+      }
+    }
+
+    function openPreview(key) {
+      const file = files[key]
+      dialogTitle.textContent = file.name
+      angle = 0
+      zoom = 1
+      offsetX = 0
+      offsetY = 0
+      mask.classList.add('show')
+      previewImg.onload = function () {
+        applyTransform()
+      }
+      previewImg.src = file.src
+      if (previewImg.complete) applyTransform()
+    }
+
+    function closePreview() {
+      mask.classList.remove('show')
+      angle = 0
+      zoom = 1
+      offsetX = 0
+      offsetY = 0
+      previewImg.style.transform = ''
+      statusChip.textContent = '0° · 100%'
+    }
+
+    document.getElementById('thumb-a').addEventListener('click', function () { openPreview('a') })
+    document.getElementById('thumb-b').addEventListener('click', function () { openPreview('b') })
+    document.getElementById('btn-close').addEventListener('click', closePreview)
+    document.getElementById('btn-left').addEventListener('click', function () {
+      angle -= 90
+      applyTransform()
+    })
+    document.getElementById('btn-right').addEventListener('click', function () {
+      angle += 90
+      applyTransform()
+    })
+    btnZoomOut.addEventListener('click', function () {
+      setZoom(zoom - ZOOM_STEP)
+    })
+    btnZoomIn.addEventListener('click', function () {
+      setZoom(zoom + ZOOM_STEP)
+    })
+    document.getElementById('btn-zoom-reset').addEventListener('click', function () {
+      resetView(true)
+    })
+
+    stage.addEventListener(
+      'wheel',
+      function (e) {
+        if (!mask.classList.contains('show')) return
+        e.preventDefault()
+        const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
+        setZoom(zoom + delta, true)
+      },
+      { passive: false }
+    )
+
+    stage.addEventListener('pointerdown', function (e) {
+      if (e.button !== 0) return
+      dragging = true
+      dragStartX = e.clientX
+      dragStartY = e.clientY
+      dragOriginX = offsetX
+      dragOriginY = offsetY
+      stage.classList.add('is-dragging')
+      stage.setPointerCapture(e.pointerId)
+    })
+    stage.addEventListener('pointermove', function (e) {
+      if (!dragging) return
+      offsetX = dragOriginX + (e.clientX - dragStartX)
+      offsetY = dragOriginY + (e.clientY - dragStartY)
+      applyTransform()
+    })
+    function endDrag(e) {
+      if (!dragging) return
+      dragging = false
+      stage.classList.remove('is-dragging')
+      try {
+        stage.releasePointerCapture(e.pointerId)
+      } catch (_) {}
+    }
+    stage.addEventListener('pointerup', endDrag)
+    stage.addEventListener('pointercancel', endDrag)
+
+    mask.addEventListener('click', function (e) {
+      if (e.target === mask) closePreview()
+    })
+    document.addEventListener('keydown', function (e) {
+      if (!mask.classList.contains('show')) return
+      if (e.key === 'Escape') closePreview()
+      if (e.key === 'ArrowLeft') { angle -= 90; applyTransform() }
+      if (e.key === 'ArrowRight') { angle += 90; applyTransform() }
+      if (e.key === '+' || e.key === '=') setZoom(zoom + ZOOM_STEP)
+      if (e.key === '-' || e.key === '_') setZoom(zoom - ZOOM_STEP)
+      if (e.key === '0') resetView(true)
+    })
+    window.addEventListener('resize', function () {
+      if (mask.classList.contains('show')) applyTransform()
+    })
+  </script>
+</body>
+</html>

+ 103 - 0
docs/superpowers/specs/2026-09-23-attachment-image-preview-rotate-zoom-design.md

@@ -0,0 +1,103 @@
+# 附件图片预览:旋转 / 缩放 / 拖拽(设计)
+
+日期:2026-09-23  
+状态:已确认方案,待实现  
+Demo:`docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html`
+
+## 背景
+
+`web-admin` 通用附件组件 `AttachmentUploadField`(`web-admin/src/components/business/attachment-upload-field/index.vue`)已支持缩略图卡片与点击预览。图片预览弹窗目前仅静态展示,缺少方向纠正与细节查看能力。
+
+用户要求:图片预览打开后支持左转 / 右转;旋转后仍须完整可见(不被预览区裁切);并支持滚轮与按钮缩放、拖拽平移。全部为**仅预览态**,不写回 MinIO / 不改存储 URL。
+
+## 目标
+
+1. 图片预览弹窗提供工具条:**左转、右转、缩小、放大、重置**。
+2. 预览区内:**鼠标滚轮缩放**、**按住拖拽平移**。
+3. 缩放倍率 = 1 时,任意 0° / 90° / 180° / 270° 下整图按外接矩形完整装入预览区。
+4. 关闭弹窗或点「重置」后,角度 / 缩放 / 偏移归零(重置可保留当前角度或一并归零——本设计约定「重置」= 角度保持、缩放与偏移归零;关闭弹窗则全部归零。与 Demo 一致:重置保留角度)。
+5. 视频、PDF、其它文档预览行为不变。
+
+## 非目标
+
+- 不把旋转结果写回对象存储或业务附件字段。
+- 不引入第三方图片预览库。
+- 本迭代不强制改造 `BxAttachmentUpload` / `BxAttachmentReadonly`(可后续复用同一交互;若改造成本低可顺带,但不作为门禁)。
+- 不做双指 pinch(桌面管理端为主)。
+
+## 方案选择
+
+| 方案 | 说明 | 结论 |
+|------|------|------|
+| A. 增强 `AttachmentUploadField` 现有预览弹窗 | 改动集中,所有使用该组件的页面自动受益 | **采用** |
+| B. 抽独立 `ImagePreviewDialog` | 边界更清晰,但需改调用点、范围更大 | 不做 |
+| C. 第三方预览库 | 体积与 Fresh Mint / 无 blur 风格对齐成本高 | 不做 |
+
+## 交互与视觉
+
+### 工具条
+
+- 位置:预览弹窗 `body` 顶部居中。
+- 按钮(中性描边,对齐 Fresh Mint):左转、右转、缩小、放大、重置。
+- 状态文案:`{角度}° · {缩放百分比}%`(可选,便于调试与用户感知)。
+- 图标:使用 `@element-plus/icons-vue`(如 `RefreshLeft` / `RefreshRight` / `ZoomIn` / `ZoomOut` 或等价),按钮带文案时须有 leading icon(OpenSpec UI 约定)。
+
+### 变换模型
+
+- 状态:`angle`(度,步进 ±90)、`zoom`(相对「完整装入」的倍率,默认 1)、`offsetX` / `offsetY`(px)。
+- 显示变换:`translate(offset) rotate(angle) scale(fitBase * zoom)`。
+- `fitBase`:按图片自然宽高与预览区可用宽高,计算旋转后外接矩形,取 `min(stageW/boundW, stageH/boundH)`,保证 zoom=1 时完整可见。
+- `zoom` 范围建议:`0.5`~`5`;步进约 `0.15`(滚轮与按钮共用)。
+- zoom ≤ 1 时可将 offset 清零,避免缩小后画面偏出。
+- 窗口 / 弹窗尺寸变化时重新计算 `fitBase` 并应用。
+
+### 输入
+
+| 输入 | 行为 |
+|------|------|
+| 左转 / 右转 | angle ±90 |
+| 缩小 / 放大 | zoom ± step,夹紧到范围 |
+| 重置 | zoom=1,offset=0;**保留**当前 angle |
+| 滚轮(预览区) | preventDefault,缩放;不触发页面滚动 |
+| 拖拽(预览区) | pointer capture,更新 offset |
+| Esc / 关闭 | 关闭弹窗并清空全部预览状态 |
+| 键盘(可选增强) | ←/→ 旋转,+/- 缩放,0 等同重置 |
+
+### 弹窗约束
+
+- 保持现有:`append-to-body`、`modal-class="order-module-dialog-mask"`、无 backdrop blur、宽度约 860px。
+- 预览舞台固定高度(如 `min(62vh, …)`),`overflow: hidden`;放大后靠 translate 平移查看,不用滚动条作为主交互。
+
+## 实现落点
+
+**主文件**:`web-admin/src/components/business/attachment-upload-field/index.vue`
+
+- 扩展现有 `el-dialog` 图片分支:工具条 + stage 事件。
+- 新增 refs / 计算:`previewAngle`、`previewZoom`、`previewOffset`、`applyPreviewTransform`、`fitBaseScale`。
+- `handlePreviewClosed`:重置全部预览状态。
+- 仅 `isImageFile(previewFile)` 时渲染工具条与交互;其它类型保持现状。
+
+**样式**:同文件 scoped SCSS;按钮/stage 对齐 Demo 与 Fresh Mint(主色 `#10b981` 链,中性边框)。
+
+**文档(可选)**:OpenSpec `ui-conventions`「附件上传卡片化布局」可补一句「图片预览支持旋转与缩放(仅预览态)」;非实现门禁,可与代码同 PR 或随后补。
+
+## 验收标准
+
+1. 打开任意图片附件预览:可见左转 / 右转 / 缩小 / 放大 / 重置。
+2. 宽图转 90° 后,在 zoom=100% 时 TOP/BOTTOM 标记(或整图边缘)均在预览区内可见,无裁切。
+3. 滚轮与按钮均可缩放;放大后可拖拽平移。
+4. 关闭再打开:回到 0° · 100% · 无偏移。
+5. PDF / 视频预览无回归;上传 / 下载 / 删除不受影响。
+6. 不发起额外写文件 / 更新附件 URL 的请求。
+
+## 风险与注意
+
+- `transform` 与 `max-width`/`max-height` 同时使用易导致旋转后裁切;实现须以自然尺寸 + `fitBase * zoom` 为准(与 Demo 一致)。
+- 拖拽与点击关闭遮罩勿冲突:仅在 stage 内拖拽;遮罩点击仍关闭。
+- 滚动穿透:预览区 `wheel` 使用 `{ passive: false }` 并 `preventDefault`。
+
+## 参考
+
+- Demo:`docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html`
+- 组件:`web-admin/src/components/business/attachment-upload-field/index.vue`
+- UI 规范:`docs/web-admin-ui-spec-fresh-mint.md`;附件布局:OpenSpec `ui-conventions`「附件上传卡片化布局」