2026-08-07 22:31:32 +08:00
|
|
|
|
# 技术方案 - REQ-坐席-001 截图拍照
|
2026-07-11 23:13:10 +08:00
|
|
|
|
|
2026-08-07 22:31:32 +08:00
|
|
|
|
> **版本**: v1.0
|
|
|
|
|
|
> **日期**: 2025-01
|
|
|
|
|
|
> **REQ编号**: REQ-坐席-001
|
|
|
|
|
|
> **关联PRD**: `PRD-REQ-坐席-001-截图拍照-v1.0.md`
|
|
|
|
|
|
> **状态**: 已完成
|
|
|
|
|
|
> **架构师**: Bob (Architect)
|
2026-07-11 23:13:10 +08:00
|
|
|
|
> **技术栈**: 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` 资源
|
|
|
|
|
|
|
|
|
|
|
|
**决策 3:ScreenCapture.vue 接收截图作为 prop**
|
|
|
|
|
|
|
|
|
|
|
|
改造后的 `ScreenCapture.vue` 不再自己调用截图 API,而是接收 `screenshotCanvas` prop(HTMLCanvasElement),原因:
|
|
|
|
|
|
- 职责分离:截图捕获(useScreenCapture)与截图编辑(ScreenCapture)解耦
|
|
|
|
|
|
- ReplyBox 统一编排:先捕获截图,再挂载编辑器
|
|
|
|
|
|
- 便于未来扩展(如从粘贴板接收图片进入编辑器)
|
|
|
|
|
|
|
|
|
|
|
|
**决策 4:马赛克实现方式 — 区域像素化**
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
用户拖拽马赛克工具 → 记录拖拽路径 → 在路径区域:
|
|
|
|
|
|
1. 从底图 Canvas 提取该区域 ImageData
|
|
|
|
|
|
2. 创建临时小 Canvas(宽高 / 10)
|
|
|
|
|
|
3. drawImage 将区域缩小绘制到小 Canvas
|
|
|
|
|
|
4. drawImage 将小 Canvas 放大绘制回编辑 Canvas(imageSmoothingEnabled=false)
|
|
|
|
|
|
5. 结果:像素化马赛克效果
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**决策 5:文字工具用内联 input 而非 prompt()**
|
|
|
|
|
|
|
|
|
|
|
|
PRD 要求不用 `prompt()`。实现方式:
|
|
|
|
|
|
- 在 Canvas 上方覆盖一个绝对定位的 `<input>` 元素
|
|
|
|
|
|
- 用户输入文字后按回车,将文字 `fillText` 到 Canvas
|
|
|
|
|
|
- 输入框位置跟随点击坐标
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 2. 文件列表
|
|
|
|
|
|
|
|
|
|
|
|
所有路径相对于 `frontend-agent/`:
|
|
|
|
|
|
|
|
|
|
|
|
#### 新建文件
|
|
|
|
|
|
|
|
|
|
|
|
| 文件路径 | 说明 |
|
|
|
|
|
|
|----------|------|
|
|
|
|
|
|
| `src/types/screenshot.ts` | 截图/拍照功能共享 TypeScript 类型定义 |
|
|
|
|
|
|
| `src/composables/useCamera.ts` | 摄像头流管理 composable(getUserMedia 封装) |
|
|
|
|
|
|
| `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
|
|
|
|
|
|
/** 缩略图预览 URL(URL.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×28px,hover 高亮 + tooltip)
|
|
|
|
|
|
- 弹窗:Element Plus el-dialog 默认主题
|
|
|
|
|
|
- CSS 变量:使用 var(--accent)、var(--bg-secondary) 等项目现有变量
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 8.2 命名约定
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
# Composable 函数
|
|
|
|
|
|
- use前缀:useScreenCapture, useCamera, usePendingImages
|
|
|
|
|
|
- 返回值:{ state, actions } 结构,state 为 Ref,actions 为函数
|
|
|
|
|
|
|
|
|
|
|
|
# 组件文件名
|
|
|
|
|
|
- PascalCase:ScreenCapture.vue, CameraCapture.vue, PendingImagePreview.vue
|
|
|
|
|
|
|
|
|
|
|
|
# 事件名
|
|
|
|
|
|
- kebab-case 不适用于 emit,使用 camelCase:confirm, 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+A(P2)
|
|
|
|
|
|
├── 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 管理
|
|
|
|
|
|
```
|