|
|
@@ -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 断言)
|