Procházet zdrojové kódy

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>
wuzilong před 1 měsícem
rodič
revize
8435836d81

+ 679 - 0
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 中图片分支结构**
+
+将原先单独的 `<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 断言)