标注工具组件功能文档
一、组件概述
该标注工具是一个基于 Canvas 的交互式绘图组件,支持多种绘图工具、测量功能、隐私遮挡(蒙版和马赛克)以及协作标注等功能。组件采用 Vue 3 + TypeScript 开发,结合 Element Plus 组件库,提供了丰富的标注能力和友好的用户界面。
二、核心功能模块
1. 基础绘图功能
功能说明
提供多种基本绘图工具,包括手绘笔、直线、箭头、矩形和圆形,支持调整线宽和颜色。
实现步骤
画布初始化
typescript
// 在onMounted钩子中初始化画布 onMounted(() => {initCanvas();window.addEventListener('resize', initCanvas); });// 初始化画布函数 const initCanvas = () => {if (!canvasRef.value) return;const canvas = canvasRef.value;// 设置画布实际尺寸为显示尺寸canvas.width = canvas.offsetWidth;canvas.height = canvas.offsetHeight;canvasContext = canvas.getContext('2d');if (canvasContext) {canvasContext.lineJoin = 'round';canvasContext.lineCap = 'round';canvasContext.font = '16px Arial';} };
绘图状态管理
typescript
// 定义绘图状态变量 const isDrawing = ref(false); const activeTool = ref<DrawingTool>('pen'); const strokeColor = ref('red'); const strokeWidthPx = ref(3); const strokeWidth = ref(0.003); // 相对线宽(基于参考宽度的百分比)
绘图事件处理
typescript
// 开始绘图 const startDrawing = (e: MouseEvent) => {if (!props.isInitiator || !canvasContext || !canvasRef.value) return;// 计算百分比坐标 (0-1)const percentPoint = getPercentCoordinates(e.clientX, e.clientY, canvasRef.value);// 根据当前工具类型初始化绘图动作currentAction = {tool: activeTool.value,points: [percentPoint],color: strokeColor.value,width: strokeWidth.value};isDrawing.value = true;startPoint = percentPoint; };// 绘图过程 const draw = (e: MouseEvent) => {if (!isDrawing.value || !currentAction) return;// 计算当前鼠标的百分比坐标const currentPercent = getPercentCoordinates(e.clientX, e.clientY, canvasRef.value!);// 根据工具类型处理不同的绘图逻辑if (currentAction.tool === 'line') {currentAction.points = [currentAction.points[0], currentPercent];} else {currentAction.points.push(currentPercent);}// 实时绘制预览redrawCanvas(); };// 停止绘图 const stopDrawing = () => {if (!isDrawing.value || !currentAction) return;isDrawing.value = false;// 发送完整的绘图动作sendDrawingAction(currentAction);currentAction = null;startPoint = null; };
重绘机制
typescript
const redrawCanvas = () => {if (!canvasContext || !canvasRef.value) return;const canvasWidth = canvasRef.value.width;const canvasHeight = canvasRef.value.height;// 清空画布canvasContext.clearRect(0, 0, canvasWidth, canvasHeight);// 重绘历史记录drawingHistory.value.forEach((action, index) => {drawAction(action, canvasWidth, canvasHeight);});// 绘制当前正在进行的动作if (currentAction) {drawAction(currentAction, canvasWidth, canvasHeight);} };
2. 测量工具功能
功能说明
提供长度测量功能,支持厘米、毫米和像素三种单位,可设置缩放比例,实现实际尺寸测量。
实现步骤
测量状态管理
typescript
// 测量工具相关状态 const measureScale = ref(1); // 缩放比例(默认1像素=1单位) const measureUnit = ref('cm'); // 默认单位:厘米 const measureAreas = ref<number[]>([]); // 存储测量工具索引
测量绘制实现
typescript
// 绘制测量结果 const drawMeasure = (points: Point[], action: DrawingAction, canvasWidth: number) => {if (!canvasContext || points.length < 2) return;const start = points[0];const end = points[1];// 绘制测量线(虚线)canvasContext.strokeStyle = action.color;canvasContext.lineWidth = action.width * canvasWidth;canvasContext.setLineDash([5, 5]); // 虚线样式canvasContext.beginPath();canvasContext.moveTo(start.x, start.y);canvasContext.lineTo(end.x, end.y);canvasContext.stroke();canvasContext.setLineDash([]); // 重置为实线// 绘制测量值标签if (action.measureValue !== undefined && action.measureUnit) {const midPoint = {x: (start.x + end.x) / 2 + 10,y: (start.y + end.y) / 2 - 10};canvasContext.fillStyle = action.color;canvasContext.font = `${0.015 * canvasWidth}px Arial`;canvasContext.fillText(`${action.measureValue}${action.measureUnit}`, midPoint.x, midPoint.y);}// 绘制端点canvasContext.beginPath();canvasContext.arc(start.x, start.y, 5, 0, Math.PI * 2);canvasContext.arc(end.x, end.y, 5, 0, Math.PI * 2);canvasContext.fill(); };
测量计算逻辑
typescript
// 计算像素到厘米的转换因子 const getPxToCmFactor = () => {const dpi = props.dpi || DEFAULT_DPI;return INCH_TO_CM / dpi; // 1英寸 = 2.54厘米 };
清除测量结果
typescript
const clearMeasurements = () => {if (measureAreas.value.length === 0) {ElMessage.info('没有可清除的测量数据');return;}ElMessageBox.confirm('确定要清除所有测量数据吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'info'}).then(() => {// 从后往前删除,避免索引混乱measureAreas.value.sort((a, b) => b - a).forEach(index => {drawingHistory.value.splice(index, 1);});// 重置测量工具索引记录measureAreas.value = [];redrawCanvas();// 发送清除测量指令给所有参与方if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'clearMeasure',userId: props.userId,creater: props.creater});}}); };
3. 隐私遮挡功能
功能说明
提供两种隐私保护工具:蒙版和马赛克,用于遮挡敏感信息。蒙版提供一个可调整大小和透明度的圆形遮挡区域;马赛克提供像素化处理功能。
实现步骤
蒙版功能实现
typescript
// 蒙版相关状态 const menbanSizePercent = ref(0.1); // 蒙版尺寸占画布宽度的10% const menbanOpacity = ref(0.7);// 绘制蒙版 const drawMenban = (points: Point[], size: number, opacity: number) => {if (!canvasContext || points.length < 1) return;const center = points[0];// 保存当前上下文状态canvasContext.save();// 设置蒙版样式canvasContext.fillStyle = `rgba(0, 0, 0, ${opacity})`;// 绘制全屏矩形(作为背景)canvasContext.fillRect(0, 0, canvasRef.value!.width, canvasRef.value!.height);// 清除中间区域,形成"蒙版窗口"效果canvasContext.globalCompositeOperation = 'destination-out';// 绘制圆形窗口canvasContext.beginPath();canvasContext.arc(center.x,center.y,size / 2, // 半径为尺寸的一半0,Math.PI * 2);canvasContext.fill();// 恢复上下文状态canvasContext.restore(); };
马赛克功能实现
typescript
// 马赛克相关状态 const mosaicBlockSize = ref(10); // 马赛克块大小(像素) const mosaicAreas = ref<number[]>([]); // 存储马赛克区域索引// 绘制标准马赛克 const drawMosaic = (points: Point[], action: DrawingAction, canvasWidth: number, canvasHeight: number) => {if (!canvasContext || points.length < 2) return;// 保存当前上下文状态canvasContext.save();// 创建马赛克区域路径const path = new Path2D();path.moveTo(points[0].x, points[0].y);for (let i = 1; i < points.length; i++) {path.lineTo(points[i].x, points[i].y);}path.closePath();canvasContext.clip(path);// 获取路径边界const { minX, minY, maxX, maxY } = getPathBounds(points);const blockSize = action.mosaicSize || 10;// 绘制标准马赛克(像素块效果)for (let y = minY; y < maxY; y += blockSize) {for (let x = minX; x < maxX; x += blockSize) {// 深灰色 + 中等透明度canvasContext.fillStyle = `rgba(120, 120, 120, 0.7)`;canvasContext.fillRect(x, y, blockSize, blockSize);}}// 恢复上下文状态canvasContext.restore(); };
4. 截图与保存功能
功能说明
支持将当前画布内容(包括所有标注)截图保存到本地,或上传至文件管理系统。
实现步骤
截图功能实现
typescript
const takeScreenshot = async () => {if (!canvasRef.value) return;try {// 获取原始画布及其尺寸const originalCanvas = canvasRef.value;const originalWidth = originalCanvas.width;const originalHeight = originalCanvas.height;// 创建临时画布tempCanvas.value = document.createElement('canvas');tempCanvas.value.width = originalWidth;tempCanvas.value.height = originalHeight;const ctx = tempCanvas.value.getContext('2d');if (!ctx) return;// 1. 绘制底层内容// ...省略底层内容绘制逻辑...// 2. 绘制非蒙版标注内容drawingHistory.value.forEach(action => {if (action.tool !== 'menban') {drawActionToCanvas(action, ctx, tempCanvas.value!);}});// 3. 绘制当前正在进行的动作(非蒙版)if (currentAction && currentAction.tool !== 'menban') {drawActionToCanvas(currentAction, ctx, tempCanvas.value!);}// 4. 生成截图数据const dataUrl = tempCanvas.value.toDataURL('image/png');previewDialog.value.url = dataUrl;previewDialog.value.show = true;// 自动保存到本地downloadImage(dataUrl, fileName.value);// 准备文件数据const blob = await fetch(dataUrl).then(res => res.blob());imgFile.value = new File([blob], fileName.value, { type: 'image/png' });} catch (error) {console.error('截图失败:', error);ElMessage.error('截图失败,请重试');} };
下载图片辅助函数
typescript
const downloadImage = (dataUrl: string, filename: string) => {try {const link = document.createElement('a');link.download = filename;link.href = dataUrl;link.style.display = 'none';document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(dataUrl);ElMessage.success('截图已保存到本地');} catch (error) {console.error('保存图片失败:', error);ElMessage.error('保存图片到本地失败,请重试');} };
上传功能实现
typescript
const handleConfirmUpload = () => {if (!imgFile.value) {ElMessage.error('截图文件不存在');return;}const formData = new FormData();formData.append('file', imgFile.value);formData.append('dir', '');formData.append('type', '10');// ...省略上传进度UI相关代码...request('/admin/sys-file/upload', {method: 'POST',headers: {Authorization: 'Bearer ' + Session.get('token'),'TENANT-ID': Session.getTenant()},onUploadProgress: (progressEvent: any) => {upLoadProgress.value = Number(progressEvent.progress.toFixed(2)) * 100;// 更新进度条UI},data: formData}).then(() => {ElMessage({ message: '截图成功上传至文件管理', type: 'success', showClose: true });}).catch(() => {ElMessage.error('截图未成功上传,请稍后重试');}); };
5. 协作标注功能
功能说明
通过 WebSocket 实现多用户实时协作标注,支持同步绘图动作、清除操作和状态更新。
实现步骤
WebSocket 事件监听
typescript
watch(() => props.socket,() => {if (props.socket) {props.socket.on('message', (event: any) => {try {const data = JSON.parse(event.data);if (data.incidentType === 'annotation') {handleDrawingData(data);}} catch (e) {console.error('Failed to parse WebSocket message:', e);}});}} );
处理接收到的绘图数据
typescript
const handleDrawingData = (data: any) => {if (data.annotationType === 'clear') {drawingHistory.value = [];mosaicAreas.value = [];measureAreas.value = [];selectedActionIndex.value = -1;if (canvasContext && canvasRef.value) {canvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);}}else if (data.annotationType === 'remove') {// 处理删除操作// ...}else if (data.annotationType === 'draw') {// 处理绘制操作// ...}else if (data.annotationType === 'moving' || data.annotationType === 'move') {// 处理移动操作// ...}// 处理其他类型操作... };
发送绘图动作
typescript
// 创建节流的发送函数(每50ms最多发送一次) const throttledSend = throttle((data: any) => {if (props.socket) {props.socket.sendJson(data);} }, 50);// 发送绘图动作 const sendDrawingAction = (action: DrawingAction) => {if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'draw',data: action,userId: props.userId,creater: props.creater});}// 如果是测量工具,添加到索引列表if (action.tool === 'measure') {measureAreas.value.push(drawingHistory.value.length);} };
完整代码:
<template><div class="annotation"><div class="annotation-container" :class="{ showBorder: drawerShow }"><canvasref="canvasRef"class="whiteboard"id="annotationCanvas"@mousedown="startDrawing"@mousemove="draw"@mouseup="stopDrawing"@mouseleave="stopDrawing"></canvas></div><div class="toolbar" v-if="isInitiator"><!-- 线宽控制项 --><div class="line-width-controls"><XmBtn icon-text="线宽选择"><template #icon><span class="iconfont icon-zhixian"></span></template> </XmBtn><el-sliderv-model="strokeWidthPx":min="1":max="20":step="1":show-input="true"style="width: 140px"tooltip="always"label="线宽"></el-slider></div><!-- 形状工具下拉框 --><el-dropdown @command="selectTool" @visible-change="isDropdownOpen = $event"><span class="el-dropdown-link"><XmBtn:class="{ active: ['pen', 'line', 'arrow', 'rectangle', 'circle'].includes(activeTool) }"icon-text="绘图工具"><template #icon><span class="iconfont icon-juxing"></span></template></XmBtn><span class="bottomMenuChecks-right" style="display: inline-block"><el-icon class="el-icon--right" v-if="isDropdownOpen"><arrow-up /></el-icon><el-icon class="el-icon--right" v-else><arrow-down /></el-icon></span></span><template #dropdown><el-dropdown-menu><el-dropdown-item command="pen"><span class="iconfont icon-shouhuibi"></span> 手绘笔</el-dropdown-item><el-dropdown-item command="line"><span class="iconfont icon-zhixian"></span> 画直线</el-dropdown-item><el-dropdown-item command="arrow"><span class="iconfont icon-jiantou"></span> 画箭头</el-dropdown-item><el-dropdown-item command="rectangle"><span class="iconfont icon-juxing"></span> 画矩形</el-dropdown-item><el-dropdown-item command="circle"><span class="iconfont icon-yuanxing"></span> 画圆形</el-dropdown-item></el-dropdown-menu></template></el-dropdown><XmBtn icon-text="测量工具" @click="selectTool('measure')" :class="{ active: activeTool === 'measure' }"><template #icon><span class="iconfont icon-Ruler"></span></template></XmBtn><!-- 测量工具控制项 --><div v-if="activeTool === 'measure'" class="measure-controls"><el-select v-model="measureUnit" placeholder="选择单位" style="width: 100px"><el-option label="厘米 (cm)" value="cm"></el-option><el-option label="毫米 (mm)" value="mm"></el-option><el-option label="像素 (px)" value="px"></el-option></el-select><el-inputv-model="measureScale"placeholder="缩放比例"type="number":min="0.01":step="0.01"style="width: 120px; margin-left: 10px"></el-input><span style="margin-left: 5px">实际尺寸/像素</span><XmBtnicon-text="清除测量"@click="clearMeasurements"class="ml20"v-if="activeTool === 'measure' || drawingHistory.some((a: DrawingAction) => a.tool === 'measure')":disabled="!hasMeasurements"><template #icon><span class="iconfont icon-Ruler-qc"></span></template></XmBtn></div><XmBtn icon-text="橡皮擦" @click="selectTool('eraser')" :class="{ active: activeTool === 'eraser' }"><template #icon><span class="iconfont icon-eraser"></span></template></XmBtn><!-- 覆盖工具下拉框(蒙版和马赛克) --><el-dropdown @command="selectTool" @visible-change="mskDropdownOpen = $event"><span class="el-dropdown-link"><XmBtn :class="{ active: ['menban', 'mosaic'].includes(activeTool) }" icon-text="隐私遮挡"><template #icon><span class="iconfont icon-MENGBAN"></span></template></XmBtn><span class="bottomMenuChecks-right"><el-icon class="el-icon--right" v-if="mskDropdownOpen"><arrow-up /></el-icon><el-icon class="el-icon--right" v-else><arrow-down /></el-icon></span></span><template #dropdown><el-dropdown-menu><el-dropdown-item command="menban"><span class="iconfont icon-MENGBAN"></span> 蒙版</el-dropdown-item><el-dropdown-item command="mosaic"><span class="iconfont icon-masaike"></span> 马赛克</el-dropdown-item></el-dropdown-menu></template></el-dropdown><!-- 蒙版控制项 --><div v-if="activeTool === 'menban'" class="menban-controls"><el-sliderv-model="menbanSizePercent":min="0.05":max="0.5":step="0.01":show-input="true"input-format="0%"style="width: 140px"tooltip="always"@input="updateMenbanSize"></el-slider><el-sliderv-model="menbanOpacity":min="0.1":max="0.9":step="0.1":show-input="true"style="width: 140px; margin-left: 10px"tooltip="always"@input="updateMenbanOpacity"></el-slider><!-- 清除蒙版按钮 --><!-- Vue 模板中(如果是 TS + Vue 单文件组件) --><XmBtnicon-text="清除蒙版"@click="clearMenban"class="ml20"v-if="activeTool === 'menban' || drawingHistory.some((a: DrawingAction) => a.tool === 'menban')":disabled="!clearMenbanDiabled"><template #icon><span class="iconfont icon-masaike-qc"></span></template></XmBtn></div><!-- 马赛克控制项 --><div v-if="activeTool === 'mosaic'" class="mosaic-controls"><el-sliderv-model="mosaicBlockSize":min="5":max="30":step="1":show-input="true"style="width: 140px"tooltip="always"label="块大小"></el-slider><XmBtnicon-text="清除马赛克"v-if="activeTool === 'mosaic' || drawingHistory.some((a: DrawingAction) => a.tool === 'mosaic')"@click="clearMosaic"class="ml20":disabled="!clearMosaicDisabled"><template #icon><span class="iconfont icon-masaike-qc"></span></template></XmBtn></div><XmBtnicon-text="移动"@click="selectTool('select')":class="{ active: activeTool === 'select' }"v-if="activeTool !== 'measure' && activeTool !== 'menban'"><template #icon><span class="iconfont icon-yidong"></span></template></XmBtn><XmBtn icon-text="截图并保存" v-if="activeTool !== 'menban'" @click="takeScreenshot"><template #icon><span class="iconfont icon-scissors"></span></template></XmBtn><XmBtn icon-text="一键清除" @click="clearCanvas" :disabled="drawingHistory.length === 0"><template #icon><span class="iconfont icon-delete"></span></template></XmBtn><XmBtn icon-text="颜色" type="color" @colorChange="colorChange" class="ml10"><template #icon><span class="iconfont icon-Color-Selected" :style="`color: ${strokeColor};`"></span></template></XmBtn><div class="xiaoanMeeting-bottomMenuBtn ml10"><div class="xiaoanMeeting-bottomMenuBtn-box"><el-input v-model="textContent" placeholder="请输入内容" style="width: 300px"></el-input><el-button type="primary" class="ml10" @click="confirmAddText">添加</el-button></div><div class="xiaoanMeeting-bottomMenuBtn-box-text">添加文字</div></div><XmBtn icon-text="关闭标注" class="ml-auto" style="color: #d23038" @click="close"><template #icon><span class="iconfont icon-logout"></span></template></XmBtn></div><!-- 截图预览弹窗 --><el-dialogv-model="previewDialog.show"title="截图预览"width="60%":close-on-click-modal="false"@close="cleanupCanvas"><div class="preview-container"><img :src="previewDialog.url" class="preview-image" alt="截图预览" /></div><template #footer><div><el-button @click="previewDialog.show = false">取消</el-button><el-button type="primary" @click="handleConfirmUpload">自动上传文件管理</el-button></div></template></el-dialog></div> </template><script lang="ts"> export default {title: '标注',icon: '',description: '' } </script><script setup lang="ts"> import {ElMessage,ElMessageBox,ElSlider,ElDialog,ElNotification,ElSelect,ElOption,ElInput,ElDropdown,ElDropdownMenu,ElDropdownItem,ElIcon } from 'element-plus' import { ArrowUp, ArrowDown } from '@element-plus/icons-vue' import XmBtn from '/@/components/Meet/bottomMenuBtn.vue' import { onMounted, ref, watch, onBeforeUnmount, computed } from 'vue' import request from '/@/utils/request' import { Session } from '/@/utils/storage' import { useUserInfo } from '/@/stores/userInfo' import { storeToRefs } from 'pinia'// 初始化用户信息 const userStore = useUserInfo() const { userInfos } = storeToRefs(userStore)// 定义绘图操作类型 - 包含测量工具 type DrawingTool =| 'pen'| 'rectangle'| 'circle'| 'arrow'| 'eraser'| 'text'| 'line'| 'select'| 'menban'| 'mosaic'| 'measure' // 新增测量工具 type Point = { x: number; y: number } // 存储的是百分比坐标 (0-1范围)// 扩展绘图动作接口,包含测量相关属性 interface DrawingAction {tool: DrawingToolpoints: Point[] // 所有点均为百分比坐标color: stringwidth: number // 线宽,基于参考宽度的百分比text?: string // 文字内容move?: boolean // 标识是否是移动操作length?: number // 直线长度(厘米)lengthUnit?: string // 长度单位,如"cm"sizePercent?: number // 蒙版/马赛克尺寸占画布宽度的百分比opacity?: number // 蒙版透明度mosaicSize?: number // 马赛克块像素大小// 测量相关属性measureType?: 'length' | 'area' // 测量类型:长度/面积measureValue?: number // 测量值measureUnit?: string // 测量单位scale?: number // 缩放比例(实际尺寸/像素尺寸) }const props = defineProps<{drawerShow: booleanisInitiator: boolean // 是否是发起者(拥有工具栏和操作权限)socket: any // WebSocket连接userId: stringhistory: any[]creater: stringreferenceWidth: number // 参考宽度,用于计算相对尺寸referenceHeight: number // 参考高度,用于计算相对尺寸dpi?: number // 可选,屏幕DPI,默认96 }>() const isDropdownOpen = ref(false) const mskDropdownOpen = ref(false) const emit = defineEmits(['close', 'change'])// 常量定义 - 屏幕DPI(每英寸像素数),用于px到cm的转换 const DEFAULT_DPI = 96 // 1英寸 = 2.54厘米 const INCH_TO_CM = 2.54// 计算像素到厘米的转换因子 const getPxToCmFactor = () => {const dpi = props.dpi || DEFAULT_DPIreturn INCH_TO_CM / dpi }const visible = ref(false) const canvasRef = ref<HTMLCanvasElement | null>(null) let canvasContext: CanvasRenderingContext2D | null = null// 绘图状态 const isDrawing = ref(false) const activeTool = ref<DrawingTool>('pen') const strokeColor = ref('red') const strokeWidthPx = ref(3) // 像素线宽,直观的像素值 const strokeWidth = ref(0.003) // 相对线宽(基于参考宽度的百分比) const textContent = ref('') const isAddingText = ref(false) // 是否正在添加文字// 监听像素线宽变化,转换为相对线宽 watch(() => strokeWidthPx.value,newValue => {// 将像素线宽转换为相对参考宽度的百分比if (props.referenceWidth) {strokeWidth.value = newValue / props.referenceWidth}},{ immediate: true } )// 蒙版相关状态 - 使用百分比 const menbanSizePercent = ref(0.1) // 蒙版尺寸占画布宽度的10% const menbanOpacity = ref(0.7)// 马赛克相关状态 const mosaicBlockSize = ref(10) // 马赛克块大小(像素) const mosaicAreas = ref<number[]>([]) // 存储马赛克区域索引// 测量工具相关状态 const measureScale = ref(1) // 缩放比例(默认1像素=1单位) const measureUnit = ref('cm') // 默认单位:厘米 const measureAreas = ref<number[]>([]) // 存储测量工具索引// 检测是否有测量数据 const hasMeasurements = computed(() => {return drawingHistory.value.some(action => action.tool === 'measure') }) // 检测是否有蒙版 const clearMenbanDiabled = computed(() => {return drawingHistory.value.some(action => action.tool === 'menban') }) //检测是否有马赛克 const clearMosaicDisabled = computed(() => {return drawingHistory.value.some(action => action.tool === 'mosaic') }) // 选择移动相关状态 const selectedActionIndex = ref(-1) // 选中的图形索引 const isMoving = ref(false) // 是否正在移动 const offset = ref<Point>({ x: 0, y: 0 }) // 鼠标与图形参考点的偏移量(百分比)// 存储绘图历史 const drawingHistory = ref<DrawingAction[]>([]) watch(() => drawingHistory,() => {emit('change', drawingHistory.value)},{ deep: true } )// 截图相关状态 const tempCanvas = ref<HTMLCanvasElement | null>(null) const previewDialog = ref({show: false,url: '' }) const fileName = ref('') const imgFile = ref<File | null>(null)let currentAction: DrawingAction | null = null let startPoint: Point | null = null// 节流函数 - 控制WebSocket发送频率 const throttle = <T>(func: (this: T, ...args: any[]) => any, limit: number) => {let lastCall = 0// 这里明确 this 类型为 T,与外层泛型对齐return function (this: T, ...args: any[]) {const now = Date.now()if (now - lastCall >= limit) {lastCall = nowreturn func.apply(this, args)}return null} }// 创建节流的发送函数(每50ms最多发送一次) const throttledSend = throttle((data: any) => {if (props.socket) {props.socket.sendJson(data)} }, 50)// 初始化画布 const initCanvas = () => {if (!canvasRef.value) returnconst canvas = canvasRef.value// 设置画布实际尺寸为显示尺寸,避免拉伸canvas.width = canvas.offsetWidthcanvas.height = canvas.offsetHeightcanvasContext = canvas.getContext('2d')if (canvasContext) {canvasContext.lineJoin = 'round'canvasContext.lineCap = 'round'canvasContext.font = '16px Arial'} }// 选择工具 const selectTool = (tool: DrawingTool) => {activeTool.value = toolisAddingText.value = false// 切换工具时取消选择状态selectedActionIndex.value = -1isMoving.value = false }// 确认添加文字 const confirmAddText = () => {if (!textContent.value.trim()) returnactiveTool.value = 'text'isAddingText.value = true }// 获取图形的参考点(用于移动时的定位) const getReferencePoint = (action: DrawingAction): Point => {if (action.points.length === 0) return { x: 0, y: 0 }// 不同图形使用不同的参考点(均为百分比坐标)switch (action.tool) {case 'rectangle':// 矩形使用左上角点return action.points[0]case 'circle':// 圆形使用圆心return {x: (action.points[0].x + action.points[1].x) / 2,y: (action.points[0].y + action.points[1].y) / 2}case 'text':// 文字使用起点return action.points[0]case 'line':case 'arrow':case 'measure': // 测量工具使用起点return action.points[0]case 'pen':case 'mosaic':// 自由画笔和马赛克使用第一个点return action.points[0]case 'menban':// 蒙版使用中心点return action.points[0]default:return action.points[0]} }// 更新蒙版大小(百分比) const updateMenbanSize = () => {if (activeTool.value === 'menban') {const existingMenbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')if (existingMenbanIndex !== -1) {drawingHistory.value[existingMenbanIndex].sizePercent = menbanSizePercent.valueredrawCanvas()// 发送更新if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'update',data: {index: existingMenbanIndex,action: drawingHistory.value[existingMenbanIndex]},userId: props.userId,creater: props.creater})}}} }// 更新蒙版透明度 const updateMenbanOpacity = () => {if (activeTool.value === 'menban') {const existingMenbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')if (existingMenbanIndex !== -1) {drawingHistory.value[existingMenbanIndex].opacity = menbanOpacity.valueredrawCanvas()// 发送更新if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'update',data: {index: existingMenbanIndex,action: drawingHistory.value[existingMenbanIndex]},userId: props.userId,creater: props.creater})}}} }// 清除蒙版功能 const clearMenban = () => {const existingMenbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')if (existingMenbanIndex === -1) {ElMessage.info('没有可清除的蒙版')return}ElMessageBox.confirm('确定要清除蒙版吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'info'}).then(() => {// 从历史记录中移除蒙版drawingHistory.value.splice(existingMenbanIndex, 1)redrawCanvas()// 如果蒙版是选中状态,清除选择if (selectedActionIndex.value === existingMenbanIndex) {selectedActionIndex.value = -1}// 发送清除蒙版指令给所有参与方if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'clearMenban',userId: props.userId,creater: props.creater})}}) }// 清除所有测量工具 const clearMeasurements = () => {if (measureAreas.value.length === 0) {ElMessage.info('没有可清除的测量数据')return}ElMessageBox.confirm('确定要清除所有测量数据吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'info'}).then(() => {// 从后往前删除,避免索引混乱measureAreas.value.sort((a, b) => b - a).forEach(index => {drawingHistory.value.splice(index, 1)})// 重置测量工具索引记录measureAreas.value = []redrawCanvas()// 发送清除测量指令给所有参与方if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'clearMeasure',userId: props.userId,creater: props.creater})}}) }// 计算百分比坐标(核心函数:将像素坐标转为0-1范围的百分比) const getPercentCoordinates = (clientX: number, clientY: number, canvas: HTMLCanvasElement) => {const rect = canvas.getBoundingClientRect()return {x: Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)), // 限制在0-1范围内y: Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)) // 限制在0-1范围内} }/*** 检查点是否在任何马赛克区域内*/ const checkIfPointInMosaicArea = (point: Point): boolean => {if (!canvasRef.value || mosaicAreas.value.length === 0) return falseconst canvasWidth = canvasRef.value.widthconst canvasHeight = canvasRef.value.heightconst pixelPoint = convertPercentToPixel(point, canvasWidth, canvasHeight)// 检查所有马赛克区域for (const index of mosaicAreas.value) {const action = drawingHistory.value[index]if (!action || action.tool !== 'mosaic' || action.points.length < 2) continue// 转换马赛克区域的百分比坐标为像素坐标const mosaicPoints = action.points.map(p => convertPercentToPixel(p, canvasWidth, canvasHeight))// 检查点是否在马赛克路径内if (isPointInPath(pixelPoint, mosaicPoints)) {return true}}return false }/*** 检查点是否在多边形路径内*/ const isPointInPath = (point: Point, polygonPoints: Point[]): boolean => {let inside = falseconst x = point.xconst y = point.y// 使用射线法判断点是否在多边形内for (let i = 0, j = polygonPoints.length - 1; i < polygonPoints.length; j = i++) {const xi = polygonPoints[i].x,yi = polygonPoints[i].yconst xj = polygonPoints[j].x,yj = polygonPoints[j].yconst intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xiif (intersect) inside = !inside}return inside }// 开始绘图 const startDrawing = (e: MouseEvent) => {if (!props.isInitiator || !canvasContext || !canvasRef.value) returnconst canvas = canvasRef.value// 计算百分比坐标 (0-1)const percentPoint = getPercentCoordinates(e.clientX, e.clientY, canvas)// 检查点击位置是否在现有马赛克区域内const isInMosaicArea = checkIfPointInMosaicArea(percentPoint)// 如果在马赛克区域内且不是编辑马赛克本身或使用橡皮擦,则阻止绘制if (isInMosaicArea && activeTool.value !== 'mosaic' && activeTool.value !== 'eraser') {ElMessage.warning('请不要在马赛克区域绘制其他内容')return}// 测量工具处理if (activeTool.value === 'measure') {isDrawing.value = truestartPoint = percentPointcurrentAction = {tool: 'measure',measureType: 'length', // 默认测量长度points: [percentPoint],color: strokeColor.value,width: strokeWidth.value,measureUnit: measureUnit.value,scale: measureScale.value}return}// 选择工具逻辑if (activeTool.value === 'select') {// 查找点击的图形const clickedIndex = findClickedAction(percentPoint.x, percentPoint.y)if (clickedIndex !== -1) {selectedActionIndex.value = clickedIndexisMoving.value = true// 计算偏移量(百分比)const action = drawingHistory.value[clickedIndex]if (action.points.length > 0) {const refPoint = getReferencePoint(action)offset.value = {x: percentPoint.x - refPoint.x,y: percentPoint.y - refPoint.y}}redrawCanvas()} else {// 点击空白处取消选择selectedActionIndex.value = -1redrawCanvas()}return}// 橡皮擦工具if (activeTool.value === 'eraser') {const clickedActionIndex = findClickedAction(percentPoint.x, percentPoint.y)if (clickedActionIndex !== -1) {// 移除被点击的图形const action = drawingHistory.value[clickedActionIndex]drawingHistory.value.splice(clickedActionIndex, 1)// 如果删除的是马赛克,更新马赛克索引列表const mosaicIndex = mosaicAreas.value.indexOf(clickedActionIndex)if (mosaicIndex !== -1) {mosaicAreas.value.splice(mosaicIndex, 1)}// 如果删除的是测量工具,更新测量索引列表if (action.tool === 'measure') {const measureIndex = measureAreas.value.indexOf(clickedActionIndex)if (measureIndex !== -1) {measureAreas.value.splice(measureIndex, 1)}}// 发送删除指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'remove',data: clickedActionIndex,userId: props.userId,creater: props.creater})}redrawCanvas()}return}// 文字工具if (activeTool.value === 'text') {if (isAddingText.value && textContent.value.trim()) {// 添加文字到画布(使用百分比坐标)const textAction: DrawingAction = {tool: 'text',points: [percentPoint],color: strokeColor.value,width: strokeWidth.value,text: textContent.value}drawingHistory.value.push(textAction)sendDrawingAction(textAction)redrawCanvas()// 重置状态textContent.value = ''isAddingText.value = false}return}// 蒙版工具处理(使用百分比)if (activeTool.value === 'menban') {// 创建蒙版动作(尺寸使用百分比)const menbanAction: DrawingAction = {tool: 'menban',points: [percentPoint], // 中心坐标(百分比)color: strokeColor.value,width: strokeWidth.value,sizePercent: menbanSizePercent.value, // 占画布宽度的百分比opacity: menbanOpacity.value}// 如果已有蒙版,替换它(保持只有一个蒙版)const existingMenbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')if (existingMenbanIndex !== -1) {drawingHistory.value.splice(existingMenbanIndex, 1, menbanAction)// 发送更新指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'update',data: {index: existingMenbanIndex,action: menbanAction},userId: props.userId,creater: props.creater})}} else {drawingHistory.value.push(menbanAction)sendDrawingAction(menbanAction)}redrawCanvas()return}// 马赛克工具处理if (activeTool.value === 'mosaic') {isDrawing.value = truestartPoint = percentPointcurrentAction = {tool: 'mosaic',points: [percentPoint],color: strokeColor.value,width: strokeWidth.value,mosaicSize: mosaicBlockSize.value}// 记录马赛克区域索引mosaicAreas.value.push(drawingHistory.value.length)return}// 其他工具正常处理isDrawing.value = truestartPoint = percentPointcurrentAction = {tool: activeTool.value,points: [percentPoint],color: strokeColor.value,width: strokeWidth.value} }// 绘图过程 const draw = (e: MouseEvent) => {if (!canvasRef.value) returnconst canvas = canvasRef.valueconst rect = canvas.getBoundingClientRect()// 计算当前鼠标的百分比坐标const currentPercent = getPercentCoordinates(e.clientX, e.clientY, canvas)// 处理测量工具if (activeTool.value === 'measure' && isDrawing.value && currentAction && startPoint) {// 更新终点坐标currentAction.points = [startPoint, currentPercent]// 计算像素距离const pixelDistance = Math.sqrt(Math.pow((currentPercent.x - startPoint.x) * rect.width, 2) +Math.pow((currentPercent.y - startPoint.y) * rect.height, 2))// 应用缩放比例计算实际距离if (currentAction.scale !== undefined) {currentAction.measureValue = Number((pixelDistance * currentAction.scale).toFixed(2))} else {// scale 未定义时的 fallback 逻辑,比如赋默认值、抛错误提醒currentAction.measureValue = 0console.warn('currentAction.scale is undefined')}redrawCanvas()return}// 处理选择移动if (activeTool.value === 'select' && isMoving.value && selectedActionIndex.value !== -1) {// 检查新位置是否会进入马赛克区域const isInMosaic = checkIfPointInMosaicArea(currentPercent)if (isInMosaic) {ElMessage.warning('不能将图形移动到马赛克区域内')return}// 获取选中的动作const action = drawingHistory.value[selectedActionIndex.value]// 计算新的参考点位置(考虑偏移量)const newRefPointX = currentPercent.x - offset.value.xconst newRefPointY = currentPercent.y - offset.value.y// 获取原始参考点const originalRefPoint = getReferencePoint(action)// 计算位移量(百分比)const dx = newRefPointX - originalRefPoint.xconst dy = newRefPointY - originalRefPoint.y// 更新所有点的位置(百分比坐标)action.points = action.points.map(point => ({x: point.x + dx,y: point.y + dy}))// 如果是直线或测量工具,重新计算长度if ((action.tool === 'line' || action.tool === 'measure') && action.points.length >= 2) {// 转换为实际像素计算长度const startPx = {x: action.points[0].x * rect.width,y: action.points[0].y * rect.height}const endPx = {x: action.points[1].x * rect.width,y: action.points[1].y * rect.height}// 计算像素距离const pixelDistance = Math.sqrt(Math.pow(endPx.x - startPx.x, 2) + Math.pow(endPx.y - startPx.y, 2))// 转换为相应单位if (action.tool === 'line') {action.length = Number((pixelDistance * getPxToCmFactor()).toFixed(2))action.lengthUnit = 'cm'} else if (action.tool === 'measure' && action.scale) {action.measureValue = Number((pixelDistance * action.scale).toFixed(2))}}// 重绘画布redrawCanvas()// 实时发送移动中的位置变化if (props.socket && props.isInitiator) {throttledSend({incidentType: 'annotation',annotationType: 'moving',data: {index: selectedActionIndex.value,action: action},userId: props.userId,creater: props.creater})}return}if (activeTool.value === 'text' || activeTool.value === 'menban') returnif (!isDrawing.value || !props.isInitiator || !canvasContext || !currentAction) return// 马赛克特殊处理if (currentAction.tool === 'mosaic') {currentAction.points.push(currentPercent)redrawCanvas()return}if (currentAction.tool === 'line') {currentAction.points = [currentAction.points[0], currentPercent]// 计算直线长度(厘米)const startPx = {x: currentAction.points[0].x * rect.width,y: currentAction.points[0].y * rect.height}const endPx = {x: currentPercent.x * rect.width,y: currentPercent.y * rect.height}// 计算像素距离const pixelDistance = Math.sqrt(Math.pow(endPx.x - startPx.x, 2) + Math.pow(endPx.y - startPx.y, 2))// 转换为厘米并保留两位小数currentAction.length = Number((pixelDistance * getPxToCmFactor()).toFixed(2))currentAction.lengthUnit = 'cm'} else {currentAction.points.push(currentPercent)}// 实时绘制预览redrawCanvas() }// 停止绘图 const stopDrawing = () => {// 处理测量工具if (activeTool.value === 'measure' && isDrawing.value && currentAction && currentAction.points.length >= 2) {isDrawing.value = falsedrawingHistory.value.push(currentAction)sendDrawingAction(currentAction) // 同步给观看方currentAction = nullstartPoint = nullreturn}// 结束移动if (activeTool.value === 'select' && isMoving.value) {isMoving.value = false// 发送移动后的最终图形数据if (selectedActionIndex.value !== -1 && props.socket && props.isInitiator) {const movedAction = drawingHistory.value[selectedActionIndex.value]props.socket.sendJson({incidentType: 'annotation',annotationType: 'move',data: {index: selectedActionIndex.value,action: movedAction},userId: props.userId,creater: props.creater})}return}if (activeTool.value === 'text' || activeTool.value === 'menban') returnif (!isDrawing.value || !currentAction) returnisDrawing.value = false// 发送完整的绘图动作sendDrawingAction(currentAction)currentAction = nullstartPoint = null }// 转换百分比坐标到目标画布像素坐标(核心函数) const convertPercentToPixel = (percentPoint: { x: number; y: number }, targetWidth: number, targetHeight: number) => {return {x: percentPoint.x * targetWidth,y: percentPoint.y * targetHeight} }// 重绘整个画布 const redrawCanvas = () => {if (!canvasContext || !canvasRef.value) returnconst canvasWidth = canvasRef.value.widthconst canvasHeight = canvasRef.value.height// 清空画布canvasContext.clearRect(0, 0, canvasWidth, canvasHeight)// 重绘历史记录,蒙版应该最后绘制以覆盖其他内容const menbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')const hasMenban = menbanIndex !== -1// 先绘制非蒙版内容drawingHistory.value.forEach((action, index) => {if (action.tool !== 'menban') {drawAction(action, canvasWidth, canvasHeight)// 为选中的图形添加视觉反馈if (index === selectedActionIndex.value) {highlightSelectedAction(action, canvasWidth, canvasHeight)}}})// 最后绘制蒙版if (hasMenban) {drawAction(drawingHistory.value[menbanIndex], canvasWidth, canvasHeight)// 如果蒙版被选中,高亮显示if (menbanIndex === selectedActionIndex.value) {highlightSelectedAction(drawingHistory.value[menbanIndex], canvasWidth, canvasHeight)}}// 绘制当前正在进行的动作if (currentAction && currentAction.tool !== 'menban') {drawAction(currentAction, canvasWidth, canvasHeight)} }// 高亮显示选中的图形 const highlightSelectedAction = (action: DrawingAction, canvasWidth: number, canvasHeight: number) => {if (!canvasContext) returnconst { tool, points, text } = action// 转换百分比坐标为实际像素const actualPoints = points.map(p => convertPercentToPixel(p, canvasWidth, canvasHeight))// 保存当前上下文状态canvasContext.save()// 使用虚线和不同颜色绘制边框canvasContext.strokeStyle = 'blue'canvasContext.setLineDash([5, 5])canvasContext.lineWidth = 2switch (tool) {case 'pen':case 'line':case 'arrow':case 'mosaic':case 'measure': // 测量工具高亮drawFreehand(actualPoints)breakcase 'rectangle':drawRectangle(actualPoints)breakcase 'circle':drawCircle(actualPoints)breakcase 'text':if (actualPoints.length > 0 && text) {// 为文字绘制一个包围框const textWidth = canvasContext.measureText(text).widthconst textHeight = 20 // 估算文字高度canvasContext.strokeRect(actualPoints[0].x - 5,actualPoints[0].y - textHeight + 5,textWidth + 10,textHeight)}breakcase 'menban':if (actualPoints.length > 0 && action.sizePercent) {// 为蒙版绘制一个虚线圆圈const actualSize = action.sizePercent * canvasWidthcanvasContext.beginPath()canvasContext.arc(actualPoints[0].x, actualPoints[0].y, actualSize / 2, 0, Math.PI * 2)canvasContext.stroke()}break}// 恢复上下文状态canvasContext.restore() }// 绘制单个动作 const drawAction = (action: DrawingAction, canvasWidth: number, canvasHeight: number) => {if (!canvasContext) returnconst { tool, points, color, width, text } = actioncanvasContext.strokeStyle = colorcanvasContext.fillStyle = color// 线宽基于画布宽度的百分比计算canvasContext.lineWidth = width * canvasWidth// 转换百分比坐标为实际像素const actualPoints = points.map(p => convertPercentToPixel(p, canvasWidth, canvasHeight))switch (tool) {case 'pen':drawFreehand(actualPoints)breakcase 'line':drawLine(actualPoints, action, canvasWidth)breakcase 'rectangle':drawRectangle(actualPoints)breakcase 'circle':drawCircle(actualPoints)breakcase 'arrow':drawArrow(actualPoints)breakcase 'text':if (text && actualPoints.length > 0) {drawText(actualPoints[0], text, color, width, canvasWidth)}breakcase 'menban':if (actualPoints.length > 0 && action.sizePercent) {// 蒙版尺寸基于画布宽度的百分比计算const actualSize = action.sizePercent * canvasWidthdrawMenban(actualPoints, actualSize, action.opacity || 0.7)}breakcase 'mosaic':if (actualPoints.length > 1) {drawMosaic(actualPoints, action, canvasWidth, canvasHeight)}breakcase 'measure': // 绘制测量工具if (actualPoints.length >= 2) {drawMeasure(actualPoints, action, canvasWidth)}break} }// 绘制测量结果 const drawMeasure = (points: Point[], action: DrawingAction, canvasWidth: number) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[1]// 绘制测量线(虚线)canvasContext.strokeStyle = action.colorcanvasContext.lineWidth = action.width * canvasWidthcanvasContext.setLineDash([5, 5]) // 虚线样式canvasContext.beginPath()canvasContext.moveTo(start.x, start.y)canvasContext.lineTo(end.x, end.y)canvasContext.stroke()canvasContext.setLineDash([]) // 重置为实线// 绘制测量值标签if (action.measureValue !== undefined && action.measureUnit) {const midPoint = {x: (start.x + end.x) / 2 + 10,y: (start.y + end.y) / 2 - 10}canvasContext.fillStyle = action.colorcanvasContext.font = `${0.015 * canvasWidth}px Arial`canvasContext.fillText(`${action.measureValue}${action.measureUnit}`, midPoint.x, midPoint.y)}// 绘制端点canvasContext.beginPath()canvasContext.arc(start.x, start.y, 5, 0, Math.PI * 2)canvasContext.arc(end.x, end.y, 5, 0, Math.PI * 2)canvasContext.fill() }// 绘制标准马赛克 const drawMosaic = (points: Point[], action: DrawingAction, canvasWidth: number, canvasHeight: number) => {if (!canvasContext || points.length < 2) return// 保存当前上下文状态canvasContext.save()// 创建马赛克区域路径const path = new Path2D()path.moveTo(points[0].x, points[0].y)for (let i = 1; i < points.length; i++) {path.lineTo(points[i].x, points[i].y)}path.closePath()canvasContext.clip(path)// 获取路径边界const { minX, minY, maxX, maxY } = getPathBounds(points)const blockSize = action.mosaicSize || 10// 绘制标准马赛克(像素块效果)for (let y = minY; y < maxY; y += blockSize) {for (let x = minX; x < maxX; x += blockSize) {// 随机取块内一点的颜色作为整个块的颜色// const pixelX = Math.min(x + Math.random() * blockSize, maxX)// const pixelY = Math.min(y + Math.random() * blockSize, maxY)// 获取该点的像素颜色// const imageData = canvasContext.getImageData(pixelX, pixelY, 1, 1).data/* 深灰色 + 中等透明度,清晰遮挡同时保留一点底层轮廓 */// const color = `rgba(120, 120, 120, 0.7)`// 绘制马赛克块canvasContext.fillStyle = `rgba(120, 120, 120, 0.7)`canvasContext.fillRect(x, y, blockSize, blockSize)}}// 恢复上下文状态canvasContext.restore() }// 辅助函数:获取路径的边界框 const getPathBounds = (points: Point[]) => {if (points.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0 }return points.reduce((bounds, point) => {return {minX: Math.min(bounds.minX, point.x),minY: Math.min(bounds.minY, point.y),maxX: Math.max(bounds.maxX, point.x),maxY: Math.max(bounds.maxY, point.y)}},{minX: points[0].x,minY: points[0].y,maxX: points[0].x,maxY: points[0].y}) }// 辅助函数:将十六进制颜色转换为RGBA // const hexToRgba = (hex: string, alpha: number) => { // hex = hex.replace('#', '') // const r = parseInt(hex.substring(0, 2), 16) // const g = parseInt(hex.substring(2, 4), 16) // const b = parseInt(hex.substring(4, 6), 16)// return `rgba(${r}, ${g}, ${b}, ${alpha})` // }// 绘制蒙版 const drawMenban = (points: Point[], size: number, opacity: number) => {if (!canvasContext || points.length < 1) returnconst center = points[0]// 保存当前上下文状态canvasContext.save()// 设置蒙版样式canvasContext.fillStyle = `rgba(0, 0, 0, ${opacity})`// 绘制全屏矩形(作为背景)canvasContext.fillRect(0, 0, canvasRef.value!.width, canvasRef.value!.height)// 清除中间区域,形成"蒙版窗口"效果canvasContext.globalCompositeOperation = 'destination-out'// 绘制圆形窗口canvasContext.beginPath()canvasContext.arc(center.x,center.y,size / 2, // 半径为尺寸的一半0,Math.PI * 2)canvasContext.fill()// 恢复上下文状态canvasContext.restore() }// 绘制自由线条 const drawFreehand = (points: Point[]) => {if (!canvasContext || points.length < 2) returncanvasContext.beginPath()canvasContext.moveTo(points[0].x, points[0].y)for (let i = 1; i < points.length; i++) {canvasContext.lineTo(points[i].x, points[i].y)}canvasContext.stroke() }// 绘制直线(带长度显示) const drawLine = (points: Point[], action: DrawingAction, canvasWidth: number) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制直线canvasContext.beginPath()canvasContext.moveTo(start.x, start.y)canvasContext.lineTo(end.x, end.y)canvasContext.stroke()// 绘制长度文本(如果有长度信息)if (action.length !== undefined && action.lengthUnit) {// 计算直线中点(文本显示位置)const midPoint = {x: (start.x + end.x) / 2 + 10, // 偏移10px避免重叠y: (start.y + end.y) / 2 - 10}// 直接绘制长度文本canvasContext.fillStyle = action.colorcanvasContext.font = `${0.015 * canvasWidth}px Arial` // 文字大小基于画布宽度canvasContext.fillText(`${action.length}${action.lengthUnit}`, midPoint.x - 35, midPoint.y + 5)} }// 绘制矩形 const drawRectangle = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]const width = end.x - start.xconst height = end.y - start.ycanvasContext.beginPath()canvasContext.rect(start.x, start.y, width, height)canvasContext.stroke() }//绘制圆形 - 从起点开始画圆 const drawCircle = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 计算矩形的中心点作为圆心const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2// 计算半径为矩形对角线的一半const radius = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)) / 2canvasContext.beginPath()canvasContext.arc(centerX, centerY, radius, 0, Math.PI * 2)canvasContext.stroke() }// 绘制箭头 const drawArrow = (points: Point[]) => {if (!canvasContext || points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制线条canvasContext.beginPath()canvasContext.moveTo(start.x, start.y)canvasContext.lineTo(end.x, end.y)canvasContext.stroke()// 绘制箭头const headLength = 15const angle = Math.atan2(end.y - start.y, end.x - start.x)canvasContext.beginPath()canvasContext.moveTo(end.x, end.y)canvasContext.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6),end.y - headLength * Math.sin(angle - Math.PI / 6))canvasContext.moveTo(end.x, end.y)canvasContext.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6),end.y - headLength * Math.sin(angle + Math.PI / 6))canvasContext.stroke() }// 绘制文字 const drawText = (point: Point, text: string, color: string, width: number, canvasWidth: number) => {if (!canvasContext) return// 文字大小基于画布宽度百分比const fontSize = width * 5 * canvasWidthcanvasContext.fillStyle = colorcanvasContext.font = `${fontSize}px Arial`canvasContext.fillText(text, point.x, point.y) }// 查找点击的图形 const findClickedAction = (xPercent: number, yPercent: number): number => {if (!canvasRef.value) return -1const canvasWidth = canvasRef.value.widthconst canvasHeight = canvasRef.value.height// 转换为实际坐标用于检测const x = xPercent * canvasWidthconst y = yPercent * canvasHeight// 从后往前检查,优先选中最上层的图形for (let i = drawingHistory.value.length - 1; i >= 0; i--) {const action = drawingHistory.value[i]// 排除测量工具,使其不能被选中和移动if (action.tool === 'measure') {continue}const points = action.points.map(p => convertPercentToPixel(p, canvasWidth, canvasHeight))if (isPointInAction({ x, y }, action.tool, points, action, canvasWidth)) {return i}}return -1 }// 检测点是否在图形内 const isPointInAction = (point: Point,tool: DrawingTool,actionPoints: Point[],action: DrawingAction,canvasWidth: number ): boolean => {if (actionPoints.length === 0) return falseswitch (tool) {case 'pen':case 'line':case 'arrow':case 'mosaic':case 'measure': // 测量工具的点检测return isPointNearLine(point, actionPoints)case 'rectangle':return isPointInRectangle(point, actionPoints)case 'circle':return isPointInCircle(point, actionPoints)case 'text':// 简单判断点击点是否在文字起点附近return Math.abs(point.x - actionPoints[0].x) < 20 && Math.abs(point.y - actionPoints[0].y) < 20case 'menban':// 检测点是否在蒙版中心附近if (actionPoints.length > 0 && action.sizePercent) {const center = actionPoints[0]const actualSize = action.sizePercent * canvasWidthconst distance = Math.sqrt(Math.pow(point.x - center.x, 2) + Math.pow(point.y - center.y, 2))// 检测范围比蒙版大一些,方便选择return distance < actualSize / 2 + 20}return falsedefault:return false} }// 判断点是否在线段附近 const isPointNearLine = (point: Point, linePoints: Point[]) => {if (linePoints.length < 2) return falsefor (let i = 0; i < linePoints.length - 1; i++) {const start = linePoints[i]const end = linePoints[i + 1]const distance = distanceToLine(point, start, end)if (distance < 10) {// 10像素内的容差return true}}return false }// 矩形检测 const isPointInRectangle = (point: Point, rectPoints: Point[]) => {if (rectPoints.length < 2) return falseconst start = rectPoints[0]const end = rectPoints[rectPoints.length - 1]const left = Math.min(start.x, end.x)const right = Math.max(start.x, end.x)const top = Math.min(start.y, end.y)const bottom = Math.max(start.y, end.y)return point.x >= left - 5 && point.x <= right + 5 && point.y >= top - 5 && point.y <= bottom + 5 }// 圆形检测 const isPointInCircle = (point: Point, circlePoints: Point[]) => {if (circlePoints.length < 2) return falseconst start = circlePoints[0]const end = circlePoints[circlePoints.length - 1]const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2const radius = Math.sqrt(Math.pow(end.x - centerX, 2) + Math.pow(end.y - centerY, 2))const distance = Math.sqrt(Math.pow(point.x - centerX, 2) + Math.pow(point.y - centerY, 2))return distance <= radius + 5 }// 计算点到线段的距离 const distanceToLine = (point: Point, lineStart: Point, lineEnd: Point) => {const A = point.x - lineStart.xconst B = point.y - lineStart.yconst C = lineEnd.x - lineStart.xconst D = lineEnd.y - lineStart.yconst dot = A * C + B * Dconst len_sq = C * C + D * Dlet param = -1if (len_sq !== 0) param = dot / len_sqlet xx: any, yy: anyif (param < 0) {xx = lineStart.xyy = lineStart.y} else if (param > 1) {xx = lineEnd.xyy = lineEnd.y} else {xx = lineStart.x + param * Cyy = lineStart.y + param * D}const dx = point.x - xxconst dy = point.y - yyreturn Math.sqrt(dx * dx + dy * dy) }// 清除画布 const clearCanvas = () => {ElMessageBox.confirm('确定要清除所有标注吗?', '警告', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning'}).then(() => {if (!canvasContext || !canvasRef.value) returncanvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)drawingHistory.value = []mosaicAreas.value = [] // 同时清除马赛克索引measureAreas.value = [] // 同时清除测量工具索引selectedActionIndex.value = -1 // 清除选择状态// 发送清除指令if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'clear',creater: props.creater})}}) }// 清除马赛克 const clearMosaic = () => {if (mosaicAreas.value.length === 0) {ElMessage.info('没有可清除的马赛克区域')return}ElMessageBox.confirm('确定要清除所有马赛克吗?', '提示', {confirmButtonText: '确定',cancelButtonText: '取消',type: 'info'}).then(() => {// 过滤掉所有马赛克动作(从后往前删除,避免索引混乱)mosaicAreas.value.sort((a, b) => b - a).forEach(index => {drawingHistory.value.splice(index, 1)})// 重置马赛克区域记录mosaicAreas.value = []redrawCanvas()// 发送清除马赛克指令给观看方if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'clearMosaic',userId: props.userId,creater: props.creater})}}) }// 发送绘图动作 const sendDrawingAction = (action: DrawingAction) => {if (props.socket && props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'draw',data: action,userId: props.userId,creater: props.creater})}// 如果是测量工具,添加到索引列表if (action.tool === 'measure') {measureAreas.value.push(drawingHistory.value.length)} }// 处理接收到的绘图数据 const handleDrawingData = (data: any) => {if (data.annotationType === 'clear') {drawingHistory.value = []mosaicAreas.value = [] // 同时清除马赛克索引measureAreas.value = [] // 同时清除测量工具索引selectedActionIndex.value = -1 // 清除选择状态if (canvasContext && canvasRef.value) {canvasContext.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)}}// 处理清除测量指令else if (data.annotationType === 'clearMeasure') {// 过滤掉所有测量工具动作drawingHistory.value = drawingHistory.value.filter(action => action.tool !== 'measure')measureAreas.value = []redrawCanvas()} else if (data.annotationType === 'remove') {if (data.userId !== props.userId) {const action = drawingHistory.value[data.data]// 检查删除的是否是马赛克const isMosaic = action?.tool === 'mosaic'// 检查删除的是否是测量工具const isMeasure = action?.tool === 'measure'drawingHistory.value.splice(data.data, 1)// 如果删除的是马赛克,更新马赛克索引if (isMosaic) {mosaicAreas.value = mosaicAreas.value.filter(index => index !== data.data)}// 如果删除的是测量工具,更新测量索引if (isMeasure) {measureAreas.value = measureAreas.value.filter(index => index !== data.data)}// 如果删除的是选中的图形,清除选择状态if (selectedActionIndex.value === data.data) {selectedActionIndex.value = -1} else if (selectedActionIndex.value > data.data) {// 调整索引selectedActionIndex.value--}}redrawCanvas()} else if (data.annotationType === 'draw') {if (data.data.move) {// 如果是移动操作,找到对应的图形并更新const index = drawingHistory.value.findIndex((_, i) => i === selectedActionIndex.value)if (index !== -1) {drawingHistory.value[index] = data.data}} else {drawingHistory.value.push(data.data)// 如果是马赛克,记录索引if (data.data.tool === 'mosaic') {mosaicAreas.value.push(drawingHistory.value.length - 1)}// 如果是测量工具,记录索引if (data.data.tool === 'measure') {measureAreas.value.push(drawingHistory.value.length - 1)}}redrawCanvas()} else if (data.annotationType === 'moving' || data.annotationType === 'move') {// 处理移动中或移动完成的图形const { index, action } = data.dataif (index >= 0 && index < drawingHistory.value.length) {drawingHistory.value[index] = actionredrawCanvas()}} else if (data.annotationType === 'update') {// 处理更新操作(如蒙版大小和透明度变化)const { index, action } = data.dataif (index >= 0 && index < drawingHistory.value.length) {drawingHistory.value[index] = action// 如果是蒙版,更新本地状态if (action.tool === 'menban') {menbanSizePercent.value = action.sizePercent || 0.1menbanOpacity.value = action.opacity || 0.7}// 如果是马赛克,更新本地状态if (action.tool === 'mosaic') {mosaicBlockSize.value = action.mosaicSize || 10}// 如果是测量工具,更新本地状态if (action.tool === 'measure') {measureUnit.value = action.measureUnit || 'cm'measureScale.value = action.scale || 1}redrawCanvas()}} else if (data.annotationType === 'clearMosaic') {// 处理清除马赛克指令drawingHistory.value = drawingHistory.value.filter(action => action.tool !== 'mosaic')mosaicAreas.value = []redrawCanvas()redrawCanvas()} else if (data.annotationType === 'clearMenban') {// 处理清除蒙版指令const menbanIndex = drawingHistory.value.findIndex(a => a.tool === 'menban')if (menbanIndex !== -1) {drawingHistory.value.splice(menbanIndex, 1)// 如果清除的是选中的蒙版,更新选择状态if (selectedActionIndex.value === menbanIndex) {selectedActionIndex.value = -1} else if (selectedActionIndex.value > menbanIndex) {selectedActionIndex.value--}redrawCanvas()}} }// 打开弹窗 const open = () => {visible.value = truesetTimeout(() => {if (props.history.length) {drawingHistory.value = props.history// 检查是否有蒙版并更新状态const existingMenban = drawingHistory.value.find(a => a.tool === 'menban')if (existingMenban) {menbanSizePercent.value = existingMenban.sizePercent || 0.1menbanOpacity.value = existingMenban.opacity || 0.7}// 检查是否有马赛克并更新状态和索引mosaicAreas.value = []measureAreas.value = []drawingHistory.value.forEach((action, index) => {if (action.tool === 'mosaic') {mosaicAreas.value.push(index)mosaicBlockSize.value = action.mosaicSize || 10}// 检查是否有测量工具并更新状态和索引if (action.tool === 'measure') {measureAreas.value.push(index)measureUnit.value = action.measureUnit || 'cm'measureScale.value = action.scale || 1}})redrawCanvas()}}, 500) }const close = () => {ElMessageBox.confirm('确定要关闭标注吗?', '温馨提示', {type: 'warning'}).then(() => {emit('close')if (props.isInitiator) {props.socket.sendJson({incidentType: 'annotation',annotationType: 'close',userId: props.userId,creater: props.creater})}}) }watch(() => props.socket,() => {if (props.socket) {props.socket.on('message', (event: any) => {try {const data = JSON.parse(event.data)if (data.incidentType === 'annotation') {handleDrawingData(data)}} catch (e) {console.error('Failed to parse WebSocket message:', e)}})}} )const colorPickerClicked = ref(false) const lastColorClickTime = ref(0)// 颜色变化处理 function colorChange(color: any) {// 检查是否是双击(两次点击时间间隔小于300ms)const currentTime = new Date().getTime()const isDoubleClick = currentTime - lastColorClickTime.value < 300if (isDoubleClick) {// 传统双击逻辑strokeColor.value = color} else {// 新增单击逻辑// 记录首次点击lastColorClickTime.value = currentTimecolorPickerClicked.value = true// 设置定时器,若300ms内没有第二次点击,则执行单击逻辑setTimeout(() => {if (colorPickerClicked.value) {strokeColor.value = colorcolorPickerClicked.value = false}}, 300)} }// 当颜色选择器失去焦点时,执行单击逻辑 const handleColorPickerBlur = () => {if (colorPickerClicked.value) {colorPickerClicked.value = false} }// 截图并保存功能 - 隐藏蒙版 const takeScreenshot = async () => {if (!canvasRef.value) returntry {// 获取原始画布及其尺寸const originalCanvas = canvasRef.valueconst originalWidth = originalCanvas.widthconst originalHeight = originalCanvas.height// const aspectRatio = originalWidth / originalHeight// 关键修复:临时画布使用原始画布尺寸,保持宽高比tempCanvas.value = document.createElement('canvas')tempCanvas.value.width = originalWidthtempCanvas.value.height = originalHeightconst ctx = tempCanvas.value.getContext('2d')if (!ctx) return// 1. 绘制底层内容(从容器的父元素中捕获)const canvasContainer = originalCanvas.parentElementif (canvasContainer) {const containerRect = canvasContainer.getBoundingClientRect()const parentElement = canvasContainer.parentElement// 创建临时画布绘制DOM内容const domCanvas = document.createElement('canvas')domCanvas.width = originalWidthdomCanvas.height = originalHeightconst domCtx = domCanvas.getContext('2d')if (domCtx && parentElement) {// 绘制背景domCtx.fillStyle = getComputedStyle(parentElement).backgroundColor || '#ffffff'domCtx.fillRect(0, 0, domCanvas.width, domCanvas.height)// 绘制视频内容 - 按比例计算位置和大小const videoElements = Array.from(document.querySelectorAll('video')).filter(v => v.readyState >= 2 && v.videoWidth > 0 && v.videoHeight > 0)videoElements.forEach(video => {const rect = video.getBoundingClientRect()// 计算视频在容器中的百分比位置const xPercent = (rect.left - containerRect.left) / containerRect.widthconst yPercent = (rect.top - containerRect.top) / containerRect.heightconst widthPercent = rect.width / containerRect.widthconst heightPercent = rect.height / containerRect.height// 转换为原始画布尺寸的像素位置const x = xPercent * originalWidthconst y = yPercent * originalHeightconst width = widthPercent * originalWidthconst height = heightPercent * originalHeightdomCtx.drawImage(video, x, y, width, height)})// 将DOM内容绘制到临时画布ctx.drawImage(domCanvas, 0, 0)}}// 2. 绘制非蒙版标注内容(跳过蒙版)drawingHistory.value.forEach(action => {// 只绘制非蒙版工具的内容if (action.tool !== 'menban') {drawActionToCanvas(action, ctx, tempCanvas.value!)}})// 3. 绘制当前正在进行的动作(非蒙版)if (currentAction && currentAction.tool !== 'menban') {drawActionToCanvas(currentAction, ctx, tempCanvas.value!)}// 4. 生成截图数据const dataUrl = tempCanvas.value.toDataURL('image/png')previewDialog.value.url = dataUrlpreviewDialog.value.show = true// 生成文件名:用户名-时间戳.pngconst username = userInfos.value?.user?.username || 'unknown' // 兼容用户信息未加载的情况const timestamp = new Date().getTime()fileName.value = `${username}-${timestamp}.png`// 自动保存到本地downloadImage(dataUrl, fileName.value)// 准备文件数据const blob = await fetch(dataUrl).then(res => res.blob())imgFile.value = new File([blob], fileName.value, { type: 'image/png' })} catch (error) {console.error('截图失败:', error)ElMessage.error('截图失败,请重试')} }// 下载图片辅助函数 const downloadImage = (dataUrl: string, filename: string) => {try {const link = document.createElement('a')link.download = filenamelink.href = dataUrllink.style.display = 'none'document.body.appendChild(link)link.click()document.body.removeChild(link)URL.revokeObjectURL(dataUrl)ElMessage.success('截图已保存到本地')} catch (error) {console.error('保存图片失败:', error)ElMessage.error('保存图片到本地失败,请重试')} }// 辅助函数:将绘图动作绘制到目标画布(跳过蒙版) const drawActionToCanvas = (action: DrawingAction, ctx: CanvasRenderingContext2D, targetCanvas: HTMLCanvasElement) => {// 如果是蒙版工具,直接返回不绘制if (action.tool === 'menban') {return}const { tool, points, color, width, text } = actionconst canvasWidth = targetCanvas.widthconst canvasHeight = targetCanvas.heightconst originalCanvas = canvasRef.value!const originalWidth = originalCanvas.widthctx.strokeStyle = colorctx.fillStyle = colorctx.fillStyle = colorctx.lineWidth = width * canvasWidth // 线宽基于画布宽度百分比ctx.lineJoin = 'round'ctx.lineCap = 'round'// 转换百分比坐标为实际像素坐标const actualPoints = points.map(p => convertPercentToPixel(p, canvasWidth, canvasHeight))// 根据工具类型绘制不同内容switch (tool) {case 'pen':drawFreehandToCanvas(actualPoints, ctx)breakcase 'line':drawLineToCanvas(actualPoints, action, ctx, canvasWidth)breakcase 'rectangle':drawRectangleToCanvas(actualPoints, ctx)breakcase 'circle':drawCircleToCanvas(actualPoints, ctx, targetCanvas)breakcase 'arrow':drawArrowToCanvas(actualPoints, ctx)breakcase 'text':if (text && actualPoints.length > 0) {drawTextToCanvas(actualPoints[0], text, color, width, ctx, canvasWidth)}breakcase 'mosaic':if (actualPoints.length > 1) {// 马赛克块大小基于原始画布比例const blockSize = ((action.mosaicSize || 10) / originalWidth) * canvasWidth// 1. 保存当前上下文状态ctx.save()// 2. 创建马赛克区域路径const mosaicPath = new Path2D()mosaicPath.moveTo(actualPoints[0].x, actualPoints[0].y)for (let i = 1; i < actualPoints.length; i++) {mosaicPath.lineTo(actualPoints[i].x, actualPoints[i].y)}mosaicPath.closePath()ctx.clip(mosaicPath)// 3. 绘制标准马赛克const { minX, minY, maxX, maxY } = getPathBounds(actualPoints)for (let y = minY; y < maxY; y += blockSize) {for (let x = minX; x < maxX; x += blockSize) {// 从底层画面取样const pixelX = Math.min(x + Math.random() * blockSize, maxX)const pixelY = Math.min(y + Math.random() * blockSize, maxY)// 绘制马赛克块const imageData = ctx.getImageData(pixelX, pixelY, 1, 1).datactx.fillStyle = `rgb(${imageData[0]}, ${imageData[1]}, ${imageData[2]})`ctx.fillRect(x, y, blockSize, blockSize)}}// 4. 恢复上下文ctx.restore()}breakcase 'measure': // 绘制测量工具到截图if (actualPoints.length >= 2) {drawMeasureToCanvas(actualPoints, action, ctx, canvasWidth)}break} }// 绘制测量工具到截图 const drawMeasureToCanvas = (points: Point[],action: DrawingAction,ctx: CanvasRenderingContext2D,canvasWidth: number ) => {if (points.length < 2) returnconst start = points[0]const end = points[1]// 绘制测量线(虚线)ctx.strokeStyle = action.colorctx.lineWidth = action.width * canvasWidthctx.setLineDash([5, 5]) // 虚线样式ctx.beginPath()ctx.moveTo(start.x, start.y)ctx.lineTo(end.x, end.y)ctx.stroke()ctx.setLineDash([]) // 重置为实线// 绘制测量值标签if (action.measureValue !== undefined && action.measureUnit) {const midPoint = {x: (start.x + end.x) / 2 + 10,y: (start.y + end.y) / 2 - 10}ctx.fillStyle = action.colorctx.font = `${0.015 * canvasWidth}px Arial`ctx.fillText(`${action.measureValue}${action.measureUnit}`, midPoint.x, midPoint.y)}// 绘制端点ctx.beginPath()ctx.arc(start.x, start.y, 5, 0, Math.PI * 2)ctx.arc(end.x, end.y, 5, 0, Math.PI * 2)ctx.fill() }// 绘制直线到目标画布(带长度) const drawLineToCanvas = (points: Point[],action: DrawingAction,ctx: CanvasRenderingContext2D,canvasWidth: number ) => {if (points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制直线ctx.beginPath()ctx.moveTo(start.x, start.y)ctx.lineTo(end.x, end.y)ctx.stroke()// 绘制长度文本if (action.length !== undefined && action.lengthUnit) {const midPoint = {x: (start.x + end.x) / 2 + 10,y: (start.y + end.y) / 2 - 10}// 直接绘制文本ctx.fillStyle = action.colorctx.font = `${0.015 * canvasWidth}px Arial` // 文字大小基于画布宽度ctx.fillText(`${action.length}${action.lengthUnit}`, midPoint.x - 35, midPoint.y + 5)} }// 以下是各个绘图工具对应的画布绘制函数 const drawFreehandToCanvas = (points: Point[], ctx: CanvasRenderingContext2D) => {if (points.length < 2) returnctx.beginPath()ctx.moveTo(points[0].x, points[0].y)for (let i = 1; i < points.length; i++) {ctx.lineTo(points[i].x, points[i].y)}ctx.stroke() }const drawRectangleToCanvas = (points: Point[], ctx: CanvasRenderingContext2D) => {if (points.length < 2) returnconst start = points[0]const end = points[points.length - 1]const w = end.x - start.xconst h = end.y - start.yctx.beginPath()ctx.rect(start.x, start.y, w, h)ctx.stroke() }const drawCircleToCanvas = (points: Point[], ctx: CanvasRenderingContext2D, targetCanvas: HTMLCanvasElement) => {if (points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 计算圆心(基于原始百分比坐标转换后的临时画布坐标)const centerX = start.x + (end.x - start.x) / 2const centerY = start.y + (end.y - start.y) / 2// 计算半径(直接使用临时画布上的坐标差,无需额外缩放)const radius = Math.sqrt(Math.pow(end.x - centerX, 2) + Math.pow(end.y - centerY, 2))ctx.beginPath()ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)ctx.stroke() }const drawArrowToCanvas = (points: Point[], ctx: CanvasRenderingContext2D) => {if (points.length < 2) returnconst start = points[0]const end = points[points.length - 1]// 绘制线条ctx.beginPath()ctx.moveTo(start.x, start.y)ctx.lineTo(end.x, end.y)ctx.stroke()// 绘制箭头const headLength = 15const angle = Math.atan2(end.y - start.y, end.x - start.x)ctx.beginPath()ctx.moveTo(end.x, end.y)ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6))ctx.moveTo(end.x, end.y)ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6))ctx.stroke() }const drawTextToCanvas = (point: Point,text: string,color: string,width: number,ctx: CanvasRenderingContext2D,canvasWidth: number ) => {const fontSize = width * 5 * canvasWidthctx.fillStyle = colorctx.font = `${fontSize}px Arial`ctx.fillText(text, point.x, point.y) }// 清理临时画布 const cleanupCanvas = () => {if (tempCanvas.value) {tempCanvas.value.remove()tempCanvas.value = null}redrawCanvas() } const upLoadProgress = ref(0)// 处理上传 const handleConfirmUpload = () => {if (!imgFile.value) {ElMessage.error('截图文件不存在')return}const formData = new FormData()formData.append('file', imgFile.value)formData.append('dir', '')formData.append('type', '10')const notification = document.createElement('div')notification.className = 'upload-notification'const form = document.createElement('div')form.className = 'upload-form'const filenameItem = document.createElement('div')filenameItem.className = 'upload-form-item'filenameItem.innerHTML = `<label class="upload-form-label">文件名:</label><span class="upload-form-content">${fileName.value}</span>`const progressItem = document.createElement('div')progressItem.className = 'upload-form-item'progressItem.innerHTML = `<label class="upload-form-label">上传进度:</label><div class="upload-progress-container"><div id="meetingRoomUploadProgress" class="upload-progress-bar"><div class="upload-progress-inner" style="width: 0"></div><span class="upload-progress-text">0%</span></span></div></div>`form.appendChild(filenameItem)form.appendChild(progressItem)notification.appendChild(form)ElNotification({type: 'info',title: '一个截图文件正在上传',message: notification as any,showClose: true,duration: 0,className: 'custom-upload-notification'} as any)const progressBar = document.querySelector('#meetingRoomUploadProgress .upload-progress-inner') as HTMLElementconst progressText = document.querySelector('#meetingRoomUploadProgress .upload-progress-text') as HTMLElementrequest('/admin/sys-file/upload', {method: 'POST',headers: {Authorization: 'Bearer ' + Session.get('token'),'TENANT-ID': Session.getTenant()},onUploadProgress: (progressEvent: any) => {upLoadProgress.value = Number(progressEvent.progress.toFixed(2)) * 100if (progressBar && progressText) {progressBar.style.width = upLoadProgress.value + '%'progressText.textContent = upLoadProgress.value + '%'}if (upLoadProgress.value === 100) {setTimeout(() => {ElNotification.closeAll()}, 1000)}},data: formData}).then(() => {ElMessage({ message: '截图成功上传至文件管理', type: 'success', showClose: true })}).catch(() => {ElMessage.error('截图未成功上传,您可稍后通过“文件管理”模块手动添加文件')})previewDialog.value.show = falsecleanupCanvas()redrawCanvas() }// 组件挂载时初始化 onMounted(() => {const colorPicker = document.querySelector('.icon-Color-Selected')if (colorPicker) {colorPicker.addEventListener('blur', handleColorPickerBlur)}initCanvas()window.addEventListener('resize', initCanvas)setTimeout(() => {if (props.history.length) {drawingHistory.value = props.history// 检查是否有蒙版并更新状态const existingMenban = drawingHistory.value.find(a => a.tool === 'menban')if (existingMenban) {menbanSizePercent.value = existingMenban.sizePercent || 0.1menbanOpacity.value = existingMenban.opacity || 0.7}// 检查是否有马赛克并更新状态和索引mosaicAreas.value = []measureAreas.value = []drawingHistory.value.forEach((action, index) => {if (action.tool === 'mosaic') {mosaicAreas.value.push(index)mosaicBlockSize.value = action.mosaicSize || 10}// 检查是否有测量工具并更新状态和索引if (action.tool === 'measure') {measureAreas.value.push(index)measureUnit.value = action.measureUnit || 'cm'measureScale.value = action.scale || 1}})redrawCanvas()}}, 500)if (props.socket) {props.socket.on('message', (event: any) => {try {const data = JSON.parse(event.data)if (data.incidentType === 'annotation') {handleDrawingData(data)}} catch (e) {console.error('Failed to parse WebSocket message:', e)}})} })// 组件卸载时清理 onBeforeUnmount(() => {window.removeEventListener('resize', initCanvas) })// 暴露方法 defineExpose({open,close }) </script><style scoped lang="scss"> .annotation {position: absolute;top: 8px;left: 8px;right: 8px;bottom: 8px;.annotation-container {position: relative;width: 100%;height: 100%;}.showBorder {border: 5px solid red;box-sizing: border-box;}canvas {position: absolute;top: 0;left: 0;width: 100%;height: 100%;pointer-events: auto;opacity: 1;transition: opacity 0.3s;}.toolbar {display: flex;align-items: center;overflow: hidden;width: 100vw;height: 8vh;position: fixed;left: 50%;transform: translateX(-50%);bottom: 0;border-radius: 6px;background: #fff;z-index: 9999;padding: 0 1.11vw;box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05);:deep(.xiaoanMeeting-bottomMenuBtn-box) {margin-bottom: 5px;.el-input__inner,.el-input__wrapper,.el-button {height: 24px;font-size: 12px;line-height: 24px;}}:deep(.xiaoanMeeting-bottomMenuBtn-box-text) {font-size: 12px;}.tool-menu {width: 50%;}.color-picker {width: 5%;}.text-input {width: 45%;}.menban-controls,.mosaic-controls,.measure-controls,.line-width-controls {/* 添加测量工具控制样式 */display: flex;align-items: center;margin: 0 15px;padding: 5px;background-color: #f5f5f5;border-radius: 4px;}:deep(.el-slider),:deep(.el-select),:deep(.el-input) {margin: 0 5px;}:deep(.xm-btn.active) {background-color: #409eff;color: white;border-color: #409eff;}/* 下拉框按钮样式调整 */:deep(.el-dropdown) {margin: 0 5px;}/* 清除蒙版按钮样式 */.ml5 {margin-left: 5px;}}el-button {padding: 8px 12px;cursor: pointer;border: 1px solid #ccc;border-radius: 4px;background: white;}el-button.active {background: #4caf50;color: white;border-color: #4caf50;}.text-input {display: flex;gap: 5px;}input[type='text'] {padding: 8px;border: 1px solid #ccc;border-radius: 4px;}.color-picker {display: flex;align-items: center;gap: 5px;}input[type='color'] {width: 30px;height: 30px;border: none;cursor: pointer;border-radius: 50%;-webkit-appearance: none;appearance: none;}input[type='color']::-webkit-color-swatch {border: none;border-radius: 50%;padding: 0;}input[type='color']::-webkit-color-swatch-wrapper {border: none;border-radius: 50%;padding: 0;}input[type='color']::-moz-color-swatch {border: none;border-radius: 50%;padding: 0;}.data-panel {width: 800px;margin-top: 20px;}.data-el-buttons {display: flex;gap: 10px;margin-top: 10px;} }/* 截图预览样式 - 确保图片保持原始比例 */ .preview-container {display: flex;justify-content: center;padding: 10px; }.preview-image {max-width: 100%;max-height: 70vh;object-fit: contain;/* 标准属性值,让图像边缘清晰,不同浏览器按规范渲染 */image-rendering: crisp-edges;/* 针对 WebKit 内核浏览器(如 Chrome、Safari )的适配,可保留或根据情况调整 */image-rendering: -webkit-crisp-edges;/* 另一种清晰渲染的取值,适合像素化风格需求 */image-rendering: pixelated; }/* 测量工具图标样式 */ .icon-celiang {background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12h20'/%3E%3Cpath d='M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");background-size: contain;background-repeat: no-repeat;display: inline-block;width: 16px;height: 16px; }/* 清除按钮图标样式 */ .icon-clear {background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");background-size: contain;background-repeat: no-repeat;display: inline-block;width: 16px;height: 16px; }/* 形状工具和覆盖工具图标 */ .icon-tuxing, .icon-fugai {display: inline-block;width: 16px;height: 16px; } .el-dropdown-link {display: flex;cursor: pointer;// color: var(--el-color-primary);// display: flex;// align-items: center;.bottomMenuChecks-right {margin-left: 5px;display: flex;align-items: center;} } </style>