里程点补充

This commit is contained in:
Chenhuaqiang
2026-10-10 11:25:04 +08:00
parent e6368e32d5
commit 96134d78ae
5 changed files with 596 additions and 23 deletions

View File

@@ -50,6 +50,8 @@ export interface RosInspectionRecordLineImage {
poseY?: number; poseY?: number;
/** 抓拍时的小车朝向 */ /** 抓拍时的小车朝向 */
poseYaw?: number; poseYaw?: number;
/** 告警位置对应的里程点标识(后端按告警位姿在巡检路径上投影匹配) */
mileageLabel?: string;
/** 识别状态:0待执行 1执行中 2执行成功 3执行失败 */ /** 识别状态:0待执行 1执行中 2执行成功 3执行失败 */
recognitionStatus?: number; recognitionStatus?: number;
/** AI识别提示词 */ /** AI识别提示词 */

49
src/api/mileage.ts Normal file
View File

@@ -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<ApiResult<RosMileagePoint[]>>({
url: "/admin/rosMileagePoint/list",
params: { carId },
});
}
// 保存里程点(有 id 为更新,无 id 为新增)
export function saveMileagePoint(data: RosMileagePoint) {
return request<ApiResult<RosMileagePoint>>({
url: "/admin/rosMileagePoint/save",
data,
});
}
// 删除里程点
export function removeMileagePoint(id: string) {
return request<ApiResult<null>>({
url: "/admin/rosMileagePoint/remove",
params: { id },
});
}

View File

@@ -16,6 +16,7 @@ interface AlarmRecord {
status: RecognitionStatusText; status: RecognitionStatusText;
time: string; time: string;
position: string; position: string;
mileage: string;
result: string; result: string;
warning: boolean; warning: boolean;
} }
@@ -153,6 +154,7 @@ function normalizeAlarmRecord(record: RosInspectionRecordLineImage, index: numbe
status: getRecognitionStatusText(record.recognitionStatus), status: getRecognitionStatusText(record.recognitionStatus),
time: record.captureTime || record.createTime || "--", time: record.captureTime || record.createTime || "--",
position: formatPosition(record), position: formatPosition(record),
mileage: record.mileageLabel || "--",
result: record.warningValue || record.recognitionResult || record.failReason || "--", result: record.warningValue || record.recognitionResult || record.failReason || "--",
warning: Boolean(record.warning), warning: Boolean(record.warning),
}; };
@@ -271,19 +273,20 @@ onMounted(() => {
<th>案件状态</th> <th>案件状态</th>
<th>告警时间</th> <th>告警时间</th>
<th>位置</th> <th>位置</th>
<th>里程点</th>
<th>识别结果</th> <th>识别结果</th>
<th>是否预警</th> <th>是否预警</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="listLoading"> <tr v-if="listLoading">
<td class="table-empty" colspan="9">近期告警加载中...</td> <td class="table-empty" colspan="10">近期告警加载中...</td>
</tr> </tr>
<tr v-else-if="listError"> <tr v-else-if="listError">
<td class="table-empty" colspan="9">{{ listError }}</td> <td class="table-empty" colspan="10">{{ listError }}</td>
</tr> </tr>
<tr v-else-if="!pagedAlarms.length"> <tr v-else-if="!pagedAlarms.length">
<td class="table-empty" colspan="9">暂无告警记录</td> <td class="table-empty" colspan="10">暂无告警记录</td>
</tr> </tr>
<tr v-for="alarm in pagedAlarms" v-else :key="alarm.id"> <tr v-for="alarm in pagedAlarms" v-else :key="alarm.id">
<td>{{ alarm.id }}</td> <td>{{ alarm.id }}</td>
@@ -305,6 +308,7 @@ onMounted(() => {
</td> </td>
<td>{{ alarm.time }}</td> <td>{{ alarm.time }}</td>
<td>{{ alarm.position }}</td> <td>{{ alarm.position }}</td>
<td>{{ alarm.mileage }}</td>
<td>{{ alarm.result }}</td> <td>{{ alarm.result }}</td>
<td>{{ alarm.warning ? "是" : "否" }}</td> <td>{{ alarm.warning ? "是" : "否" }}</td>
</tr> </tr>

View File

@@ -8,6 +8,12 @@ import {
updateRosCarMapUrls, updateRosCarMapUrls,
uploadFile, uploadFile,
} from "@/api/rosCar"; } from "@/api/rosCar";
import {
getMileagePointList,
removeMileagePoint,
saveMileagePoint,
type RosMileagePoint,
} from "@/api/mileage";
import { parseRosMapYaml } from "@/utils/rosMapYaml"; import { parseRosMapYaml } from "@/utils/rosMapYaml";
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中"; type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中";
@@ -20,7 +26,9 @@ type RecognitionActionCode =
| "TUNNEL_WATER" | "TUNNEL_WATER"
| "WALL_CRACK"; | "WALL_CRACK";
type PatrolTaskMode = "定时" | "周期" | "临时"; 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 { interface RosCarRecord {
id?: string; id?: string;
@@ -214,6 +222,20 @@ interface PatrolTask {
updatedAt: string; updatedAt: string;
} }
/**
* 里程点:设备级标定物,独立于巡检点位与轮次,仅用于告警位置参照
*/
interface MileagePointState {
/** 后端主键id,未保存时为空 */
id?: string;
/** 里程点标识,人工填写,如 a区-01 */
label: string;
/** 地图百分比坐标x */
x: number;
/** 地图百分比坐标y */
y: number;
}
interface DockConfigState { interface DockConfigState {
dockX?: number; dockX?: number;
dockY?: number; dockY?: number;
@@ -236,6 +258,8 @@ interface DeviceConfigState {
points: PatrolPoint[]; points: PatrolPoint[];
selectedPointIds: string[]; selectedPointIds: string[];
plans: PatrolTask[]; plans: PatrolTask[];
/** 设备级里程点列表(独立接口维护,不随巡检任务保存) */
mileages: MileagePointState[];
dock: DockConfigState; dock: DockConfigState;
} }
@@ -312,6 +336,26 @@ const pointDropPosition = ref<"before" | "after">("before");
// 列表点击"前/后插入"后,记录待插入位置,等地图点选坐标后写入该位置 // 列表点击"前/后插入"后,记录待插入位置,等地图点选坐标后写入该位置
const pendingInsertIndex = ref<number | null>(null); const pendingInsertIndex = ref<number | null>(null);
const pendingInsertLabel = ref(""); const pendingInsertLabel = ref("");
// 地图当前编辑对象,默认编辑巡检轮次点位
const mapEditTarget = ref<MapEditTarget>("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<HTMLElement | null>(null); const mapStageRef = ref<HTMLElement | null>(null);
const mapCanvasRef = ref<HTMLCanvasElement | null>(null); const mapCanvasRef = ref<HTMLCanvasElement | null>(null);
const mapOverviewRef = ref<HTMLCanvasElement | null>(null); const mapOverviewRef = ref<HTMLCanvasElement | null>(null);
@@ -406,6 +450,9 @@ const selectedPointHighlight = {
glow: "rgba(255, 178, 110, 0.32)", glow: "rgba(255, 178, 110, 0.32)",
}; };
// 里程点独立配色(设备级标定物,不参与巡检路径)
const mileagePointColor = "#c39bff";
const defaultRosMapMeta: RosMapMetaState = { const defaultRosMapMeta: RosMapMetaState = {
image: "room.png", image: "room.png",
resolution: 0.05, resolution: 0.05,
@@ -1287,6 +1334,7 @@ function buildDeviceConfig(device: DeviceRecord): DeviceConfigState {
points, points,
selectedPointIds: [], selectedPointIds: [],
plans, plans,
mileages: [],
dock: buildDockConfig(device), dock: buildDockConfig(device),
}; };
} }
@@ -1476,11 +1524,14 @@ async function openConfigDialog(device: DeviceRecord) {
await applyDeviceMapUrls(device, config); await applyDeviceMapUrls(device, config);
config.points = config.points.map(normalizePatrolPointActions); config.points = config.points.map(normalizePatrolPointActions);
// 兼容热更新前生成的旧配置对象,避免里程点列表未初始化导致渲染异常
config.mileages = config.mileages ?? [];
deviceConfigs[device.id] = config; deviceConfigs[device.id] = config;
configuringDeviceId.value = device.id; configuringDeviceId.value = device.id;
configDialogVisible.value = true; configDialogVisible.value = true;
mapEditorMode.value = "select"; mapEditorMode.value = "select";
cancelInsertPoint(); cancelInsertPoint();
void loadMileagePoints(device.id, config);
if (!config.plans.some((plan) => plan.id === config.activePlanId)) { if (!config.plans.some((plan) => plan.id === config.activePlanId)) {
config.activePlanId = config.plans[0]?.id ?? ""; config.activePlanId = config.plans[0]?.id ?? "";
@@ -1521,10 +1572,12 @@ function closeConfigDialog() {
configDialogVisible.value = false; configDialogVisible.value = false;
planDialogVisible.value = false; planDialogVisible.value = false;
mileageDialogVisible.value = false;
configuringDeviceId.value = null; configuringDeviceId.value = null;
cancelInsertPoint(); cancelInsertPoint();
clearSelectionBox(); clearSelectionBox();
clearDragPointState(); clearDragPointState();
clearDragMileageState();
clearMapPanState(); clearMapPanState();
stopMapSyncTimer(); stopMapSyncTimer();
} }
@@ -1832,6 +1885,30 @@ function findHitPoint(event: MouseEvent) {
return null; 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() { function renderMapCanvas() {
const canvas = mapCanvasRef.value; const canvas = mapCanvasRef.value;
const rect = getMapStageRect(); const rect = getMapStageRect();
@@ -2003,6 +2080,37 @@ function renderMapCanvas() {
context.restore(); 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( drawDockMarker(
context, context,
rect, rect,
@@ -2289,6 +2397,14 @@ function clearSelectionBox() {
selectionBox.endY = 0; selectionBox.endY = 0;
} }
function clearDragMileageState() {
dragMileageState.active = false;
dragMileageState.moved = false;
dragMileageState.mileageId = "";
dragMileageState.offsetX = 0;
dragMileageState.offsetY = 0;
}
function clearDragPointState() { function clearDragPointState() {
dragPointState.active = false; dragPointState.active = false;
dragPointState.moved = false; dragPointState.moved = false;
@@ -2353,6 +2469,30 @@ function handleMapPointerDown(event: MouseEvent) {
return; 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); const hitPoint = findHitPoint(event);
if (hitPoint) { if (hitPoint) {
if (!currentConfig.value.selectedPointIds.includes(hitPoint.id)) { if (!currentConfig.value.selectedPointIds.includes(hitPoint.id)) {
@@ -2392,6 +2532,39 @@ function handleMapPointerMove(event: MouseEvent) {
return; 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) { if (dragPointState.active && currentConfig.value) {
const position = getRelativePosition(event); const position = getRelativePosition(event);
if (!position) { if (!position) {
@@ -2455,6 +2628,24 @@ function handleMapPointerUp(event: MouseEvent) {
return; 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.active) {
if (dragPointState.moved) { if (dragPointState.moved) {
suppressStageClick.value = true; suppressStageClick.value = true;
@@ -2721,6 +2912,13 @@ function handleMapStageClick(event: MouseEvent) {
return; return;
} }
// 里程模式:点中已有里程点则编辑,点空白处新增
if (mapEditTarget.value === "mileage") {
const hitMileage = findHitMileagePoint(event);
openMileageDialog(hitMileage ? undefined : position, hitMileage ?? undefined);
return;
}
const hitPoint = findHitPoint(event); const hitPoint = findHitPoint(event);
if (hitPoint) { if (hitPoint) {
handlePointClick(hitPoint.id, event); handlePointClick(hitPoint.id, event);
@@ -2904,6 +3102,170 @@ function clearCanvas() {
updateSelection([]); 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() { async function saveConfigDialog() {
if (!currentConfig.value || !currentConfiguredDevice.value || saveProgressVisible.value) { if (!currentConfig.value || !currentConfiguredDevice.value || saveProgressVisible.value) {
return; return;
@@ -3459,23 +3821,40 @@ watch(
</div> </div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<button class="editor-tool-btn icon" :class="{ active: mapEditorMode === 'select' }" type="button" <div class="map-edit-target-switch">
title="拖动查看" aria-label="拖动查看" @click="cancelInsertPoint(); mapEditorMode = 'select'"> <button :class="{ active: mapEditTarget === 'patrol' }" type="button"
<svg viewBox="0 0 24 24" aria-hidden="true"> @click="switchMapEditTarget('patrol')">
<path 巡检轮次
d="M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z" /> </button>
</svg> <button :class="{ active: mapEditTarget === 'mileage' }" type="button"
</button> @click="switchMapEditTarget('mileage')">
<button v-for="mode in editorModes" :key="mode.id" :class="[ 里程点
'editor-tool-btn', </button>
{ active: mapEditorMode === mode.id }, </div>
]" type="button" @click="mapEditorMode = mode.id">
{{ mode.label }} <template v-if="mapEditTarget === 'patrol'">
</button> <button class="editor-tool-btn icon" :class="{ active: mapEditorMode === 'select' }" type="button"
<button class="editor-tool-btn" type="button" @click="clearCanvas"> title="拖动查看" aria-label="拖动查看" @click="cancelInsertPoint(); mapEditorMode = 'select'">
清空巡检点 <svg viewBox="0 0 24 24" aria-hidden="true">
</button> <path
<div v-if="pendingInsertIndex !== null" class="editor-tip"> d="M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z" />
</svg>
</button>
<button v-for="mode in editorModes" :key="mode.id" :class="[
'editor-tool-btn',
{ active: mapEditorMode === mode.id },
]" type="button" @click="mapEditorMode = mode.id">
{{ mode.label }}
</button>
<button class="editor-tool-btn" type="button" @click="clearCanvas">
清空巡检点
</button>
</template>
<div v-if="mapEditTarget === 'mileage'" class="editor-tip">
点击空白处新增里程点,拖动已有点位可调整位置
</div>
<div v-else-if="pendingInsertIndex !== null" class="editor-tip">
在地图上点击放置新点位,将插入到{{ pendingInsertLabel }} 在地图上点击放置新点位,将插入到{{ pendingInsertLabel }}
<button class="editor-tip-cancel" type="button" @click="cancelInsertPoint">取消插入</button> <button class="editor-tip-cancel" type="button" @click="cancelInsertPoint">取消插入</button>
</div> </div>
@@ -3611,6 +3990,40 @@ watch(
</div> </div>
</div> </div>
<div class="config-side-card mileage-config-board">
<div class="panel-head inline">
<div>
<div class="panel-title">里程点配置</div>
<div class="panel-subtitle">
设备级定位标定物,独立于巡检点位与轮次,用于告警时定位故障位置
</div>
</div>
<button :class="{ primary: mapEditTarget === 'mileage' }" class="page-action-btn ghost" type="button"
@click="switchMapEditTarget('mileage')">
地图编辑
</button>
</div>
<div v-if="mapEditTarget === 'mileage'" class="mileage-pick-hint">
请在左侧地图上点击新增里程点,或拖动已有点位调整位置
</div>
<div class="mileage-list-shell">
<div v-for="mileage in currentConfig.mileages" :key="mileage.id" class="mileage-list-item">
<div class="mileage-list-main">
<div class="mileage-list-label">{{ mileage.label || "未命名里程点" }}</div>
</div>
<div class="point-list-actions">
<button type="button" @click="openMileageDialog(undefined, mileage)">编辑</button>
<button class="danger" type="button" @click="removeMileagePointItem(mileage)">删除</button>
</div>
</div>
<div v-if="!currentConfig.mileages.length" class="config-empty point-list-empty">
暂无里程点,点击“地图点选新增”后在地图上选择位置。
</div>
</div>
</div>
<div class="config-side-card dock-config-board"> <div class="config-side-card dock-config-board">
<div class="dock-config-head"> <div class="dock-config-head">
<span class="dock-config-title">充电回充配置</span> <span class="dock-config-title">充电回充配置</span>
@@ -3772,6 +4185,36 @@ watch(
</div> </div>
</div> </div>
<div v-if="mileageDialogVisible" class="plan-dialog-mask" @click.self="closeMileageDialog">
<div class="plan-dialog-card">
<div class="panel-head inline">
<div>
<div class="panel-title">里程点</div>
<div class="panel-subtitle">填写里程标识后保存,例如 a区-01</div>
</div>
<button class="dialog-close" type="button" @click="closeMileageDialog">×</button>
</div>
<div class="config-form-grid compact plan-dialog-grid">
<label class="config-field wide">
<span>里程标识</span>
<input v-model="mileageForm.label" placeholder="例如:a区-01" type="text" />
</label>
</div>
<div v-if="mileageMessage" class="map-upload-message">{{ mileageMessage }}</div>
<div class="plan-editor-actions">
<button class="page-action-btn ghost" type="button" @click="closeMileageDialog">
取消
</button>
<button class="page-action-btn primary" :disabled="mileageSaving" type="button" @click="saveMileage">
{{ mileageSaving ? "保存中..." : "保存" }}
</button>
</div>
</div>
</div>
<div v-if="saveProgressVisible" class="save-progress-mask"> <div v-if="saveProgressVisible" class="save-progress-mask">
<div class="save-progress-card"> <div class="save-progress-card">
<div class="save-progress-title">保存</div> <div class="save-progress-title">保存</div>
@@ -4142,6 +4585,31 @@ watch(
gap: 10px; 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 { .editor-tool-btn {
height: 34px; height: 34px;
padding: 0 14px; padding: 0 14px;
@@ -4757,8 +5225,8 @@ watch(
.rounds-board { .rounds-board {
padding: 16px; padding: 16px;
flex: 1 1 auto; flex: 1 1 auto;
/* 新增充电回充卡片后,轮次配置不再设为 0 基准高度,避免被压缩到看不见 */ /* 侧栏已有充电回充、里程点两张卡片,保证轮次配置的最小可用高度 */
min-height: 300px; min-height: 460px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -4922,6 +5390,50 @@ watch(
font-size: 11px; 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 { .point-drag-handle {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;

View File

@@ -243,6 +243,7 @@ interface AlertItem {
title: string; title: string;
result: string; result: string;
time: string; time: string;
mileage: string;
photo: string; photo: string;
} }
@@ -398,6 +399,7 @@ function normalizeAlertRecord(record: RosInspectionRecordLineImage, index: numbe
title, title,
result: record.warningValue || record.recognitionResult || record.failReason || "--", result: record.warningValue || record.recognitionResult || record.failReason || "--",
time: record.captureTime || record.createTime || "--", time: record.captureTime || record.createTime || "--",
mileage: record.mileageLabel || "",
photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(title), photo: buildFileAccessUrl(record.imageUrl || "") || getRecentAlertPhoto(title),
}; };
} }
@@ -1820,6 +1822,10 @@ function getStatusClass(status: DeviceItem["status"]) {
<span class="alert-label">告警时间</span> <span class="alert-label">告警时间</span>
<span class="alert-time">{{ alert.time }}</span> <span class="alert-time">{{ alert.time }}</span>
</div> </div>
<div v-if="alert.mileage" class="alert-time-row">
<span class="alert-label">里程点</span>
<span class="alert-time">{{ alert.mileage }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>