docs: add implementation plan for attachment image preview rotate/zoom.
Break down AttachmentUploadField preview enhancements into helper tests, UI wiring, styles, and manual acceptance checks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3583e6456b
commit
8435836d81
1 file changed
+679
@@ -0,0 +1,679 @@
|
||||
# 附件图片预览旋转/缩放/拖拽 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 `AttachmentUploadField` 图片预览弹窗中支持左转/右转、完整可见适配、按钮与滚轮缩放、拖拽平移(仅预览态,不写回文件)。
|
||||
|
||||
**Architecture:** 将 `fitBaseScale` / `clampZoom` / `buildPreviewTransform` 抽成纯函数工具,便于 Node 断言;组件内用 refs 管理 angle/zoom/offset,预览区用 `translate + rotate + scale` 渲染。视频/PDF 分支不变。
|
||||
|
||||
**Tech Stack:** Vue 3、Element Plus、`@element-plus/icons-vue`、SCSS;无 vitest,纯函数用 Node `assert` 脚本验证。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-23-attachment-image-preview-rotate-zoom-design.md`
|
||||
**Demo:** `docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| Create: `web-admin/src/utils/image-preview-transform.ts` | 纯函数:fitBase、clampZoom、compose transform CSS |
|
||||
| Create: `web-admin/scripts/test-image-preview-transform.mjs` | Node 断言(编译/或直接测逻辑等价 JS) |
|
||||
| Modify: `web-admin/src/components/business/attachment-upload-field/index.vue` | 工具条、stage 事件、状态、样式 |
|
||||
| Optional: `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md` | 附件预览补一句(非门禁) |
|
||||
|
||||
> **Note on test script:** `web-admin` 无 vitest;为避免为单测引入构建链,`test-image-preview-transform.mjs` 内联与 TS 工具**相同算法**的纯 JS 副本做断言,TS 文件为正式源。实现时先写断言脚本(红),再写 `image-preview-transform.ts`,再让脚本 `import` 该逻辑——若 Node 无法直接 import `.ts`,则脚本保持内联副本,并在 Task 1 用注释声明「须与 TS 保持同步」;**推荐**:工具写成 `.mjs` 导出供脚本与组件共用——但组件侧习惯 `.ts`。折中:**TS 工具为唯一实现**;断言脚本用动态 `node --experimental-strip-types`(Node 22+)或复制算法。本仓库 Node 版本未知,采用:**工具写 TS + 断言脚本 import 编译前内联相同函数体**(在脚本顶部注释 `SYNC WITH image-preview-transform.ts`),实现 Task 时以 TS 为准、脚本复制粘贴同一函数。
|
||||
|
||||
更干净的做法(本计划采用):
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| Create: `web-admin/src/utils/image-preview-transform.ts` | 正式实现 |
|
||||
| Create: `web-admin/scripts/test-image-preview-transform.mjs` | 从 TS 源**手工同步**的同名函数 + assert(与 bxInvoiceThumb 计划同模式:若可 ESM import JS,则改用 `.js` 工具) |
|
||||
|
||||
**最终采用:** 创建 `web-admin/src/utils/image-preview-transform.js`(纯 ESM,无类型依赖,组件可直接 import),断言脚本 import 同一文件。类型用 JSDoc。避免 strip-types。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 纯函数工具 + 断言脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/utils/image-preview-transform.js`
|
||||
- Create: `web-admin/scripts/test-image-preview-rotate.mjs`
|
||||
|
||||
- [ ] **Step 1: 写失败的断言脚本(先 import 不存在的模块)**
|
||||
|
||||
创建 `web-admin/scripts/test-image-preview-rotate.mjs`:
|
||||
|
||||
```js
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
IMAGE_PREVIEW_ZOOM_MIN,
|
||||
IMAGE_PREVIEW_ZOOM_MAX,
|
||||
IMAGE_PREVIEW_ZOOM_STEP,
|
||||
clampPreviewZoom,
|
||||
computeFitBaseScale,
|
||||
buildPreviewTransformCss
|
||||
} from '../src/utils/image-preview-transform.js'
|
||||
|
||||
assert.equal(IMAGE_PREVIEW_ZOOM_MIN, 0.5)
|
||||
assert.equal(IMAGE_PREVIEW_ZOOM_MAX, 5)
|
||||
assert.equal(IMAGE_PREVIEW_ZOOM_STEP, 0.15)
|
||||
|
||||
assert.equal(clampPreviewZoom(0.1), 0.5)
|
||||
assert.equal(clampPreviewZoom(9), 5)
|
||||
assert.equal(clampPreviewZoom(1.234), 1.23)
|
||||
|
||||
// 未旋转:640x400 装入 320x200 → scale = 0.5
|
||||
assert.ok(
|
||||
Math.abs(computeFitBaseScale(640, 400, 0, 320, 200) - 0.5) < 1e-9
|
||||
)
|
||||
|
||||
// 旋转 90°:外接 400x640 装入 320x200 → scale = min(320/400, 200/640) = 0.3125
|
||||
assert.ok(
|
||||
Math.abs(computeFitBaseScale(640, 400, 90, 320, 200) - 0.3125) < 1e-9
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
buildPreviewTransformCss(10, -20, 90, 0.5),
|
||||
'translate(10px, -20px) rotate(90deg) scale(0.5)'
|
||||
)
|
||||
|
||||
console.log('image-preview-transform ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web-admin && node scripts/test-image-preview-rotate.mjs
|
||||
```
|
||||
|
||||
Expected: `ERR_MODULE_NOT_FOUND` for `image-preview-transform.js`
|
||||
|
||||
- [ ] **Step 3: 实现工具模块**
|
||||
|
||||
Create `web-admin/src/utils/image-preview-transform.js`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ x: number, y: number }} PreviewOffset */
|
||||
|
||||
export const IMAGE_PREVIEW_ZOOM_MIN = 0.5
|
||||
export const IMAGE_PREVIEW_ZOOM_MAX = 5
|
||||
export const IMAGE_PREVIEW_ZOOM_STEP = 0.15
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampPreviewZoom(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return 1
|
||||
const clamped = Math.min(IMAGE_PREVIEW_ZOOM_MAX, Math.max(IMAGE_PREVIEW_ZOOM_MIN, n))
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋转后外接矩形完整装入 stage 的基础缩放(zoom=1 时使用)。
|
||||
* @param {number} naturalWidth
|
||||
* @param {number} naturalHeight
|
||||
* @param {number} angleDeg
|
||||
* @param {number} stageWidth
|
||||
* @param {number} stageHeight
|
||||
* @returns {number}
|
||||
*/
|
||||
export function computeFitBaseScale(
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
angleDeg,
|
||||
stageWidth,
|
||||
stageHeight
|
||||
) {
|
||||
const natW = Math.max(Number(naturalWidth) || 1, 1)
|
||||
const natH = Math.max(Number(naturalHeight) || 1, 1)
|
||||
const stageW = Math.max(Number(stageWidth) || 1, 1)
|
||||
const stageH = Math.max(Number(stageHeight) || 1, 1)
|
||||
const rad = ((Number(angleDeg) || 0) * 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} offsetX
|
||||
* @param {number} offsetY
|
||||
* @param {number} angleDeg
|
||||
* @param {number} scale
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildPreviewTransformCss(offsetX, offsetY, angleDeg, scale) {
|
||||
return (
|
||||
'translate(' +
|
||||
offsetX +
|
||||
'px, ' +
|
||||
offsetY +
|
||||
'px) rotate(' +
|
||||
angleDeg +
|
||||
'deg) scale(' +
|
||||
scale +
|
||||
')'
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 再跑断言,确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web-admin && node scripts/test-image-preview-rotate.mjs
|
||||
```
|
||||
|
||||
Expected: `image-preview-transform ok`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/utils/image-preview-transform.js web-admin/scripts/test-image-preview-rotate.mjs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(web-admin): add image preview transform helpers for fit/zoom.
|
||||
|
||||
Pure functions for AttachmentUploadField image preview rotate/zoom math, with a Node assert script.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 预览弹窗模板 — 工具条 + stage 结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`(template 中 `el-dialog` 段,约 119–156 行)
|
||||
|
||||
- [ ] **Step 1: 扩展 icon import**
|
||||
|
||||
在现有 `@element-plus/icons-vue` import 中追加:
|
||||
|
||||
```ts
|
||||
RefreshLeft,
|
||||
RefreshRight,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Refresh
|
||||
```
|
||||
|
||||
(`Refresh` 用于「重置」;若项目更偏好其它图标,保持文案「重置」即可。)
|
||||
|
||||
- [ ] **Step 2: 替换 dialog body 中图片分支结构**
|
||||
|
||||
将原先单独的 `<img class="preview-media" ...>` 改为(视频/PDF 分支保持不动):
|
||||
|
||||
```vue
|
||||
<div v-loading="previewLoading" class="preview-body">
|
||||
<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)"
|
||||
...保持原样...
|
||||
/>
|
||||
<!-- pdf / fallback 保持原样 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
注意:`@wheel.prevent` 在 Vue 中等价于 preventDefault;若需 `{ passive: false }` 行为,`.prevent` 修饰符已足够。
|
||||
|
||||
- [ ] **Step 3: 暂存(本 Task 脚本未接线时模板会报未定义,继续 Task 3 再一起提交亦可)**
|
||||
|
||||
若希望本 Task 可编译:先在 script 中加空 stub(见 Task 3 Step 1),然后:
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm exec vue-tsc --noEmit 2>&1 | head -40
|
||||
```
|
||||
|
||||
Expected: 无与本组件相关的新增错误(仓库 `strict: false`)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 组件脚本 — 状态与交互
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`(`<script setup>`)
|
||||
|
||||
- [ ] **Step 1: import 工具与常量**
|
||||
|
||||
```ts
|
||||
import {
|
||||
IMAGE_PREVIEW_ZOOM_MIN,
|
||||
IMAGE_PREVIEW_ZOOM_MAX,
|
||||
IMAGE_PREVIEW_ZOOM_STEP,
|
||||
clampPreviewZoom,
|
||||
computeFitBaseScale,
|
||||
buildPreviewTransformCss
|
||||
} from '@/utils/image-preview-transform.js'
|
||||
```
|
||||
|
||||
并在 template 可用处暴露常量(script 顶层已可直接用名字)。
|
||||
|
||||
- [ ] **Step 2: 新增 refs / computed**
|
||||
|
||||
在 `previewLoading` 附近加入:
|
||||
|
||||
```ts
|
||||
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)
|
||||
|
||||
let previewDragStartX = 0
|
||||
let previewDragStartY = 0
|
||||
let previewDragOriginX = 0
|
||||
let previewDragOriginY = 0
|
||||
|
||||
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)}%`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 apply / 交互函数**
|
||||
|
||||
```ts
|
||||
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 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 打开/关闭时重置**
|
||||
|
||||
修改 `handlePreviewAttachment`:在 `previewVisible.value = true` 前/后调用 `resetAllPreviewView()`(图片时):
|
||||
|
||||
```ts
|
||||
function handlePreviewAttachment(file: AttachmentItem) {
|
||||
if (!canPreview(file)) return
|
||||
previewFile.value = file
|
||||
previewPdfSrc.value = ''
|
||||
resetAllPreviewView()
|
||||
previewVisible.value = true
|
||||
// ... pdf 逻辑不变
|
||||
}
|
||||
```
|
||||
|
||||
修改 `handlePreviewClosed`:
|
||||
|
||||
```ts
|
||||
function handlePreviewClosed() {
|
||||
previewFile.value = null
|
||||
previewPdfSrc.value = ''
|
||||
previewLoading.value = false
|
||||
resetAllPreviewView()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 可选键盘(规格可选;本计划纳入)**
|
||||
|
||||
在 `onMounted` / `onBeforeUnmount` 旁增加(仅预览打开时):
|
||||
|
||||
```ts
|
||||
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()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 保留现有 paste 监听
|
||||
document.addEventListener('keydown', handlePreviewKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handlePreviewKeydown)
|
||||
// 保留现有 paste 移除
|
||||
})
|
||||
```
|
||||
|
||||
注意:合并进现有 `onMounted`/`onBeforeUnmount`,不要重复注册。
|
||||
|
||||
- [ ] **Step 6: 窗口 resize 时重算 fit**
|
||||
|
||||
```ts
|
||||
function handlePreviewWindowResize() {
|
||||
if (!previewVisible.value) return
|
||||
refreshPreviewFitBase()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handlePreviewWindowResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handlePreviewWindowResize)
|
||||
})
|
||||
```
|
||||
|
||||
同样合并进现有生命周期钩子。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 样式(对齐 Demo / Fresh Mint)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/components/business/attachment-upload-field/index.vue`(`<style scoped lang="scss">`)
|
||||
|
||||
- [ ] **Step 1: 调整 `.preview-media` 与新增 stage 样式**
|
||||
|
||||
保留视频/PDF 的 `.preview-media` 规则;新增:
|
||||
|
||||
```scss
|
||||
.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;
|
||||
}
|
||||
```
|
||||
|
||||
工具条按钮:使用默认 `el-button`(非 primary),符合「中性描边」;不要给整组加 `type="primary"`。
|
||||
|
||||
- [ ] **Step 2: 手动打开 Demo 对照间距**
|
||||
|
||||
Open: `docs/superpowers/demos/2026-09-23-attachment-image-preview-rotate-demo.html`
|
||||
|
||||
对照:工具条居中、stage 灰底圆角、无 blur。
|
||||
|
||||
- [ ] **Step 3: Commit Tasks 2–4**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/components/business/attachment-upload-field/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
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.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 手工验收
|
||||
|
||||
**Files:** 无代码变更(清单)
|
||||
|
||||
- [ ] **Step 1: 启动 web-admin**
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 打开任一使用 `AttachmentUploadField` 的页面**(如协议附件、车辆证件、运输计划相关表单),上传或选一张**横向宽图**,点击缩略图预览。
|
||||
|
||||
验收勾选:
|
||||
|
||||
1. 工具条可见:左转 / 右转 / 缩小 / 放大 / 重置
|
||||
2. 宽图转 90°,100% 时整图在 stage 内无裁切
|
||||
3. 滚轮与按钮可缩放;放大后可拖拽平移
|
||||
4. 「重置」:缩放回 100%、偏移清零,**角度保留**
|
||||
5. 关闭再开:0° · 100% · 无偏移
|
||||
6. PDF / 视频预览无回归;下载/删除正常
|
||||
7. Network 面板:旋转/缩放过程**无**新的上传/更新附件请求
|
||||
|
||||
- [ ] **Step 3: 再跑纯函数脚本**
|
||||
|
||||
```bash
|
||||
cd web-admin && node scripts/test-image-preview-rotate.mjs
|
||||
```
|
||||
|
||||
Expected: `image-preview-transform ok`
|
||||
|
||||
- [ ] **Step 4: 更新 graphify(改了 web-admin 代码后)**
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh frontend
|
||||
```
|
||||
|
||||
(若脚本失败可跳过并在 PR 说明;仓库 AGENTS.md 要求改前端后更新图谱。)
|
||||
|
||||
---
|
||||
|
||||
### Task 6(可选): OpenSpec 补一句
|
||||
|
||||
**Files:**
|
||||
- Modify: `openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md`(「附件上传卡片化布局」节)
|
||||
|
||||
- [ ] **Step 1:** 在该 Requirement 列表末尾增加:
|
||||
|
||||
```markdown
|
||||
10. **图片预览增强**:图片预览弹窗 SHALL 支持左转/右转、缩放(按钮与滚轮)与拖拽平移;SHALL 在缩放倍率 1 时保证旋转后整图完整可见;SHALL NOT 将旋转/缩放结果写回存储。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(openspec): note image preview rotate/zoom for attachment cards.
|
||||
|
||||
Document preview-only rotate and zoom expectations under attachment upload card layout.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage check
|
||||
|
||||
| Spec 要求 | Task |
|
||||
|-----------|------|
|
||||
| 工具条左转/右转/缩小/放大/重置 | Task 2–3 |
|
||||
| 滚轮缩放、拖拽平移 | Task 3 |
|
||||
| zoom=1 完整可见(外接矩形) | Task 1 `computeFitBaseScale` + Task 3 |
|
||||
| 重置保留角度;关闭全清 | Task 3 |
|
||||
| 视频/PDF 不变 | Task 2 分支保留 |
|
||||
| 仅预览不写回 | 无写 API;Task 5 验收 |
|
||||
| 无 blur / 860px 弹窗 | 保留现有 dialog 属性 |
|
||||
| 可选键盘 | Task 3 Step 5 |
|
||||
| OpenSpec 一句 | Task 6 可选 |
|
||||
|
||||
## Placeholder / 一致性自检
|
||||
|
||||
- 常量名统一:`IMAGE_PREVIEW_ZOOM_*`、`clampPreviewZoom`、`computeFitBaseScale`、`buildPreviewTransformCss`
|
||||
- 无 TBD;测试命令与期望输出已写明
|
||||
- 工具路径:`@/utils/image-preview-transform.js`(与仓库其它 `.js` util 可并存;若 eslint 要求 `.ts`,可将同内容改为 `.ts` 并用断言脚本内联副本——优先 `.js` 以跑通 Node 断言)
|
||||
Reference in new issue
Block a user