From 8435836d81938ca220a47884ce31692509b221cb Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Wed, 23 Sep 2026 14:19:03 +0800 Subject: [PATCH] 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 --- ...23-attachment-image-preview-rotate-zoom.md | 679 ++++++++++++++++++ 1 file changed, 679 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-attachment-image-preview-rotate-zoom.md diff --git a/docs/superpowers/plans/2026-09-23-attachment-image-preview-rotate-zoom.md b/docs/superpowers/plans/2026-09-23-attachment-image-preview-rotate-zoom.md new file mode 100644 index 000000000..67885af50 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-attachment-image-preview-rotate-zoom.md @@ -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 中图片分支结构** + +将原先单独的 `` 改为(视频/PDF 分支保持不动): + +```vue +
+ +
+``` + +注意:`@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`(`