Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-坐席-001-截图拍照-v1.0.md
T

1079 lines
40 KiB
Markdown
Raw Normal View 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: 系统设计](#part-a-系统设计)
- [1. 实现方案](#1-实现方案)
- [2. 文件列表](#2-文件列表)
- [3. 数据结构和接口](#3-数据结构和接口)
- [4. 程序调用流程](#4-程序调用流程)
- [5. 待明确事项](#5-待明确事项)
- [Part B: 任务分解](#part-b-任务分解)
- [6. 依赖包列表](#6-依赖包列表)
- [7. 任务列表](#7-任务列表)
- [8. 共享知识](#8-共享知识)
- [9. 任务依赖图](#9-任务依赖图)
---
## 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 类图
```mermaid
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 核心类型定义
```typescript
// 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(改造后)**
```typescript
// Props
interface ScreenCaptureProps {
/** getDisplayMedia 捕获的屏幕画面 Canvas */
screenshotCanvas: HTMLCanvasElement
}
// Emits
interface ScreenCaptureEmits {
(e: 'confirm', blob: Blob): void // 截图确认,返回编辑后的图片 Blob
(e: 'cancel'): void // 取消截图
}
```
**CameraCapture.vue(新建)**
```typescript
// 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(新建)**
```typescript
// Props
interface PendingImagePreviewProps {
/** 待发送图片列表 */
images: PendingImage[]
}
// Emits
interface PendingImagePreviewEmits {
(e: 'remove', id: string): void // 删除指定图片
}
```
---
### 4. 程序调用流程
#### 4.1 截图完整流程时序图
```mermaid
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 拍照完整流程时序图
```mermaid
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 图文组合发送流程时序图
```mermaid
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 组件初始化与资源清理流程
```mermaid
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`(新建)<br>`src/composables/useScreenCapture.ts`(修改)<br>`src/composables/useCamera.ts`(新建)<br>`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` 作为编辑底图使用
- 导出 `captureScreenAsCanvas``useScreenCapture` 都包含它
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>`
- 错误处理:`NotAllowedError``permission-denied``NotFoundError``no-device``NotReadableError``in-use`
4. **`src/composables/usePendingImages.ts`(新建)**
- `addImage(blob: Blob, source: 'screenshot' | 'camera')`: `void` — 生成 id`Date.now() + random`)、`previewUrl``URL.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`(修改)<br>`src/components/chat/CameraCapture.vue`(新建)<br>`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 颜色默认红色 `#ff3b30`P1 加颜色选择器
- 确认按钮 emit `confirm(blob)`,取消按钮 emit `cancel()`
**确认逻辑改造**
- 从编辑 Canvas 裁剪选区区域 → `canvas.toBlob()` → `emit('confirm', blob)`
- 不再直接上传发送(由 ReplyBox 接收后加入待发送队列)
**保留功能**:8 方向手柄调整选区、ESC 退出、选区拖拽
2. **`src/components/chat/CameraCapture.vue`(新建)**
**模板结构**
```html
<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()`/ESC`emit('cancel')` → `emit('update:modelValue', false)`
- `onUnmounted`:调用 `useCamera.stopCamera()` 确保释放摄像头
3. **`src/components/chat/PendingImagePreview.vue`(新建)**
**模板结构**
```html
<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>
```
**Props**`images: PendingImage[]`
**Emits**`remove(id: string)`
**样式**:横向排列、80×80px 缩略图、圆角、右上角 × 删除按钮、超出宽度横向滚动
---
#### T03: 集成层 — ReplyBox 改造 + 快捷键 + 联调
| 属性 | 值 |
|------|-----|
| **任务 ID** | T03 |
| **任务名称** | 集成层:ReplyBox 工具栏 + 待发送图片区 + 发送逻辑 + 快捷键 |
| **源文件** | `src/components/chat/ReplyBox.vue`(修改)<br>`src/composables/usePendingImages.ts`(集成验证)<br>`src/composables/useKeyboardShortcuts.ts`(修改) |
| **依赖** | T01, T02 |
| **优先级** | P0 |
**任务描述**
1. **`src/components/chat/ReplyBox.vue`(大幅改造)**
**模板变更**
- 工具栏新增截图按钮和拍照按钮(放在最左侧):
```html
<!-- 改造后工具栏顺序:截图 | 拍照 | 分隔线 | 表情 | 文件 | 分隔线 | 邀请 | 快速回复 -->
<button class="tb-btn" title="截图" @click="handleScreenshot">✂️</button>
<button class="tb-btn" title="拍照" @click="handleCamera">📷</button>
<div class="tb-sep"></div>
<!-- ... 现有按钮 ... -->
```
- 在工具栏下方、textarea 上方插入待发送图片预览区:
```html
<PendingImagePreview
:images="pendingImages.images.value"
@remove="pendingImages.removeImage"
/>
```
- 挂载 CameraCapture 组件:
```html
<CameraCapture
v-model="showCameraDialog"
@confirm="onCameraConfirm"
@cancel="onCameraCancel"
/>
```
- ScreenCapture 挂载方式变更:新增 `:screenshot-canvas` prop
```html
<ScreenCapture
v-if="showScreenCapture"
:screenshot-canvas="screenshotCanvas"
@confirm="onScreenCaptureConfirm"
@cancel="onScreenCaptureCancel"
/>
```
**Script 变更**
- 导入新组件和 composable
```typescript
import CameraCapture from './CameraCapture.vue'
import PendingImagePreview from './PendingImagePreview.vue'
import { captureScreenAsCanvas } from '@/composables/useScreenCapture'
import { usePendingImages } from '@/composables/usePendingImages'
```
- 新增状态:
```typescript
const showCameraDialog = ref(false)
const screenshotCanvas = ref<HTMLCanvasElement | null>(null)
const pendingImages = usePendingImages()
```
- 改造 `handleScreenshot()`
```typescript
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()`
```typescript
function handleCamera() {
const convId = conversationStore.currentConversation?.id
if (!convId) { ElMessage.warning('请先选择一个会话'); return }
showCameraDialog.value = true
}
```
- 改造 `onScreenCaptureConfirm(blob)`
```typescript
// 旧:直接 uploadFile + sendMessage
// 新:加入待发送图片队列
function onScreenCaptureConfirm(blob: Blob) {
showScreenCapture.value = false
screenshotCanvas.value = null
pendingImages.addImage(blob, 'screenshot')
}
```
- 新增 `onCameraConfirm(blob)`
```typescript
function onCameraConfirm(blob: Blob) {
showCameraDialog.value = false
pendingImages.addImage(blob, 'camera')
}
```
- **核心改造 `handleSend()`**
```typescript
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)`
```typescript
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` 返回的 `images` 是 `Ref<PendingImage[]>`ReplyBox 通过 `.value` 访问
- 确保 `addImage` / `removeImage` / `clearAll` 方法正确触发响应式更新
- 确保 `revokeObjectURL` 在 removeImage 和 clearAll 中被调用
3. **`src/composables/useKeyboardShortcuts.ts`(小幅修改)**
- 新增 `Ctrl+Shift+A` 快捷键支持(P2 预留,不阻塞 P0):
```typescript
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. 任务依赖图
```mermaid
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` 等)和 composable`useCamera`
- **T01 → T03**ReplyBox 集成依赖 `usePendingImages`、`captureScreenAsCanvas`
- **T02 → T03**ReplyBox 需要导入并挂载 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 管理
```