Files
wecom_it_smart_desk/docs/02-技术文档/01-架构设计/坐席端截图拍照功能-架构设计.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

1077 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 坐席端截图 + 拍照功能 — 系统架构设计
> **文档版本**: v1.0
> **架构师**: Bob (Architect)
> **日期**: 2025-01
> **关联 PRD**: `docs/01-产品文档/04-坐席工作台/坐席端截图拍照功能-PRD.md`
> **技术栈**: 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 管理
```