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>
This commit is contained in:
wuzilongandCursor committed 2026-09-23 14:16:00 +08:00
1 parent 65d904fff1
commit 3583e6456b
2 files changed
+756

No files matched your search

@@ -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>
@@ -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`「附件上传卡片化布局」