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:
wuzilongandCursor committed 2026-09-23 14:19:03 +08:00
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 断言)