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:
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`「附件上传卡片化布局」
|
||||
Reference in new issue
Block a user