diff --git a/src/api/alarm.ts b/src/api/alarm.ts index e4f0776..21d2e72 100644 --- a/src/api/alarm.ts +++ b/src/api/alarm.ts @@ -50,6 +50,8 @@ export interface RosInspectionRecordLineImage { poseY?: number; /** 抓拍时的小车朝向 */ poseYaw?: number; + /** 告警位置对应的里程点标识(后端按告警位姿在巡检路径上投影匹配) */ + mileageLabel?: string; /** 识别状态:0待执行 1执行中 2执行成功 3执行失败 */ recognitionStatus?: number; /** AI识别提示词 */ diff --git a/src/api/mileage.ts b/src/api/mileage.ts new file mode 100644 index 0000000..1035ec2 --- /dev/null +++ b/src/api/mileage.ts @@ -0,0 +1,49 @@ +import request from "@/utils/request"; +import type { ApiResult } from "@/types/api"; + +/** + * 里程点(设备级标定物,独立于巡检任务与轮次) + * 对应后端 com.haizhiyustc.ros.entity.RosMileagePoint + */ +export interface RosMileagePoint { + /** 主键id,新增时不传 */ + id?: string; + /** 所属巡检设备id */ + carId: string; + /** 里程点标识,人工填写,如 a区-01 */ + mileageLabel: string; + /** 位姿x,单位米 */ + poseX?: number; + /** 位姿y,单位米 */ + poseY?: number; + /** 备注 */ + remark?: string; + /** 创建时间 */ + createTime?: string; + /** 更新时间 */ + updateTime?: string; +} + +// 查询指定设备的里程点列表 +export function getMileagePointList(carId: string) { + return request>({ + url: "/admin/rosMileagePoint/list", + params: { carId }, + }); +} + +// 保存里程点(有 id 为更新,无 id 为新增) +export function saveMileagePoint(data: RosMileagePoint) { + return request>({ + url: "/admin/rosMileagePoint/save", + data, + }); +} + +// 删除里程点 +export function removeMileagePoint(id: string) { + return request>({ + url: "/admin/rosMileagePoint/remove", + params: { id }, + }); +} diff --git a/src/views/dashboard1/pages/AlarmManagementPage.vue b/src/views/dashboard1/pages/AlarmManagementPage.vue index 2620dc9..7df3509 100644 --- a/src/views/dashboard1/pages/AlarmManagementPage.vue +++ b/src/views/dashboard1/pages/AlarmManagementPage.vue @@ -16,6 +16,7 @@ interface AlarmRecord { status: RecognitionStatusText; time: string; position: string; + mileage: string; result: string; warning: boolean; } @@ -153,6 +154,7 @@ function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: numbe status: getRecognitionStatusText(record.recognitionStatus), time: record.captureTime || record.createTime || "--", position: formatPosition(record), + mileage: record.mileageLabel || "--", result: record.warningValue || record.recognitionResult || record.failReason || "--", warning: Boolean(record.warning), }; @@ -271,19 +273,20 @@ onMounted(() => { 案件状态 告警时间 位置 + 里程点 识别结果 是否预警 - 近期告警加载中... + 近期告警加载中... - {{ listError }} + {{ listError }} - 暂无告警记录 + 暂无告警记录 {{ alarm.id }} @@ -305,6 +308,7 @@ onMounted(() => { {{ alarm.time }} {{ alarm.position }} + {{ alarm.mileage }} {{ alarm.result }} {{ alarm.warning ? "是" : "否" }} diff --git a/src/views/dashboard1/pages/EquipmentManagementPage.vue b/src/views/dashboard1/pages/EquipmentManagementPage.vue index 79d8ec8..a7f87dd 100644 --- a/src/views/dashboard1/pages/EquipmentManagementPage.vue +++ b/src/views/dashboard1/pages/EquipmentManagementPage.vue @@ -8,6 +8,12 @@ import { updateRosCarMapUrls, uploadFile, } from "@/api/rosCar"; +import { + getMileagePointList, + removeMileagePoint, + saveMileagePoint, + type RosMileagePoint, +} from "@/api/mileage"; import { parseRosMapYaml } from "@/utils/rosMapYaml"; type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中"; @@ -20,7 +26,9 @@ type RecognitionActionCode = | "TUNNEL_WATER" | "WALL_CRACK"; type PatrolTaskMode = "定时" | "周期" | "临时"; -type EditorMode = "select" | "normal" | "thermal" | "pump" | "waypoint" | "dock" | "preDock"; +type EditorMode = "select" | "normal" | "thermal" | "pump" | "waypoint" | "dock" | "preDock" | "mileage"; +/** 地图编辑目标:巡检轮次(编辑巡检点位)或里程点(独立标定物),两者互斥 */ +type MapEditTarget = "patrol" | "mileage"; interface RosCarRecord { id?: string; @@ -214,6 +222,20 @@ interface PatrolTask { updatedAt: string; } +/** + * 里程点:设备级标定物,独立于巡检点位与轮次,仅用于告警位置参照 + */ +interface MileagePointState { + /** 后端主键id,未保存时为空 */ + id?: string; + /** 里程点标识,人工填写,如 a区-01 */ + label: string; + /** 地图百分比坐标x */ + x: number; + /** 地图百分比坐标y */ + y: number; +} + interface DockConfigState { dockX?: number; dockY?: number; @@ -236,6 +258,8 @@ interface DeviceConfigState { points: PatrolPoint[]; selectedPointIds: string[]; plans: PatrolTask[]; + /** 设备级里程点列表(独立接口维护,不随巡检任务保存) */ + mileages: MileagePointState[]; dock: DockConfigState; } @@ -312,6 +336,26 @@ const pointDropPosition = ref<"before" | "after">("before"); // 列表点击"前/后插入"后,记录待插入位置,等地图点选坐标后写入该位置 const pendingInsertIndex = ref(null); const pendingInsertLabel = ref(""); +// 地图当前编辑对象,默认编辑巡检轮次点位 +const mapEditTarget = ref("patrol"); +// 里程点拖动状态 +const dragMileageState = reactive({ + active: false, + moved: false, + mileageId: "", + offsetX: 0, + offsetY: 0, +}); +// 里程点编辑弹窗状态 +const mileageDialogVisible = ref(false); +const mileageSaving = ref(false); +const mileageMessage = ref(""); +const mileageForm = reactive({ + id: "", + label: "", + x: 0, + y: 0, +}); const mapStageRef = ref(null); const mapCanvasRef = ref(null); const mapOverviewRef = ref(null); @@ -406,6 +450,9 @@ const selectedPointHighlight = { glow: "rgba(255, 178, 110, 0.32)", }; +// 里程点独立配色(设备级标定物,不参与巡检路径) +const mileagePointColor = "#c39bff"; + const defaultRosMapMeta: RosMapMetaState = { image: "room.png", resolution: 0.05, @@ -1287,6 +1334,7 @@ function buildDeviceConfig(device: DeviceRecord): DeviceConfigState { points, selectedPointIds: [], plans, + mileages: [], dock: buildDockConfig(device), }; } @@ -1476,11 +1524,14 @@ async function openConfigDialog(device: DeviceRecord) { await applyDeviceMapUrls(device, config); config.points = config.points.map(normalizePatrolPointActions); + // 兼容热更新前生成的旧配置对象,避免里程点列表未初始化导致渲染异常 + config.mileages = config.mileages ?? []; deviceConfigs[device.id] = config; configuringDeviceId.value = device.id; configDialogVisible.value = true; mapEditorMode.value = "select"; cancelInsertPoint(); + void loadMileagePoints(device.id, config); if (!config.plans.some((plan) => plan.id === config.activePlanId)) { config.activePlanId = config.plans[0]?.id ?? ""; @@ -1521,10 +1572,12 @@ function closeConfigDialog() { configDialogVisible.value = false; planDialogVisible.value = false; + mileageDialogVisible.value = false; configuringDeviceId.value = null; cancelInsertPoint(); clearSelectionBox(); clearDragPointState(); + clearDragMileageState(); clearMapPanState(); stopMapSyncTimer(); } @@ -1832,6 +1885,30 @@ function findHitPoint(event: MouseEvent) { return null; } +// 里程点命中检测,用于里程模式下的拖动与编辑 +function findHitMileagePoint(event: MouseEvent) { + const rect = getMapStageRect(); + if (!rect || !currentConfig.value) { + return null; + } + + const pointerX = event.clientX - rect.left; + const pointerY = event.clientY - rect.top; + const mileages = [...currentConfig.value.mileages].reverse(); + + for (const mileage of mileages) { + const pixelPoint = percentToStagePixels(mileage.x, mileage.y, rect); + const deltaX = pixelPoint.x - pointerX; + const deltaY = pixelPoint.y - pointerY; + + if (Math.hypot(deltaX, deltaY) <= 16) { + return mileage; + } + } + + return null; +} + function renderMapCanvas() { const canvas = mapCanvasRef.value; const rect = getMapStageRect(); @@ -2003,6 +2080,37 @@ function renderMapCanvas() { context.restore(); }); + // 里程点用菱形 + 标识文字绘制,与巡检点位区分 + currentConfig.value?.mileages.forEach((mileage) => { + const pixelPoint = percentToStagePixels(mileage.x, mileage.y, rect); + + context.save(); + context.beginPath(); + context.moveTo(pixelPoint.x, pixelPoint.y - 9); + context.lineTo(pixelPoint.x + 9, pixelPoint.y); + context.lineTo(pixelPoint.x, pixelPoint.y + 9); + context.lineTo(pixelPoint.x - 9, pixelPoint.y); + context.closePath(); + context.fillStyle = "rgba(5, 18, 38, 0.94)"; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = mileagePointColor; + context.shadowColor = "rgba(195, 155, 255, 0.35)"; + context.shadowBlur = 12; + context.stroke(); + context.restore(); + + if (mileage.label) { + context.save(); + context.fillStyle = "#e6d6ff"; + context.font = '600 11px "Microsoft YaHei", sans-serif'; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText(mileage.label, pixelPoint.x, pixelPoint.y - 12); + context.restore(); + } + }); + drawDockMarker( context, rect, @@ -2289,6 +2397,14 @@ function clearSelectionBox() { selectionBox.endY = 0; } +function clearDragMileageState() { + dragMileageState.active = false; + dragMileageState.moved = false; + dragMileageState.mileageId = ""; + dragMileageState.offsetX = 0; + dragMileageState.offsetY = 0; +} + function clearDragPointState() { dragPointState.active = false; dragPointState.moved = false; @@ -2353,6 +2469,30 @@ function handleMapPointerDown(event: MouseEvent) { return; } + // 里程模式:只操作里程点,命中则进入拖动,未命中则平移地图 + if (mapEditTarget.value === "mileage") { + const hitMileage = findHitMileagePoint(event); + + if (hitMileage) { + dragMileageState.active = true; + dragMileageState.moved = false; + dragMileageState.mileageId = hitMileage.id ?? ""; + dragMileageState.offsetX = hitMileage.x - position.x; + dragMileageState.offsetY = hitMileage.y - position.y; + bindMapPointerTracking(); + return; + } + + mapPanState.active = true; + mapPanState.moved = false; + mapPanState.startClientX = event.clientX; + mapPanState.startClientY = event.clientY; + mapPanState.startPanX = mapPan.x; + mapPanState.startPanY = mapPan.y; + bindMapPointerTracking(); + return; + } + const hitPoint = findHitPoint(event); if (hitPoint) { if (!currentConfig.value.selectedPointIds.includes(hitPoint.id)) { @@ -2392,6 +2532,39 @@ function handleMapPointerMove(event: MouseEvent) { return; } + if (dragMileageState.active && currentConfig.value) { + const position = getRelativePosition(event); + if (!position) { + return; + } + + const targetMileage = currentConfig.value.mileages.find( + (mileage) => mileage.id === dragMileageState.mileageId, + ); + + if (!targetMileage) { + clearDragMileageState(); + return; + } + + const nextPosition = clampMapPosition( + position.x + dragMileageState.offsetX, + position.y + dragMileageState.offsetY, + ); + + if ( + Math.abs(nextPosition.x - targetMileage.x) > 0.05 || + Math.abs(nextPosition.y - targetMileage.y) > 0.05 + ) { + dragMileageState.moved = true; + } + + targetMileage.x = nextPosition.x; + targetMileage.y = nextPosition.y; + scheduleMapCanvasRender(); + return; + } + if (dragPointState.active && currentConfig.value) { const position = getRelativePosition(event); if (!position) { @@ -2455,6 +2628,24 @@ function handleMapPointerUp(event: MouseEvent) { return; } + if (dragMileageState.active) { + const mileageId = dragMileageState.mileageId; + const moved = dragMileageState.moved; + + clearDragMileageState(); + + if (moved) { + suppressStageClick.value = true; + const mileage = currentConfig.value?.mileages.find((item) => item.id === mileageId); + + if (mileage) { + void persistMileagePosition(mileage); + } + } + + return; + } + if (dragPointState.active) { if (dragPointState.moved) { suppressStageClick.value = true; @@ -2721,6 +2912,13 @@ function handleMapStageClick(event: MouseEvent) { return; } + // 里程模式:点中已有里程点则编辑,点空白处新增 + if (mapEditTarget.value === "mileage") { + const hitMileage = findHitMileagePoint(event); + openMileageDialog(hitMileage ? undefined : position, hitMileage ?? undefined); + return; + } + const hitPoint = findHitPoint(event); if (hitPoint) { handlePointClick(hitPoint.id, event); @@ -2904,6 +3102,170 @@ function clearCanvas() { updateSelection([]); } +// 里程点按设备维度独立加载,不随巡检任务一起保存 +async function loadMileagePoints(deviceId: string, config: DeviceConfigState) { + try { + const response = await getMileagePointList(deviceId); + const rows: RosMileagePoint[] = response.data ?? []; + + config.mileages = rows.map((item) => { + const position = rosPoseToMapPercent(item.poseX ?? 0, item.poseY ?? 0); + + return { + id: item.id, + label: item.mileageLabel, + x: position.x, + y: position.y, + }; + }); + nextTick(() => { + scheduleMapCanvasRender(); + }); + } catch (error) { + console.error(error); + config.mileages = []; + } +} + +// 切换地图编辑目标:里程模式下只操作里程点,巡检模式下只操作巡检点位 +function switchMapEditTarget(target: MapEditTarget) { + mapEditTarget.value = target; + cancelInsertPoint(); + + if (target === "mileage") { + mapEditorMode.value = "mileage"; + return; + } + + if (mapEditorMode.value === "mileage") { + mapEditorMode.value = "select"; + } +} + +// 拖动结束后把里程点新位置写回后端 +async function persistMileagePosition(mileage: MileagePointState) { + const device = currentConfiguredDevice.value; + + if (!device || !mileage.id) { + return; + } + + const rosPosition = percentToRosPosition(mileage.x, mileage.y); + + try { + await saveMileagePoint({ + id: mileage.id, + carId: device.id, + mileageLabel: mileage.label, + poseX: rosPosition.x, + poseY: rosPosition.y, + }); + } catch (error) { + console.error(error); + } +} + +// 打开里程点编辑弹窗;position 为地图点选得到的百分比坐标 +function openMileageDialog( + position?: { x: number; y: number }, + mileage?: MileagePointState, +) { + mileageForm.id = mileage?.id ?? ""; + mileageForm.label = mileage?.label ?? ""; + mileageForm.x = mileage?.x ?? position?.x ?? 0; + mileageForm.y = mileage?.y ?? position?.y ?? 0; + mileageMessage.value = ""; + mileageDialogVisible.value = true; +} + +function closeMileageDialog() { + mileageDialogVisible.value = false; + mileageMessage.value = ""; + mileageSaving.value = false; + // 保持当前编辑目标:里程模式下继续可点选,巡检模式下回到查看 + mapEditorMode.value = mapEditTarget.value === "mileage" ? "mileage" : "select"; +} + +// 保存里程点:把页面百分比坐标换算成 ROS 米制坐标后提交 +async function saveMileage() { + const config = currentConfig.value; + const device = currentConfiguredDevice.value; + + if (!config || !device || mileageSaving.value) { + return; + } + + const label = mileageForm.label.trim(); + + if (!label) { + mileageMessage.value = "请填写里程点标识,例如 a区-01"; + return; + } + + const rosPosition = percentToRosPosition(mileageForm.x, mileageForm.y); + + mileageSaving.value = true; + mileageMessage.value = ""; + + try { + const response = await saveMileagePoint({ + id: mileageForm.id || undefined, + carId: device.id, + mileageLabel: label, + poseX: rosPosition.x, + poseY: rosPosition.y, + }); + const saved = response.data; + + if (saved?.id) { + const position = rosPoseToMapPercent(saved.poseX ?? 0, saved.poseY ?? 0); + const nextMileage: MileagePointState = { + id: saved.id, + label: saved.mileageLabel || label, + x: position.x, + y: position.y, + }; + const index = config.mileages.findIndex((item) => item.id === saved.id); + + if (index >= 0) { + config.mileages[index] = nextMileage; + } else { + config.mileages.push(nextMileage); + } + } + + mileageDialogVisible.value = false; + mileageMessage.value = ""; + mapEditorMode.value = mapEditTarget.value === "mileage" ? "mileage" : "select"; + nextTick(() => { + scheduleMapCanvasRender(); + }); + } catch (error) { + console.error(error); + mileageMessage.value = "里程点保存失败,请稍后重试"; + } finally { + mileageSaving.value = false; + } +} + +async function removeMileagePointItem(mileage: MileagePointState) { + const config = currentConfig.value; + + if (!config || !mileage.id) { + return; + } + + try { + await removeMileagePoint(mileage.id); + config.mileages = config.mileages.filter((item) => item.id !== mileage.id); + nextTick(() => { + scheduleMapCanvasRender(); + }); + } catch (error) { + console.error(error); + } +} + async function saveConfigDialog() { if (!currentConfig.value || !currentConfiguredDevice.value || saveProgressVisible.value) { return; @@ -3459,23 +3821,40 @@ watch(
- - - -
+
+ + +
+ + + +
+ 点击空白处新增里程点,拖动已有点位可调整位置 +
+
在地图上点击放置新点位,将插入到{{ pendingInsertLabel }}
@@ -3611,6 +3990,40 @@ watch(
+
+
+
+
里程点配置
+
+ 设备级定位标定物,独立于巡检点位与轮次,用于告警时定位故障位置 +
+
+ +
+ +
+ 请在左侧地图上点击新增里程点,或拖动已有点位调整位置 +
+ +
+
+
+
{{ mileage.label || "未命名里程点" }}
+
+
+ + +
+
+
+ 暂无里程点,点击“地图点选新增”后在地图上选择位置。 +
+
+
+
充电回充配置 @@ -3772,6 +4185,36 @@ watch(
+
+
+
+
+
里程点
+
填写里程标识后保存,例如 a区-01
+
+ +
+ +
+ +
+ +
{{ mileageMessage }}
+ +
+ + +
+
+
+
保存
@@ -4142,6 +4585,31 @@ watch( gap: 10px; } +.map-edit-target-switch { + display: inline-flex; + align-items: center; + padding: 2px; + border: 1px solid rgba(87, 198, 255, 0.26); + background: rgba(8, 31, 62, 0.72); +} + +.map-edit-target-switch button { + height: 28px; + padding: 0 14px; + border: 0; + background: transparent; + color: rgba(192, 235, 250, 0.78); + font-size: 12px; + cursor: pointer; +} + +.map-edit-target-switch button.active { + color: #eefcff; + background: linear-gradient(90deg, + rgba(24, 102, 180, 0.88), + rgba(35, 158, 215, 0.88)); +} + .editor-tool-btn { height: 34px; padding: 0 14px; @@ -4757,8 +5225,8 @@ watch( .rounds-board { padding: 16px; flex: 1 1 auto; - /* 新增充电回充卡片后,轮次配置不再设为 0 基准高度,避免被压缩到看不见 */ - min-height: 300px; + /* 侧栏已有充电回充、里程点两张卡片,保证轮次配置的最小可用高度 */ + min-height: 460px; display: flex; flex-direction: column; } @@ -4922,6 +5390,50 @@ watch( font-size: 11px; } +.mileage-config-board { + flex: 0 0 auto; +} + +.mileage-pick-hint { + margin-top: 12px; + padding: 8px 10px; + border: 1px dashed rgba(195, 155, 255, 0.5); + background: rgba(60, 40, 110, 0.35); + color: #e2d4ff; + font-size: 12px; +} + +.mileage-list-shell { + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 180px; + overflow: auto; +} + +.mileage-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(174, 138, 255, 0.22); + background: rgba(30, 18, 62, 0.62); +} + +.mileage-list-main { + min-width: 0; + flex: 1; +} + +.mileage-list-label { + color: #f2fbff; + font-size: 14px; + font-weight: 700; +} + + .point-drag-handle { flex: 0 0 auto; display: inline-flex; diff --git a/src/views/dashboard1/pages/IntegratedCenterPage.vue b/src/views/dashboard1/pages/IntegratedCenterPage.vue index a40f2ff..cf49c9d 100644 --- a/src/views/dashboard1/pages/IntegratedCenterPage.vue +++ b/src/views/dashboard1/pages/IntegratedCenterPage.vue @@ -243,6 +243,7 @@ interface AlertItem { title: string; result: string; time: string; + mileage: string; photo: string; } @@ -398,6 +399,7 @@ function normalizeAlertRecord(record: RosInspectionRecordLineImage, index: numbe title, result: record.warningValue || record.recognitionResult || record.failReason || "--", time: record.captureTime || record.createTime || "--", + mileage: record.mileageLabel || "", photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(title), }; } @@ -1820,6 +1822,10 @@ function getStatusClass(status: DeviceItem["status"]) { 告警时间 {{ alert.time }}
+
+ 里程点 + {{ alert.mileage }} +