facc04aa65
本提交为 .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-*/
1077 lines
40 KiB
Markdown
1077 lines
40 KiB
Markdown
# 坐席端截图 + 拍照功能 — 系统架构设计
|
||
|
||
> **文档版本**: 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` 资源
|
||
|
||
**决策 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 管理
|
||
```
|