本提交为 .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-*/
40 KiB
坐席端截图 + 拍照功能 — 系统架构设计
文档版本: v1.0
架构师: Bob (Architect)
日期: 2025-01
关联 PRD:docs/01-产品文档/04-坐席工作台/坐席端截图拍照功能-PRD.md
技术栈: Vue 3 + Element Plus + Tailwind CSS + 原生 Canvas API
目录
Part A: 系统设计
1. 实现方案
1.1 核心技术挑战分析
| 挑战 | 难度 | 解决方案 |
|---|---|---|
| 屏幕捕获 | 中 | getDisplayMedia() 获取屏幕流 → Canvas.drawImage() 捕获单帧。已有 useScreenCapture.ts 可复用 |
| 选区编辑器 | 高 | 全屏 Canvas 遮罩 + 矩形选框 + 8 方向手柄。基于现有 ScreenCapture.vue 改造 |
| 马赛克工具 | 中 | Canvas 像素化:将选定区域缩小再放大(drawImage + imageSmoothingEnabled=false) |
| 撤销机制 | 中 | ImageData 历史栈,每次操作入栈,撤销弹栈恢复 |
| 摄像头预览 | 低 | getUserMedia() + <video> 实时预览,拍照时 drawImage 到 Canvas |
| 待发送图片队列 | 中 | 独立 composable usePendingImages 管理,URL.createObjectURL 生成缩略图 |
| 图文组合发送 | 中 | 发送时遍历图片队列逐张上传+发送,最后发送文字消息(两条独立消息) |
1.2 框架与库选型
| 类别 | 选型 | 理由 |
|---|---|---|
| UI 框架 | Vue 3 Composition API | 项目已使用,<script setup> + 响应式 ref/reactive |
| 组件库 | Element Plus | 项目已使用,el-dialog 用于拍照弹窗,el-button 用于工具栏按钮 |
| 样式 | Tailwind CSS + Scoped CSS | 项目已使用,工具栏等局部样式用 scoped CSS |
| 截图捕获 | 原生 getDisplayMedia() |
PRD 决策:Chrome/Edge 环境,无需 html2canvas fallback |
| 截图编辑 | 原生 Canvas 2D API | PRD 约束:不引入 fabric.js 等重量级库 |
| 摄像头 | 原生 getUserMedia() + <video> |
浏览器原生支持,无额外依赖 |
| 状态管理 | Composable(非 Pinia Store) | 待发送图片队列是 ReplyBox 局部状态,无需全局共享 |
| 文件上传 | 复用现有 uploadFile() |
已有重试机制(3 次指数退避) |
1.3 架构模式
采用 组件 + Composable 模式(Vue 3 推荐范式):
ReplyBox.vue(编排者)
├── useScreenCapture.ts(屏幕捕获逻辑)
├── useCamera.ts(摄像头逻辑)
├── usePendingImages.ts(待发送图片队列)
├── ScreenCapture.vue(截图编辑器 — 全屏覆盖层)
├── CameraCapture.vue(拍照弹窗 — el-dialog)
└── PendingImagePreview.vue(待发送图片缩略图条)
- Composable 层:封装浏览器 API 调用和状态管理,组件只关心 UI
- 组件层:每个组件职责单一,通过
props接收数据、emit传递结果 - 编排者:
ReplyBox.vue作为编排者,协调各组件和数据流
1.4 关键设计决策
决策 1:截图数据传递方式 — Canvas 而非 Blob
useScreenCapture.ts 改造为返回 HTMLCanvasElement(而非 Blob),原因:
ScreenCapture.vue需要原始 Canvas 作为编辑底图- Canvas 可直接
drawImage渲染到遮罩层,无需 Blob→Image 异步转换 - 最终确认时再从选区 Canvas 导出 Blob
决策 2:待发送图片队列放在 Composable 而非 Pinia Store
创建 usePendingImages.ts composable 而非扩展 conversation.ts store,原因:
- 待发送图片是 ReplyBox 的局部 UI 状态,不需要跨组件共享
- 避免污染全局 store(store 已有 60+ 状态和方法)
- composable 可在 ReplyBox 卸载时自动清理
URL.createObjectURL资源
决策 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 类图
classDiagram
%% ===== 类型定义 =====
class PendingImage {
+id: string
+blob: Blob
+previewUrl: string
+source: "screenshot" | "camera"
+createdAt: number
}
class SelectionBox {
+x: number
+y: number
+w: number
+h: number
}
class EditHistoryEntry {
+imageData: ImageData
+timestamp: number
}
class ScreenshotToolConfig {
+name: ScreenshotTool
+icon: string
+label: string
}
%% ===== Composables =====
class useScreenCapture {
-isCapturing: Ref~boolean~
+isScreenCaptureSupported() boolean
+captureScreen() Promise~Blob | null~
+captureScreenAsCanvas() Promise~HTMLCanvasElement | null~
}
class useCamera {
-isActive: Ref~boolean~
-error: Ref~string | null~
-stream: MediaStream | null
+startCamera() Promise~boolean~
+stopCamera() void
+captureFrame() Promise~Blob | null~
+switchCamera() Promise~boolean~
}
class usePendingImages {
-images: Ref~PendingImage[]~
+addImage(blob: Blob, source: string) void
+removeImage(id: string) void
+clearAll() void
+hasImages: ComputedRef~boolean~
+count: ComputedRef~number~
}
%% ===== 组件 =====
class ScreenCapture {
+props: screenshotCanvas: HTMLCanvasElement
+emit: confirm(blob: Blob)
+emit: cancel()
-box: Ref~SelectionBox~
-currentTool: Ref~ScreenshotTool~
-currentColor: Ref~string~
-history: EditHistoryEntry[]
-historyIndex: number
-editCanvas: HTMLCanvasElement
-maskCanvas: HTMLCanvasElement
+startSelect(e: MouseEvent) void
+onMove(e: MouseEvent) void
+startResize(dir: string, e: MouseEvent) void
+drawMosaic(x1, y1, x2, y2) void
+drawArrow(x1, y1, x2, y2) void
+drawText(x, y, text) void
+undo() void
+confirm() void
+cancel() void
}
class CameraCapture {
+props: modelValue: boolean
+emit: confirm(blob: Blob)
+emit: cancel()
+emit: update:modelValue(boolean)
-videoRef: Ref~HTMLVideoElement~
-capturedImage: Ref~string | null~
-cameraError: Ref~string | null~
+startCamera() void
+capture() void
+retake() void
+confirm() void
+close() void
}
class PendingImagePreview {
+props: images: PendingImage[]
+emit: remove(id: string)
-thumbnails: ComputedRef
}
class ReplyBox {
-pendingImages: usePendingImages
-showScreenCapture: Ref~boolean~
-showCameraDialog: Ref~boolean~
-screenshotCanvas: Ref~HTMLCanvasElement | null~
+handleScreenshot() Promise~void~
+handleCamera() void
+onScreenCaptureConfirm(blob: Blob) void
+onCameraConfirm(blob: Blob) void
+onCameraCancel() void
+removePendingImage(id: string) void
+handleSend() Promise~void~
+sendPendingImages(convId: string) Promise~void~
}
%% ===== 关系 =====
ReplyBox --> useScreenCapture : 调用截图
ReplyBox --> useCamera : 调用拍照
ReplyBox --> usePendingImages : 管理待发送图片
ReplyBox --> ScreenCapture : 挂载截图编辑器
ReplyBox --> CameraCapture : 挂载拍照弹窗
ReplyBox --> PendingImagePreview : 挂载预览条
ScreenCapture --> SelectionBox : 选区状态
ScreenCapture --> EditHistoryEntry : 撤销历史
CameraCapture --> useCamera : 摄像头逻辑
PendingImagePreview --> PendingImage : 渲染缩略图
usePendingImages --> PendingImage : 管理队列
3.2 核心类型定义
// src/types/screenshot.ts
/** 待发送图片项 */
export interface PendingImage {
/** 唯一标识(基于时间戳生成) */
id: string
/** 图片 Blob 数据(用于上传) */
blob: Blob
/** 缩略图预览 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(改造后)
// Props
interface ScreenCaptureProps {
/** getDisplayMedia 捕获的屏幕画面 Canvas */
screenshotCanvas: HTMLCanvasElement
}
// Emits
interface ScreenCaptureEmits {
(e: 'confirm', blob: Blob): void // 截图确认,返回编辑后的图片 Blob
(e: 'cancel'): void // 取消截图
}
CameraCapture.vue(新建)
// Props
interface CameraCaptureProps {
/** v-model 控制弹窗显隐 */
modelValue: boolean
}
// Emits
interface CameraCaptureEmits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', blob: Blob): void // 拍照确认,返回图片 Blob
(e: 'cancel'): void // 取消拍照
}
PendingImagePreview.vue(新建)
// Props
interface PendingImagePreviewProps {
/** 待发送图片列表 */
images: PendingImage[]
}
// Emits
interface PendingImagePreviewEmits {
(e: 'remove', id: string): void // 删除指定图片
}
4. 程序调用流程
4.1 截图完整流程时序图
sequenceDiagram
participant User as 用户
participant RB as ReplyBox.vue
participant USC as useScreenCapture
participant SC as ScreenCapture.vue
participant UPI as usePendingImages
participant PIP as PendingImagePreview.vue
User->>RB: 点击截图按钮
RB->>RB: handleScreenshot()
RB->>USC: captureScreenAsCanvas()
Note over USC: 浏览器弹出 getDisplayMedia 选择器
User->>USC: 选择屏幕/窗口/标签页
USC->>USC: getDisplayMedia() 获取视频流
USC->>USC: video.play() → canvas.drawImage() 捕获一帧
USC->>USC: stream.getTracks().stop() 停止共享
USC-->>RB: 返回 HTMLCanvasElement
RB->>RB: screenshotCanvas = canvas
RB->>RB: showScreenCapture = true
Note over SC: 组件挂载,显示全屏遮罩
SC->>SC: 初始化:底图 Canvas + 遮罩 Canvas
SC->>SC: drawMask() 绘制半透明遮罩
User->>SC: 鼠标拖拽框选区域
SC->>SC: 更新 box {x, y, w, h}
SC->>SC: drawMask() 清除选区内遮罩
User->>SC: 选择马赛克工具
User->>SC: 在选区内拖拽涂抹
SC->>SC: saveHistory() 保存当前状态
SC->>SC: drawMosaic() 像素化处理
User->>SC: 选择箭头工具
User->>SC: 拖拽绘制箭头
SC->>SC: saveHistory() 保存当前状态
SC->>SC: drawArrow() 绘制箭头线段
User->>SC: 点击撤销
SC->>SC: historyIndex--
SC->>SC: ctx.putImageData() 恢复上一状态
User->>SC: 点击确认 ✓
SC->>SC: 从编辑 Canvas 裁剪选区
SC->>SC: canvas.toBlob() 转 Blob
SC-->>RB: emit('confirm', blob)
RB->>RB: showScreenCapture = false
RB->>UPI: addImage(blob, 'screenshot')
UPI->>UPI: URL.createObjectURL(blob) 生成预览URL
UPI->>UPI: images.push({ id, blob, previewUrl, source })
UPI-->>RB: 队列已更新
RB->>PIP: 响应式传递 images
PIP->>PIP: 渲染缩略图 + × 删除按钮
PIP-->>User: 显示待发送图片预览
4.2 拍照完整流程时序图
sequenceDiagram
participant User as 用户
participant RB as ReplyBox.vue
participant CC as CameraCapture.vue
participant UC as useCamera
participant UPI as usePendingImages
User->>RB: 点击拍照按钮
RB->>RB: handleCamera()
RB->>RB: showCameraDialog = true
RB->>CC: 挂载组件(v-model=true)
CC->>CC: ElMessage.info('即将请求摄像头权限...')
CC->>UC: startCamera()
UC->>UC: getUserMedia({ video: { facingMode: 'user' } })
Note over UC: 浏览器弹出权限请求
User->>UC: 允许摄像头访问
UC->>UC: stream 赋值到 video.srcObject
UC->>UC: video.play()
UC-->>CC: 摄像头预览已启动
CC-->>User: 显示实时摄像头预览
User->>CC: 点击拍照按钮 📷
CC->>UC: captureFrame()
UC->>UC: canvas.drawImage(video, 0, 0)
UC->>UC: canvas.toDataURL('image/png')
UC-->>CC: 返回图片 dataURL
CC->>CC: capturedImage = dataURL
CC->>CC: 隐藏 video,显示 img 静态画面
CC-->>User: 显示拍照结果 + 重拍/确认按钮
alt 用户点击重拍
User->>CC: 点击重拍
CC->>CC: capturedImage = null
CC->>CC: 恢复 video 实时预览
CC-->>User: 回到实时预览状态
else 用户点击确认
User->>CC: 点击确认 ✓
CC->>UC: captureFrame()(再次捕获确保最新帧)
CC->>CC: dataURL → Blob 转换
CC->>UC: stopCamera() 停止摄像头
CC-->>RB: emit('confirm', blob)
CC-->>RB: emit('update:modelValue', false)
end
RB->>RB: showCameraDialog = false
RB->>UPI: addImage(blob, 'camera')
UPI->>UPI: 生成 previewUrl,加入队列
UPI-->>RB: 队列已更新
RB-->>User: 输入区显示拍照缩略图
4.3 图文组合发送流程时序图
sequenceDiagram
participant User as 用户
participant RB as ReplyBox.vue
participant UPI as usePendingImages
participant API_U as uploadFile()
participant API_M as sendMessage()
participant Store as conversationStore
User->>RB: 输入文字 + 有待发送图片
User->>RB: 点击发送 / 按 Enter
RB->>RB: handleSend()
RB->>RB: const content = inputText.trim()
RB->>RB: const hasImages = pendingImages.count > 0
RB->>RB: const hasText = content.length > 0
alt 无图片且无文字
RB->>RB: return(不发送)
else 有图片
RB->>RB: ElMessage.info('发送中...')
RB->>UPI: 获取 images 数组
loop 遍历每张图片
RB->>API_U: uploadFile(image.blob)
API_U-->>RB: { url, filename, file_size }
RB->>API_M: sendMessage(convId, '[图片]', 'image', { media_url, file_name, file_size })
API_M-->>RB: 返回新消息 Message
RB->>Store: messages.push(newMsg)
end
RB->>UPI: clearAll() 清空图片队列
RB->>RB: URL.revokeObjectURL() 释放预览URL
end
opt 有文字
RB->>RB: emit('send', content)(走原有文字发送逻辑)
RB->>RB: inputText = ''
end
RB->>RB: textareaHeight = 60(恢复默认高度)
RB-->>User: 发送完成,输入区清空
4.4 组件初始化与资源清理流程
sequenceDiagram
participant RB as ReplyBox
participant SC as ScreenCapture
participant CC as CameraCapture
participant UPI as usePendingImages
Note over RB: 组件挂载 onMounted
RB->>RB: 注册全局快捷键(Ctrl+D 语音 / Ctrl+Shift+A 截图P2)
Note over RB: 用户触发截图
RB->>SC: v-if="showScreenCapture" 挂载
SC->>SC: onMounted → 初始化 Canvas、事件监听
SC->>SC: drawMask() 绘制初始遮罩
Note over RB: 截图取消/确认
RB->>SC: v-if=false 卸载
SC->>SC: onUnmounted → 移除事件监听、清空 history
Note over RB: 用户触发拍照
RB->>CC: v-model=true 挂载
CC->>CC: onMounted → startCamera()
Note over RB: 拍照取消/确认
RB->>CC: v-model=false 卸载
CC->>CC: onUnmounted → stopCamera() 停止流
Note over RB: ReplyBox 卸载
RB->>RB: onUnmounted
RB->>UPI: clearAll() → revokeObjectURL 释放内存
RB->>RB: 移除快捷键监听
5. 待明确事项
| 编号 | 事项 | 当前假设 | 影响范围 |
|---|---|---|---|
| A1 | 图片上传并发限制 | 假设逐张串行上传(避免并发请求过多)。如需并发可改用 Promise.all 但需控制并发数 |
ReplyBox.vue sendPendingImages |
| A2 | 图片上传中途失败 | 假设某张图片上传失败时,ElMessage.error 提示该张失败,剩余图片继续发送,已上传的图片消息不撤回 |
ReplyBox.vue sendPendingImages |
| A3 | 截图选区最小尺寸 | 假设最小 20×20 像素(与现有 ScreenCapture.vue 一致),低于此值自动取消选区 |
ScreenCapture.vue endSelect |
| A4 | 马赛克粒度 | 假设像素块大小为 10px(即每 10×10 像素合并为一个色块),可后续调整 | ScreenCapture.vue drawMosaic |
| A5 | 摄像头默认分辨率 | 假设请求 1280×720,浏览器可能返回不同分辨率,<video> 自适应 |
useCamera.ts startCamera |
| A6 | Ctrl+Shift+A 快捷键 | PRD 标记为 P2,架构中预留接口但 P0 不实现。快捷键需全局生效(焦点不在输入框时) | useKeyboardShortcuts.ts |
| A7 | 待发送图片最大数量 | PRD 未限制。假设无上限,但建议 UI 层超过 10 张时提示。内存方面每张约 200KB-2MB | usePendingImages.ts |
Part B: 任务分解
6. 依赖包列表
本项目 无需引入任何新的第三方依赖。所有功能基于浏览器原生 API 和项目现有依赖实现:
# 现有依赖(已安装,无需额外操作)
- vue@^3.4.0 — 响应式框架(已有)
- element-plus@^2.7.0 — el-dialog / el-button 组件(已有)
- @element-plus/icons-vue@^2.3.0 — 图标(已有)
- pinia@^2.1.0 — 状态管理(已有,本功能不新增 store)
# 浏览器原生 API(无需安装)
- navigator.mediaDevices.getDisplayMedia() — 屏幕捕获
- navigator.mediaDevices.getUserMedia() — 摄像头访问
- Canvas 2D API — 截图编辑(马赛克/箭头/文字)
- URL.createObjectURL() / revokeObjectURL() — 图片预览URL
# 注意:html2canvas-pro 仍保留在依赖中(ScreenshotEditor.vue fallback 使用),
# 但新截图功能不再依赖它
7. 任务列表
T01: 基础设施层 — 类型定义 + Composable 函数
| 属性 | 值 |
|---|---|
| 任务 ID | T01 |
| 任务名称 | 基础设施层:类型定义 + 屏幕捕获/摄像头/待发送图片 Composable |
| 源文件 | src/types/screenshot.ts(新建)src/composables/useScreenCapture.ts(修改)src/composables/useCamera.ts(新建)src/composables/usePendingImages.ts(新建) |
| 依赖 | 无 |
| 优先级 | P0 |
任务描述:
-
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'
- 定义
-
src/composables/useScreenCapture.ts(修改)- 保留现有
captureScreen()返回Promise<Blob | null>(向后兼容) - 新增
captureScreenAsCanvas()方法:返回Promise<HTMLCanvasElement | null>- 内部逻辑与
captureScreen()类似,但不做canvas.toBlob()转换 - 直接返回捕获了屏幕画面的 Canvas 元素
- 供
ScreenCapture.vue作为编辑底图使用
- 内部逻辑与
- 导出
captureScreenAsCanvas和useScreenCapture都包含它
- 保留现有
-
src/composables/useCamera.ts(新建)startCamera(facingMode?: 'user' | 'environment'):Promise<boolean>— 调用getUserMedia,返回是否成功stopCamera():void— 停止所有视频轨道,释放摄像头captureFrame():Promise<Blob | null>— 从<video>当前帧drawImage到 Canvas,返回 PNG BlobswitchCamera():Promise<boolean>— 切换前置/后置摄像头(P1)- 响应式状态:
isActive: Ref<boolean>、error: Ref<CameraError | null>、stream: Ref<MediaStream | null> - 错误处理:
NotAllowedError→permission-denied;NotFoundError→no-device;NotReadableError→in-use
-
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(修改)src/components/chat/CameraCapture.vue(新建)src/components/chat/PendingImagePreview.vue(新建) |
| 依赖 | T01 |
| 优先级 | P0 |
任务描述:
-
src/components/chat/ScreenCapture.vue(大幅改造)Props 变更:
- 新增
screenshotCanvas: HTMLCanvasElementprop(接收 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),取消按钮 emitcancel()
确认逻辑改造:
- 从编辑 Canvas 裁剪选区区域 →
canvas.toBlob()→emit('confirm', blob) - 不再直接上传发送(由 ReplyBox 接收后加入待发送队列)
保留功能:8 方向手柄调整选区、ESC 退出、选区拖拽
- 新增
-
src/components/chat/CameraCapture.vue(新建)模板结构:
<el-dialog v-model="visible" title="拍照" width="640px" :close-on-click-modal="false"> <!-- 预览区 --> <div class="camera-preview"> <video v-if="!capturedImage" ref="videoRef" autoplay muted /> <img v-else :src="capturedImage" class="captured-photo" /> <!-- 错误状态 --> <div v-if="cameraError" class="camera-error"> <p>{{ errorMessage }}</p> </div> </div> <!-- 控制区 --> <template #footer> <div v-if="!capturedImage && !cameraError"> <el-button @click="close">取消</el-button> <el-button type="primary" circle @click="capture">📷</el-button> </div> <div v-else-if="capturedImage"> <el-button @click="retake">重拍</el-button> <el-button type="primary" @click="confirm">确认 ✓</el-button> </div> <div v-else> <el-button @click="close">关闭</el-button> </div> </template> </el-dialog>逻辑:
onMounted/watch(modelValue):弹窗打开时调用useCamera.startCamera()capture():调用useCamera.captureFrame()→ 设置capturedImageretake():清空capturedImage,恢复 video 预览confirm():capturedImage→ Blob →emit('confirm', blob)→emit('update:modelValue', false)close()/ESC:emit('cancel')→emit('update:modelValue', false)onUnmounted:调用useCamera.stopCamera()确保释放摄像头
-
src/components/chat/PendingImagePreview.vue(新建)模板结构:
<div v-if="images.length > 0" class="pending-images-bar"> <div class="thumbnail" v-for="img in images" :key="img.id"> <img :src="img.previewUrl" class="thumb-img" /> <button class="thumb-remove" @click="$emit('remove', img.id)">✕</button> </div> </div>Props:
images: PendingImage[]Emits:remove(id: string)样式:横向排列、80×80px 缩略图、圆角、右上角 × 删除按钮、超出宽度横向滚动
T03: 集成层 — ReplyBox 改造 + 快捷键 + 联调
| 属性 | 值 |
|---|---|
| 任务 ID | T03 |
| 任务名称 | 集成层:ReplyBox 工具栏 + 待发送图片区 + 发送逻辑 + 快捷键 |
| 源文件 | src/components/chat/ReplyBox.vue(修改)src/composables/usePendingImages.ts(集成验证)src/composables/useKeyboardShortcuts.ts(修改) |
| 依赖 | T01, T02 |
| 优先级 | P0 |
任务描述:
-
src/components/chat/ReplyBox.vue(大幅改造)模板变更:
- 工具栏新增截图按钮和拍照按钮(放在最左侧):
<!-- 改造后工具栏顺序:截图 | 拍照 | 分隔线 | 表情 | 文件 | 分隔线 | 邀请 | 快速回复 --> <button class="tb-btn" title="截图" @click="handleScreenshot">✂️</button> <button class="tb-btn" title="拍照" @click="handleCamera">📷</button> <div class="tb-sep"></div> <!-- ... 现有按钮 ... --> - 在工具栏下方、textarea 上方插入待发送图片预览区:
<PendingImagePreview :images="pendingImages.images.value" @remove="pendingImages.removeImage" /> - 挂载 CameraCapture 组件:
<CameraCapture v-model="showCameraDialog" @confirm="onCameraConfirm" @cancel="onCameraCancel" /> - ScreenCapture 挂载方式变更:新增
:screenshot-canvasprop<ScreenCapture v-if="showScreenCapture" :screenshot-canvas="screenshotCanvas" @confirm="onScreenCaptureConfirm" @cancel="onScreenCaptureCancel" />
Script 变更:
- 导入新组件和 composable:
import CameraCapture from './CameraCapture.vue' import PendingImagePreview from './PendingImagePreview.vue' import { captureScreenAsCanvas } from '@/composables/useScreenCapture' import { usePendingImages } from '@/composables/usePendingImages' - 新增状态:
const showCameraDialog = ref(false) const screenshotCanvas = ref<HTMLCanvasElement | null>(null) const pendingImages = usePendingImages() - 改造
handleScreenshot():async function handleScreenshot() { const convId = conversationStore.currentConversation?.id if (!convId) { ElMessage.warning('请先选择一个会话'); return } const canvas = await captureScreenAsCanvas() if (!canvas) return // 用户取消或不支持 screenshotCanvas.value = canvas showScreenCapture.value = true } - 新增
handleCamera():function handleCamera() { const convId = conversationStore.currentConversation?.id if (!convId) { ElMessage.warning('请先选择一个会话'); return } showCameraDialog.value = true } - 改造
onScreenCaptureConfirm(blob):// 旧:直接 uploadFile + sendMessage // 新:加入待发送图片队列 function onScreenCaptureConfirm(blob: Blob) { showScreenCapture.value = false screenshotCanvas.value = null pendingImages.addImage(blob, 'screenshot') } - 新增
onCameraConfirm(blob):function onCameraConfirm(blob: Blob) { showCameraDialog.value = false pendingImages.addImage(blob, 'camera') } - 核心改造
handleSend():async function handleSend() { const content = inputText.value.trim() const hasImages = pendingImages.hasImages.value const hasText = content.length > 0 if (!hasImages && !hasText) return const convId = conversationStore.currentConversation?.id if (!convId) return // 有图片:逐张上传 + 发送图片消息 if (hasImages) { await sendPendingImages(convId) } // 有文字:发送文字消息(走原有 emit 逻辑) if (hasText) { emit('send', content) } // 清空输入 inputText.value = '' textareaHeight.value = 60 } - 新增
sendPendingImages(convId):async function sendPendingImages(convId: string) { ElMessage.info('发送中...') const images = [...pendingImages.images.value] for (const img of images) { try { const result = await uploadFile(img.blob) const newMsg = await sendMessage(convId, '[图片]', 'image', { media_url: result.url, file_name: result.filename, file_size: result.file_size, }) conversationStore.messages.push(newMsg) } catch (error) { ElMessage.error(`图片发送失败:${error?.message || '未知错误'}`) } } pendingImages.clearAll() ElMessage.success('发送成功') } onUnmounted中新增:pendingImages.clearAll()释放 ObjectURL- 发送按钮 disabled 逻辑变更:
!inputText.trim() && !pendingImages.hasImages.value
- 工具栏新增截图按钮和拍照按钮(放在最左侧):
-
src/composables/usePendingImages.ts(集成验证)- 确保
usePendingImages返回的images是Ref<PendingImage[]>,ReplyBox 通过.value访问 - 确保
addImage/removeImage/clearAll方法正确触发响应式更新 - 确保
revokeObjectURL在 removeImage 和 clearAll 中被调用
- 确保
-
src/composables/useKeyboardShortcuts.ts(小幅修改)- 新增
Ctrl+Shift+A快捷键支持(P2 预留,不阻塞 P0):interface UseKeyboardShortcutsOptions { // ... 现有选项 ... /** 截图快捷键(Ctrl+Shift+A)*/ onScreenshot?: () => void } - 在
registerShortcuts()中注册:{ ctrl: true, shift: true, key: 'a', handler: () => options.onScreenshot?.() } - 注意:
Ctrl+Shift+A在输入框聚焦时也应生效(entry.ctrl为 true 时允许)
- 新增
8. 共享知识
8.1 跨文件约定
# 图片数据格式
- 截图和拍照结果统一为 PNG 格式的 Blob 对象
- 截图编辑器内部使用 Canvas 2D API,最终通过 canvas.toBlob(blob => ..., 'image/png') 导出
- 拍照组件通过 canvas.toDataURL('image/png') 再转 Blob,或直接 canvas.toBlob()
# 待发送图片队列
- 图片队列由 usePendingImages composable 管理
- 每张图片通过 URL.createObjectURL(blob) 生成 previewUrl 用于缩略图显示
- 删除图片或清空队列时必须调用 URL.revokeObjectURL(previewUrl) 释放内存
- 队列是 ReplyBox 局部状态,不放入 Pinia Store
# 发送逻辑
- 图文组合发送:先逐张上传+发送图片消息,再发送文字消息(两条独立消息)
- 图片消息 content 为 '[图片]',msg_type 为 'image'
- 文字消息走原有 emit('send', content) 逻辑
- 发送成功后清空图片队列和输入框
- 上传失败时:ElMessage.error 提示,继续发送剩余图片,不中断流程
# API 复用
- 上传:uploadFile(file: File | Blob) → { url, filename, file_size, msg_type }
- 发送:sendMessage(convId, content, msgType, options) → Message
- 两个 API 均已有错误处理和重试机制
# 浏览器 API
- getDisplayMedia:仅在 HTTPS 或 localhost 下可用(生产环境 itsupport.servyou.com.cn 满足)
- getUserMedia:同上
- 用户取消屏幕选择(NotAllowedError)时静默退出,不报错
- 摄像头权限被拒时显示友好提示
# Canvas 编辑器约定
- 编辑 Canvas 坐标系:与屏幕像素 1:1 对应(不做 DPR 缩放,保持简单)
- 选区坐标:使用 CSS 像素(与 window.innerWidth/innerHeight 一致)
- 撤销历史:使用 ImageData 快照,每次操作前 saveHistory()
- 马赛克粒度:10px(每 10×10 像素合并为一个色块)
# 组件通信
- ScreenCapture: props(screenshotCanvas) → emit(confirm: Blob, cancel)
- CameraCapture: v-model(modelValue) → emit(confirm: Blob, cancel, update:modelValue)
- PendingImagePreview: props(images) → emit(remove: id)
- ReplyBox 作为编排者,统一管理所有子组件的状态和数据流
# 样式约定
- 截图选框颜色:#07C160(微信绿,与现有 ScreenCapture.vue 一致)
- 工具栏按钮:复用 .tb-btn 类(32×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. 任务依赖图
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 管理