Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-坐席-001-截图拍照-v1.0.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

40 KiB
Raw Blame History

技术方案 - REQ-坐席-001 截图拍照

版本: v1.0 日期: 2025-01 REQ编号: REQ-坐席-001 关联PRD: PRD-REQ-坐席-001-截图拍照-v1.0.md 状态: 已完成 架构师: Bob (Architect) 技术栈: Vue 3 + Element Plus + Tailwind CSS + 原生 Canvas API


目录


Part A: 系统设计

1. 实现方案

1.1 核心技术挑战分析

挑战 难度 解决方案
屏幕捕获 getDisplayMedia() 获取屏幕流 → Canvas.drawImage() 捕获单帧。已有 useScreenCapture.ts 可复用
选区编辑器 全屏 Canvas 遮罩 + 矩形选框 + 8 方向手柄。基于现有 ScreenCapture.vue 改造
马赛克工具 Canvas 像素化:将选定区域缩小再放大(drawImage + imageSmoothingEnabled=false
撤销机制 ImageData 历史栈,每次操作入栈,撤销弹栈恢复
摄像头预览 getUserMedia() + <video> 实时预览,拍照时 drawImage 到 Canvas
待发送图片队列 独立 composable usePendingImages 管理,URL.createObjectURL 生成缩略图
图文组合发送 发送时遍历图片队列逐张上传+发送,最后发送文字消息(两条独立消息)

1.2 框架与库选型

类别 选型 理由
UI 框架 Vue 3 Composition API 项目已使用,<script setup> + 响应式 ref/reactive
组件库 Element Plus 项目已使用,el-dialog 用于拍照弹窗,el-button 用于工具栏按钮
样式 Tailwind CSS + Scoped CSS 项目已使用,工具栏等局部样式用 scoped CSS
截图捕获 原生 getDisplayMedia() PRD 决策:Chrome/Edge 环境,无需 html2canvas fallback
截图编辑 原生 Canvas 2D API PRD 约束:不引入 fabric.js 等重量级库
摄像头 原生 getUserMedia() + <video> 浏览器原生支持,无额外依赖
状态管理 Composable(非 Pinia Store 待发送图片队列是 ReplyBox 局部状态,无需全局共享
文件上传 复用现有 uploadFile() 已有重试机制(3 次指数退避)

1.3 架构模式

采用 组件 + Composable 模式(Vue 3 推荐范式):

ReplyBox.vue(编排者)
  ├── useScreenCapture.ts(屏幕捕获逻辑)
  ├── useCamera.ts(摄像头逻辑)
  ├── usePendingImages.ts(待发送图片队列)
  ├── ScreenCapture.vue(截图编辑器 — 全屏覆盖层)
  ├── CameraCapture.vue(拍照弹窗 — el-dialog
  └── PendingImagePreview.vue(待发送图片缩略图条)
  • Composable 层:封装浏览器 API 调用和状态管理,组件只关心 UI
  • 组件层:每个组件职责单一,通过 props 接收数据、emit 传递结果
  • 编排者ReplyBox.vue 作为编排者,协调各组件和数据流

1.4 关键设计决策

决策 1:截图数据传递方式 — Canvas 而非 Blob

useScreenCapture.ts 改造为返回 HTMLCanvasElement(而非 Blob),原因:

  • ScreenCapture.vue 需要原始 Canvas 作为编辑底图
  • Canvas 可直接 drawImage 渲染到遮罩层,无需 Blob→Image 异步转换
  • 最终确认时再从选区 Canvas 导出 Blob

决策 2:待发送图片队列放在 Composable 而非 Pinia Store

创建 usePendingImages.ts composable 而非扩展 conversation.ts store,原因:

  • 待发送图片是 ReplyBox 的局部 UI 状态,不需要跨组件共享
  • 避免污染全局 store(store 已有 60+ 状态和方法)
  • composable 可在 ReplyBox 卸载时自动清理 URL.createObjectURL 资源

决策 3ScreenCapture.vue 接收截图作为 prop

改造后的 ScreenCapture.vue 不再自己调用截图 API,而是接收 screenshotCanvas propHTMLCanvasElement),原因:

  • 职责分离:截图捕获(useScreenCapture)与截图编辑(ScreenCapture)解耦
  • ReplyBox 统一编排:先捕获截图,再挂载编辑器
  • 便于未来扩展(如从粘贴板接收图片进入编辑器)

决策 4:马赛克实现方式 — 区域像素化

用户拖拽马赛克工具 → 记录拖拽路径 → 在路径区域:
1. 从底图 Canvas 提取该区域 ImageData
2. 创建临时小 Canvas(宽高 / 10
3. drawImage 将区域缩小绘制到小 Canvas
4. drawImage 将小 Canvas 放大绘制回编辑 CanvasimageSmoothingEnabled=false
5. 结果:像素化马赛克效果

决策 5:文字工具用内联 input 而非 prompt()

PRD 要求不用 prompt()。实现方式:

  • 在 Canvas 上方覆盖一个绝对定位的 <input> 元素
  • 用户输入文字后按回车,将文字 fillText 到 Canvas
  • 输入框位置跟随点击坐标

2. 文件列表

所有路径相对于 frontend-agent/

新建文件

文件路径 说明
src/types/screenshot.ts 截图/拍照功能共享 TypeScript 类型定义
src/composables/useCamera.ts 摄像头流管理 composablegetUserMedia 封装)
src/composables/usePendingImages.ts 待发送图片队列管理 composable
src/components/chat/CameraCapture.vue 摄像头拍照弹窗组件(el-dialog)
src/components/chat/PendingImagePreview.vue 待发送图片缩略图预览条组件

修改文件

文件路径 修改范围 说明
src/composables/useScreenCapture.ts 增强 新增 captureScreenAsCanvas() 返回 HTMLCanvasElement(现有 captureScreen() 保留返回 Blob
src/components/chat/ScreenCapture.vue 大幅改造 接入 getDisplayMedia 截图作为底图;新增马赛克/文字/箭头/撤销工具;结果改为 emit blob(不直接发送)
src/components/chat/ReplyBox.vue 大幅改造 工具栏加截图/拍照按钮;加待发送图片预览区;改造发送逻辑支持图文组合发送
src/composables/useKeyboardShortcuts.ts 小幅增强 新增 Ctrl+Shift+A 截图快捷键(P2,预留)

不修改文件

文件路径 原因
src/components/chat/ScreenshotEditor.vue 保留不动,作为 html2canvas fallback 备用
src/components/chat/InputBox.vue PRD 决策 Q6:只改 ReplyBox.vue
src/api/upload.ts 复用现有 uploadFile()
src/api/message.ts 复用现有 sendMessage()
src/stores/conversation.ts 不扩展,待发送图片用 composable 管理

3. 数据结构和接口

3.1 类图

classDiagram
    %% ===== 类型定义 =====
    class PendingImage {
        +id: string
        +blob: Blob
        +previewUrl: string
        +source: "screenshot" | "camera"
        +createdAt: number
    }

    class SelectionBox {
        +x: number
        +y: number
        +w: number
        +h: number
    }

    class EditHistoryEntry {
        +imageData: ImageData
        +timestamp: number
    }

    class ScreenshotToolConfig {
        +name: ScreenshotTool
        +icon: string
        +label: string
    }

    %% ===== Composables =====
    class useScreenCapture {
        -isCapturing: Ref~boolean~
        +isScreenCaptureSupported() boolean
        +captureScreen() Promise~Blob | null~
        +captureScreenAsCanvas() Promise~HTMLCanvasElement | null~
    }

    class useCamera {
        -isActive: Ref~boolean~
        -error: Ref~string | null~
        -stream: MediaStream | null
        +startCamera() Promise~boolean~
        +stopCamera() void
        +captureFrame() Promise~Blob | null~
        +switchCamera() Promise~boolean~
    }

    class usePendingImages {
        -images: Ref~PendingImage[]~
        +addImage(blob: Blob, source: string) void
        +removeImage(id: string) void
        +clearAll() void
        +hasImages: ComputedRef~boolean~
        +count: ComputedRef~number~
    }

    %% ===== 组件 =====
    class ScreenCapture {
        +props: screenshotCanvas: HTMLCanvasElement
        +emit: confirm(blob: Blob)
        +emit: cancel()
        -box: Ref~SelectionBox~
        -currentTool: Ref~ScreenshotTool~
        -currentColor: Ref~string~
        -history: EditHistoryEntry[]
        -historyIndex: number
        -editCanvas: HTMLCanvasElement
        -maskCanvas: HTMLCanvasElement
        +startSelect(e: MouseEvent) void
        +onMove(e: MouseEvent) void
        +startResize(dir: string, e: MouseEvent) void
        +drawMosaic(x1, y1, x2, y2) void
        +drawArrow(x1, y1, x2, y2) void
        +drawText(x, y, text) void
        +undo() void
        +confirm() void
        +cancel() void
    }

    class CameraCapture {
        +props: modelValue: boolean
        +emit: confirm(blob: Blob)
        +emit: cancel()
        +emit: update:modelValue(boolean)
        -videoRef: Ref~HTMLVideoElement~
        -capturedImage: Ref~string | null~
        -cameraError: Ref~string | null~
        +startCamera() void
        +capture() void
        +retake() void
        +confirm() void
        +close() void
    }

    class PendingImagePreview {
        +props: images: PendingImage[]
        +emit: remove(id: string)
        -thumbnails: ComputedRef
    }

    class ReplyBox {
        -pendingImages: usePendingImages
        -showScreenCapture: Ref~boolean~
        -showCameraDialog: Ref~boolean~
        -screenshotCanvas: Ref~HTMLCanvasElement | null~
        +handleScreenshot() Promise~void~
        +handleCamera() void
        +onScreenCaptureConfirm(blob: Blob) void
        +onCameraConfirm(blob: Blob) void
        +onCameraCancel() void
        +removePendingImage(id: string) void
        +handleSend() Promise~void~
        +sendPendingImages(convId: string) Promise~void~
    }

    %% ===== 关系 =====
    ReplyBox --> useScreenCapture : 调用截图
    ReplyBox --> useCamera : 调用拍照
    ReplyBox --> usePendingImages : 管理待发送图片
    ReplyBox --> ScreenCapture : 挂载截图编辑器
    ReplyBox --> CameraCapture : 挂载拍照弹窗
    ReplyBox --> PendingImagePreview : 挂载预览条
    ScreenCapture --> SelectionBox : 选区状态
    ScreenCapture --> EditHistoryEntry : 撤销历史
    CameraCapture --> useCamera : 摄像头逻辑
    PendingImagePreview --> PendingImage : 渲染缩略图
    usePendingImages --> PendingImage : 管理队列

3.2 核心类型定义

// src/types/screenshot.ts

/** 待发送图片项 */
export interface PendingImage {
  /** 唯一标识(基于时间戳生成) */
  id: string
  /** 图片 Blob 数据(用于上传) */
  blob: Blob
  /** 缩略图预览 URLURL.createObjectURL 生成) */
  previewUrl: string
  /** 图片来源:截图 or 拍照 */
  source: 'screenshot' | 'camera'
  /** 创建时间戳 */
  createdAt: number
}

/** 截图选区 */
export interface SelectionBox {
  x: number
  y: number
  w: number
  h: number
}

/** 截图编辑工具类型 */
export type ScreenshotTool = 'mosaic' | 'text' | 'arrow' | 'rect'

/** 编辑历史记录条目 */
export interface EditHistoryEntry {
  /** Canvas 快照 */
  imageData: ImageData
  /** 操作时间戳 */
  timestamp: number
}

/** 截图工具配置 */
export interface ScreenshotToolConfig {
  name: ScreenshotTool
  icon: string
  label: string
}

/** 摄像头错误类型 */
export type CameraError = 'permission-denied' | 'no-device' | 'in-use' | 'unknown'

3.3 组件 Props / Emits 接口

ScreenCapture.vue(改造后)

// Props
interface ScreenCaptureProps {
  /** getDisplayMedia 捕获的屏幕画面 Canvas */
  screenshotCanvas: HTMLCanvasElement
}

// Emits
interface ScreenCaptureEmits {
  (e: 'confirm', blob: Blob): void   // 截图确认,返回编辑后的图片 Blob
  (e: 'cancel'): void                // 取消截图
}

CameraCapture.vue(新建)

// Props
interface CameraCaptureProps {
  /** v-model 控制弹窗显隐 */
  modelValue: boolean
}

// Emits
interface CameraCaptureEmits {
  (e: 'update:modelValue', value: boolean): void
  (e: 'confirm', blob: Blob): void   // 拍照确认,返回图片 Blob
  (e: 'cancel'): void                // 取消拍照
}

PendingImagePreview.vue(新建)

// Props
interface PendingImagePreviewProps {
  /** 待发送图片列表 */
  images: PendingImage[]
}

// Emits
interface PendingImagePreviewEmits {
  (e: 'remove', id: string): void    // 删除指定图片
}

4. 程序调用流程

4.1 截图完整流程时序图

sequenceDiagram
    participant User as 用户
    participant RB as ReplyBox.vue
    participant USC as useScreenCapture
    participant SC as ScreenCapture.vue
    participant UPI as usePendingImages
    participant PIP as PendingImagePreview.vue

    User->>RB: 点击截图按钮
    RB->>RB: handleScreenshot()
    RB->>USC: captureScreenAsCanvas()
    
    Note over USC: 浏览器弹出 getDisplayMedia 选择器
    User->>USC: 选择屏幕/窗口/标签页
    
    USC->>USC: getDisplayMedia() 获取视频流
    USC->>USC: video.play() → canvas.drawImage() 捕获一帧
    USC->>USC: stream.getTracks().stop() 停止共享
    USC-->>RB: 返回 HTMLCanvasElement
    
    RB->>RB: screenshotCanvas = canvas
    RB->>RB: showScreenCapture = true
    
    Note over SC: 组件挂载,显示全屏遮罩
    SC->>SC: 初始化:底图 Canvas + 遮罩 Canvas
    SC->>SC: drawMask() 绘制半透明遮罩
    
    User->>SC: 鼠标拖拽框选区域
    SC->>SC: 更新 box {x, y, w, h}
    SC->>SC: drawMask() 清除选区内遮罩
    
    User->>SC: 选择马赛克工具
    User->>SC: 在选区内拖拽涂抹
    SC->>SC: saveHistory() 保存当前状态
    SC->>SC: drawMosaic() 像素化处理
    
    User->>SC: 选择箭头工具
    User->>SC: 拖拽绘制箭头
    SC->>SC: saveHistory() 保存当前状态
    SC->>SC: drawArrow() 绘制箭头线段
    
    User->>SC: 点击撤销
    SC->>SC: historyIndex--
    SC->>SC: ctx.putImageData() 恢复上一状态
    
    User->>SC: 点击确认 ✓
    SC->>SC: 从编辑 Canvas 裁剪选区
    SC->>SC: canvas.toBlob() 转 Blob
    SC-->>RB: emit('confirm', blob)
    
    RB->>RB: showScreenCapture = false
    RB->>UPI: addImage(blob, 'screenshot')
    
    UPI->>UPI: URL.createObjectURL(blob) 生成预览URL
    UPI->>UPI: images.push({ id, blob, previewUrl, source })
    UPI-->>RB: 队列已更新
    
    RB->>PIP: 响应式传递 images
    PIP->>PIP: 渲染缩略图 + × 删除按钮
    PIP-->>User: 显示待发送图片预览

4.2 拍照完整流程时序图

sequenceDiagram
    participant User as 用户
    participant RB as ReplyBox.vue
    participant CC as CameraCapture.vue
    participant UC as useCamera
    participant UPI as usePendingImages

    User->>RB: 点击拍照按钮
    RB->>RB: handleCamera()
    RB->>RB: showCameraDialog = true
    RB->>CC: 挂载组件(v-model=true
    
    CC->>CC: ElMessage.info('即将请求摄像头权限...')
    CC->>UC: startCamera()
    
    UC->>UC: getUserMedia({ video: { facingMode: 'user' } })
    Note over UC: 浏览器弹出权限请求
    User->>UC: 允许摄像头访问
    
    UC->>UC: stream 赋值到 video.srcObject
    UC->>UC: video.play()
    UC-->>CC: 摄像头预览已启动
    CC-->>User: 显示实时摄像头预览
    
    User->>CC: 点击拍照按钮 📷
    CC->>UC: captureFrame()
    UC->>UC: canvas.drawImage(video, 0, 0)
    UC->>UC: canvas.toDataURL('image/png')
    UC-->>CC: 返回图片 dataURL
    CC->>CC: capturedImage = dataURL
    CC->>CC: 隐藏 video,显示 img 静态画面
    CC-->>User: 显示拍照结果 + 重拍/确认按钮
    
    alt 用户点击重拍
        User->>CC: 点击重拍
        CC->>CC: capturedImage = null
        CC->>CC: 恢复 video 实时预览
        CC-->>User: 回到实时预览状态
    else 用户点击确认
        User->>CC: 点击确认 ✓
        CC->>UC: captureFrame()(再次捕获确保最新帧)
        CC->>CC: dataURL → Blob 转换
        CC->>UC: stopCamera() 停止摄像头
        CC-->>RB: emit('confirm', blob)
        CC-->>RB: emit('update:modelValue', false)
    end
    
    RB->>RB: showCameraDialog = false
    RB->>UPI: addImage(blob, 'camera')
    UPI->>UPI: 生成 previewUrl,加入队列
    UPI-->>RB: 队列已更新
    RB-->>User: 输入区显示拍照缩略图

4.3 图文组合发送流程时序图

sequenceDiagram
    participant User as 用户
    participant RB as ReplyBox.vue
    participant UPI as usePendingImages
    participant API_U as uploadFile()
    participant API_M as sendMessage()
    participant Store as conversationStore

    User->>RB: 输入文字 + 有待发送图片
    User->>RB: 点击发送 / 按 Enter
    
    RB->>RB: handleSend()
    RB->>RB: const content = inputText.trim()
    RB->>RB: const hasImages = pendingImages.count > 0
    RB->>RB: const hasText = content.length > 0
    
    alt 无图片且无文字
        RB->>RB: return(不发送)
    else 有图片
        RB->>RB: ElMessage.info('发送中...')
        RB->>UPI: 获取 images 数组
        
        loop 遍历每张图片
            RB->>API_U: uploadFile(image.blob)
            API_U-->>RB: { url, filename, file_size }
            
            RB->>API_M: sendMessage(convId, '[图片]', 'image', { media_url, file_name, file_size })
            API_M-->>RB: 返回新消息 Message
            RB->>Store: messages.push(newMsg)
        end
        
        RB->>UPI: clearAll() 清空图片队列
        RB->>RB: URL.revokeObjectURL() 释放预览URL
    end
    
    opt 有文字
        RB->>RB: emit('send', content)(走原有文字发送逻辑)
        RB->>RB: inputText = ''
    end
    
    RB->>RB: textareaHeight = 60(恢复默认高度)
    RB-->>User: 发送完成,输入区清空

4.4 组件初始化与资源清理流程

sequenceDiagram
    participant RB as ReplyBox
    participant SC as ScreenCapture
    participant CC as CameraCapture
    participant UPI as usePendingImages

    Note over RB: 组件挂载 onMounted
    RB->>RB: 注册全局快捷键(Ctrl+D 语音 / Ctrl+Shift+A 截图P2
    
    Note over RB: 用户触发截图
    RB->>SC: v-if="showScreenCapture" 挂载
    SC->>SC: onMounted → 初始化 Canvas、事件监听
    SC->>SC: drawMask() 绘制初始遮罩
    
    Note over RB: 截图取消/确认
    RB->>SC: v-if=false 卸载
    SC->>SC: onUnmounted → 移除事件监听、清空 history
    
    Note over RB: 用户触发拍照
    RB->>CC: v-model=true 挂载
    CC->>CC: onMounted → startCamera()
    
    Note over RB: 拍照取消/确认
    RB->>CC: v-model=false 卸载
    CC->>CC: onUnmounted → stopCamera() 停止流
    
    Note over RB: ReplyBox 卸载
    RB->>RB: onUnmounted
    RB->>UPI: clearAll() → revokeObjectURL 释放内存
    RB->>RB: 移除快捷键监听

5. 待明确事项

编号 事项 当前假设 影响范围
A1 图片上传并发限制 假设逐张串行上传(避免并发请求过多)。如需并发可改用 Promise.all 但需控制并发数 ReplyBox.vue sendPendingImages
A2 图片上传中途失败 假设某张图片上传失败时,ElMessage.error 提示该张失败,剩余图片继续发送,已上传的图片消息不撤回 ReplyBox.vue sendPendingImages
A3 截图选区最小尺寸 假设最小 20×20 像素(与现有 ScreenCapture.vue 一致),低于此值自动取消选区 ScreenCapture.vue endSelect
A4 马赛克粒度 假设像素块大小为 10px(即每 10×10 像素合并为一个色块),可后续调整 ScreenCapture.vue drawMosaic
A5 摄像头默认分辨率 假设请求 1280×720,浏览器可能返回不同分辨率,<video> 自适应 useCamera.ts startCamera
A6 Ctrl+Shift+A 快捷键 PRD 标记为 P2,架构中预留接口但 P0 不实现。快捷键需全局生效(焦点不在输入框时) useKeyboardShortcuts.ts
A7 待发送图片最大数量 PRD 未限制。假设无上限,但建议 UI 层超过 10 张时提示。内存方面每张约 200KB-2MB usePendingImages.ts

Part B: 任务分解

6. 依赖包列表

本项目 无需引入任何新的第三方依赖。所有功能基于浏览器原生 API 和项目现有依赖实现:

# 现有依赖(已安装,无需额外操作)
- vue@^3.4.0                          — 响应式框架(已有)
- element-plus@^2.7.0                 — el-dialog / el-button 组件(已有)
- @element-plus/icons-vue@^2.3.0      — 图标(已有)
- pinia@^2.1.0                         — 状态管理(已有,本功能不新增 store)

# 浏览器原生 API(无需安装)
- navigator.mediaDevices.getDisplayMedia() — 屏幕捕获
- navigator.mediaDevices.getUserMedia()    — 摄像头访问
- Canvas 2D API                            — 截图编辑(马赛克/箭头/文字)
- URL.createObjectURL() / revokeObjectURL() — 图片预览URL

# 注意:html2canvas-pro 仍保留在依赖中(ScreenshotEditor.vue fallback 使用),
#       但新截图功能不再依赖它

7. 任务列表

T01: 基础设施层 — 类型定义 + Composable 函数

属性
任务 ID T01
任务名称 基础设施层:类型定义 + 屏幕捕获/摄像头/待发送图片 Composable
源文件 src/types/screenshot.ts(新建)
src/composables/useScreenCapture.ts(修改)
src/composables/useCamera.ts(新建)
src/composables/usePendingImages.ts(新建)
依赖
优先级 P0

任务描述

  1. src/types/screenshot.ts(新建)

    • 定义 PendingImage 接口:{ id, blob, previewUrl, source, createdAt }
    • 定义 SelectionBox 接口:{ x, y, w, h }
    • 定义 ScreenshotTool 类型:'mosaic' | 'text' | 'arrow' | 'rect'
    • 定义 EditHistoryEntry 接口:{ imageData, timestamp }
    • 定义 ScreenshotToolConfig 接口:{ name, icon, label }
    • 定义 CameraError 类型:'permission-denied' | 'no-device' | 'in-use' | 'unknown'
  2. src/composables/useScreenCapture.ts(修改)

    • 保留现有 captureScreen() 返回 Promise<Blob | null>(向后兼容)
    • 新增 captureScreenAsCanvas() 方法:返回 Promise<HTMLCanvasElement | null>
      • 内部逻辑与 captureScreen() 类似,但不做 canvas.toBlob() 转换
      • 直接返回捕获了屏幕画面的 Canvas 元素
      • ScreenCapture.vue 作为编辑底图使用
    • 导出 captureScreenAsCanvasuseScreenCapture 都包含它
  3. src/composables/useCamera.ts(新建)

    • startCamera(facingMode?: 'user' | 'environment'): Promise<boolean> — 调用 getUserMedia,返回是否成功
    • stopCamera(): void — 停止所有视频轨道,释放摄像头
    • captureFrame(): Promise<Blob | null> — 从 <video> 当前帧 drawImage 到 Canvas,返回 PNG Blob
    • switchCamera(): Promise<boolean> — 切换前置/后置摄像头(P1
    • 响应式状态:isActive: Ref<boolean>error: Ref<CameraError | null>stream: Ref<MediaStream | null>
    • 错误处理:NotAllowedErrorpermission-deniedNotFoundErrorno-deviceNotReadableErrorin-use
  4. src/composables/usePendingImages.ts(新建)

    • addImage(blob: Blob, source: 'screenshot' | 'camera'): void — 生成 idDate.now() + random)、previewUrlURL.createObjectURL),push 到 images 数组
    • removeImage(id: string): void — 找到对应项,URL.revokeObjectURL 释放内存,从数组移除
    • clearAll(): void — 遍历所有项 revokeObjectURL,清空数组
    • 响应式状态:images: Ref<PendingImage[]>
    • 计算属性:hasImages: ComputedRef<boolean>count: ComputedRef<number>
    • 重要:在组件 onUnmounted 时需调用 clearAll() 释放内存(由 ReplyBox 负责)

T02: 核心组件层 — 截图编辑器改造 + 拍照组件 + 待发送图片预览

属性
任务 ID T02
任务名称 核心组件:ScreenCapture 改造 + CameraCapture 新建 + PendingImagePreview 新建
源文件 src/components/chat/ScreenCapture.vue(修改)
src/components/chat/CameraCapture.vue(新建)
src/components/chat/PendingImagePreview.vue(新建)
依赖 T01
优先级 P0

任务描述

  1. src/components/chat/ScreenCapture.vue(大幅改造)

    Props 变更

    • 新增 screenshotCanvas: HTMLCanvasElement prop(接收 getDisplayMedia 捕获的画面)
    • 移除内部 captureScreen() 方法中的 html2canvas 调用
    • onMounted 中直接使用 prop 的 Canvas 作为 fullScreenImage

    新增工具

    • 马赛克工具mosaic):
      • 工具栏新增马赛克按钮(图标 或 SVG
      • 拖拽时在路径区域执行像素化:提取区域 → 缩小 10 倍 → 放大回原尺寸(imageSmoothingEnabled = false
      • 马赛克只在选区内生效
    • 文字工具text):
      • 点击选区内位置 → 显示绝对定位 <input> 元素(跟随点击坐标)
      • 输入文字按回车 → ctx.fillText() 渲染到 Canvas → 隐藏 input
      • 按 ESC 或点击外部 → 取消输入
    • 箭头工具arrow):已有,保留并确保在选区内绘制
    • 撤销功能undo):
      • 每次编辑操作前调用 saveHistory()ctx.getImageData() 存入 history 数组
      • 撤销按钮:historyIndex--ctx.putImageData() 恢复
      • historyIndex <= 0 时禁用撤销按钮

    工具栏改造(按 PRD 布局):

    工具组:马赛克 | 文字 | 箭头 | 矩形(P1)
    颜色组:🔴 🟡 🔵 🟢 ⚫(P1)
    操作组:撤销 | 取消 | 确认✓
    
    • P0 颜色默认红色 #ff3b30P1 加颜色选择器
    • 确认按钮 emit confirm(blob),取消按钮 emit cancel()

    确认逻辑改造

    • 从编辑 Canvas 裁剪选区区域 → canvas.toBlob()emit('confirm', blob)
    • 不再直接上传发送(由 ReplyBox 接收后加入待发送队列)

    保留功能:8 方向手柄调整选区、ESC 退出、选区拖拽

  2. src/components/chat/CameraCapture.vue(新建)

    模板结构

    <el-dialog v-model="visible" title="拍照" width="640px" :close-on-click-modal="false">
      <!-- 预览区 -->
      <div class="camera-preview">
        <video v-if="!capturedImage" ref="videoRef" autoplay muted />
        <img v-else :src="capturedImage" class="captured-photo" />
        <!-- 错误状态 -->
        <div v-if="cameraError" class="camera-error">
          <p>{{ errorMessage }}</p>
        </div>
      </div>
      <!-- 控制区 -->
      <template #footer>
        <div v-if="!capturedImage && !cameraError">
          <el-button @click="close">取消</el-button>
          <el-button type="primary" circle @click="capture">📷</el-button>
        </div>
        <div v-else-if="capturedImage">
          <el-button @click="retake">重拍</el-button>
          <el-button type="primary" @click="confirm">确认 ✓</el-button>
        </div>
        <div v-else>
          <el-button @click="close">关闭</el-button>
        </div>
      </template>
    </el-dialog>
    

    逻辑

    • onMounted/watch(modelValue):弹窗打开时调用 useCamera.startCamera()
    • capture():调用 useCamera.captureFrame() → 设置 capturedImage
    • retake():清空 capturedImage,恢复 video 预览
    • confirm()capturedImage → Blob → emit('confirm', blob)emit('update:modelValue', false)
    • close()/ESCemit('cancel')emit('update:modelValue', false)
    • onUnmounted:调用 useCamera.stopCamera() 确保释放摄像头
  3. src/components/chat/PendingImagePreview.vue(新建)

    模板结构

    <div v-if="images.length > 0" class="pending-images-bar">
      <div class="thumbnail" v-for="img in images" :key="img.id">
        <img :src="img.previewUrl" class="thumb-img" />
        <button class="thumb-remove" @click="$emit('remove', img.id)"></button>
      </div>
    </div>
    

    Propsimages: PendingImage[] Emitsremove(id: string) 样式:横向排列、80×80px 缩略图、圆角、右上角 × 删除按钮、超出宽度横向滚动


T03: 集成层 — ReplyBox 改造 + 快捷键 + 联调

属性
任务 ID T03
任务名称 集成层:ReplyBox 工具栏 + 待发送图片区 + 发送逻辑 + 快捷键
源文件 src/components/chat/ReplyBox.vue(修改)
src/composables/usePendingImages.ts(集成验证)
src/composables/useKeyboardShortcuts.ts(修改)
依赖 T01, T02
优先级 P0

任务描述

  1. src/components/chat/ReplyBox.vue(大幅改造)

    模板变更

    • 工具栏新增截图按钮和拍照按钮(放在最左侧):
      <!-- 改造后工具栏顺序:截图 | 拍照 | 分隔线 | 表情 | 文件 | 分隔线 | 邀请 | 快速回复 -->
      <button class="tb-btn" title="截图" @click="handleScreenshot">✂️</button>
      <button class="tb-btn" title="拍照" @click="handleCamera">📷</button>
      <div class="tb-sep"></div>
      <!-- ... 现有按钮 ... -->
      
    • 在工具栏下方、textarea 上方插入待发送图片预览区:
      <PendingImagePreview
        :images="pendingImages.images.value"
        @remove="pendingImages.removeImage"
      />
      
    • 挂载 CameraCapture 组件:
      <CameraCapture
        v-model="showCameraDialog"
        @confirm="onCameraConfirm"
        @cancel="onCameraCancel"
      />
      
    • ScreenCapture 挂载方式变更:新增 :screenshot-canvas prop
      <ScreenCapture
        v-if="showScreenCapture"
        :screenshot-canvas="screenshotCanvas"
        @confirm="onScreenCaptureConfirm"
        @cancel="onScreenCaptureCancel"
      />
      

    Script 变更

    • 导入新组件和 composable
      import CameraCapture from './CameraCapture.vue'
      import PendingImagePreview from './PendingImagePreview.vue'
      import { captureScreenAsCanvas } from '@/composables/useScreenCapture'
      import { usePendingImages } from '@/composables/usePendingImages'
      
    • 新增状态:
      const showCameraDialog = ref(false)
      const screenshotCanvas = ref<HTMLCanvasElement | null>(null)
      const pendingImages = usePendingImages()
      
    • 改造 handleScreenshot()
      async function handleScreenshot() {
        const convId = conversationStore.currentConversation?.id
        if (!convId) { ElMessage.warning('请先选择一个会话'); return }
        const canvas = await captureScreenAsCanvas()
        if (!canvas) return  // 用户取消或不支持
        screenshotCanvas.value = canvas
        showScreenCapture.value = true
      }
      
    • 新增 handleCamera()
      function handleCamera() {
        const convId = conversationStore.currentConversation?.id
        if (!convId) { ElMessage.warning('请先选择一个会话'); return }
        showCameraDialog.value = true
      }
      
    • 改造 onScreenCaptureConfirm(blob)
      // 旧:直接 uploadFile + sendMessage
      // 新:加入待发送图片队列
      function onScreenCaptureConfirm(blob: Blob) {
        showScreenCapture.value = false
        screenshotCanvas.value = null
        pendingImages.addImage(blob, 'screenshot')
      }
      
    • 新增 onCameraConfirm(blob)
      function onCameraConfirm(blob: Blob) {
        showCameraDialog.value = false
        pendingImages.addImage(blob, 'camera')
      }
      
    • 核心改造 handleSend()
      async function handleSend() {
        const content = inputText.value.trim()
        const hasImages = pendingImages.hasImages.value
        const hasText = content.length > 0
        if (!hasImages && !hasText) return
      
        const convId = conversationStore.currentConversation?.id
        if (!convId) return
      
        // 有图片:逐张上传 + 发送图片消息
        if (hasImages) {
          await sendPendingImages(convId)
        }
      
        // 有文字:发送文字消息(走原有 emit 逻辑)
        if (hasText) {
          emit('send', content)
        }
      
        // 清空输入
        inputText.value = ''
        textareaHeight.value = 60
      }
      
    • 新增 sendPendingImages(convId)
      async function sendPendingImages(convId: string) {
        ElMessage.info('发送中...')
        const images = [...pendingImages.images.value]
        for (const img of images) {
          try {
            const result = await uploadFile(img.blob)
            const newMsg = await sendMessage(convId, '[图片]', 'image', {
              media_url: result.url,
              file_name: result.filename,
              file_size: result.file_size,
            })
            conversationStore.messages.push(newMsg)
          } catch (error) {
            ElMessage.error(`图片发送失败:${error?.message || '未知错误'}`)
          }
        }
        pendingImages.clearAll()
        ElMessage.success('发送成功')
      }
      
    • onUnmounted 中新增:pendingImages.clearAll() 释放 ObjectURL
    • 发送按钮 disabled 逻辑变更:!inputText.trim() && !pendingImages.hasImages.value
  2. src/composables/usePendingImages.ts(集成验证)

    • 确保 usePendingImages 返回的 imagesRef<PendingImage[]>ReplyBox 通过 .value 访问
    • 确保 addImage / removeImage / clearAll 方法正确触发响应式更新
    • 确保 revokeObjectURL 在 removeImage 和 clearAll 中被调用
  3. src/composables/useKeyboardShortcuts.ts(小幅修改)

    • 新增 Ctrl+Shift+A 快捷键支持(P2 预留,不阻塞 P0):
      interface UseKeyboardShortcutsOptions {
        // ... 现有选项 ...
        /** 截图快捷键(Ctrl+Shift+A*/
        onScreenshot?: () => void
      }
      
    • registerShortcuts() 中注册:{ ctrl: true, shift: true, key: 'a', handler: () => options.onScreenshot?.() }
    • 注意:Ctrl+Shift+A 在输入框聚焦时也应生效(entry.ctrl 为 true 时允许)

8. 共享知识

8.1 跨文件约定

# 图片数据格式
- 截图和拍照结果统一为 PNG 格式的 Blob 对象
- 截图编辑器内部使用 Canvas 2D API,最终通过 canvas.toBlob(blob => ..., 'image/png') 导出
- 拍照组件通过 canvas.toDataURL('image/png') 再转 Blob,或直接 canvas.toBlob()

# 待发送图片队列
- 图片队列由 usePendingImages composable 管理
- 每张图片通过 URL.createObjectURL(blob) 生成 previewUrl 用于缩略图显示
- 删除图片或清空队列时必须调用 URL.revokeObjectURL(previewUrl) 释放内存
- 队列是 ReplyBox 局部状态,不放入 Pinia Store

# 发送逻辑
- 图文组合发送:先逐张上传+发送图片消息,再发送文字消息(两条独立消息)
- 图片消息 content 为 '[图片]'msg_type 为 'image'
- 文字消息走原有 emit('send', content) 逻辑
- 发送成功后清空图片队列和输入框
- 上传失败时:ElMessage.error 提示,继续发送剩余图片,不中断流程

# API 复用
- 上传:uploadFile(file: File | Blob) → { url, filename, file_size, msg_type }
- 发送:sendMessage(convId, content, msgType, options) → Message
- 两个 API 均已有错误处理和重试机制

# 浏览器 API
- getDisplayMedia:仅在 HTTPS 或 localhost 下可用(生产环境 itsupport.servyou.com.cn 满足)
- getUserMedia:同上
- 用户取消屏幕选择(NotAllowedError)时静默退出,不报错
- 摄像头权限被拒时显示友好提示

# Canvas 编辑器约定
- 编辑 Canvas 坐标系:与屏幕像素 1:1 对应(不做 DPR 缩放,保持简单)
- 选区坐标:使用 CSS 像素(与 window.innerWidth/innerHeight 一致)
- 撤销历史:使用 ImageData 快照,每次操作前 saveHistory()
- 马赛克粒度:10px(每 10×10 像素合并为一个色块)

# 组件通信
- ScreenCapture: props(screenshotCanvas) → emit(confirm: Blob, cancel)
- CameraCapture: v-model(modelValue) → emit(confirm: Blob, cancel, update:modelValue)
- PendingImagePreview: props(images) → emit(remove: id)
- ReplyBox 作为编排者,统一管理所有子组件的状态和数据流

# 样式约定
- 截图选框颜色:#07C160(微信绿,与现有 ScreenCapture.vue 一致)
- 工具栏按钮:复用 .tb-btn 类(32×28pxhover 高亮 + tooltip
- 弹窗:Element Plus el-dialog 默认主题
- CSS 变量:使用 var(--accent)、var(--bg-secondary) 等项目现有变量

8.2 命名约定

# Composable 函数
- use前缀:useScreenCapture, useCamera, usePendingImages
- 返回值:{ state, actions } 结构,state 为 Refactions 为函数

# 组件文件名
- PascalCaseScreenCapture.vue, CameraCapture.vue, PendingImagePreview.vue

# 事件名
- kebab-case 不适用于 emit,使用 camelCaseconfirm, cancel, remove, update:modelValue

# 类型文件
- src/types/ 目录下,按功能模块命名:screenshot.ts
- 接口名 PascalCase,类型别名可用联合类型

9. 任务依赖图

graph TD
    T01["T01: 基础设施层<br/>类型 + Composable<br/>(4 files)"]
    T02["T02: 核心组件层<br/>ScreenCapture + CameraCapture + Preview<br/>(3 files)"]
    T03["T03: 集成层<br/>ReplyBox + 快捷键 + 联调<br/>(3 files)"]

    T01 --> T02
    T01 --> T03
    T02 --> T03

    style T01 fill:#4caf50,color:#fff,stroke:#388e3c
    style T02 fill:#2196f3,color:#fff,stroke:#1976d2
    style T03 fill:#ff9800,color:#fff,stroke:#f57c00

依赖说明

  • T01 → T02:核心组件依赖类型定义(PendingImage, SelectionBox 等)和 composableuseCamera
  • T01 → T03ReplyBox 集成依赖 usePendingImagescaptureScreenAsCanvas
  • T02 → T03ReplyBox 需要导入并挂载 ScreenCapture、CameraCapture、PendingImagePreview 组件

实施顺序T01 → T02 → T03(严格串行,每步完成后可独立验证)


附录:文件变更总览

frontend-agent/src/
├── types/
│   └── screenshot.ts                    [新建] 共享类型定义
├── composables/
│   ├── useScreenCapture.ts              [修改] 新增 captureScreenAsCanvas()
│   ├── useCamera.ts                     [新建] 摄像头流管理
│   ├── usePendingImages.ts              [新建] 待发送图片队列
│   └── useKeyboardShortcuts.ts          [修改] 新增 Ctrl+Shift+AP2
├── components/chat/
│   ├── ScreenCapture.vue                [修改] 大幅改造:接入 getDisplayMedia + 编辑工具
│   ├── CameraCapture.vue                [新建] 拍照弹窗组件
│   ├── PendingImagePreview.vue          [新建] 待发送图片缩略图预览条
│   └── ReplyBox.vue                     [修改] 大幅改造:按钮 + 预览区 + 发送逻辑
└── (不修改)
    ├── ScreenshotEditor.vue             [保留] html2canvas fallback 备用
    ├── InputBox.vue                     [不动] PRD Q6 决策
    ├── api/upload.ts                    [复用] uploadFile()
    ├── api/message.ts                   [复用] sendMessage()
    └── stores/conversation.ts           [不扩展] 待发送图片用 composable 管理