feat: 路径查看弹窗改为全屏双栏布局,新增画布预览
- 路径查看弹窗改为 95vw×90vh 大窗口 - 左侧统计+表格,右侧 Canvas 画布预览 - 画布显示扫描区域虚线框、路径扫描线箭头、背景图 - 支持从工具栏打开查看路径文件 - 子任务航线文件行新增"查看"按钮(空路径时禁用)
This commit is contained in:
@ -21,6 +21,10 @@
|
|||||||
<template #icon><n-icon><CheckmarkCircleOutline /></n-icon></template>
|
<template #icon><n-icon><CheckmarkCircleOutline /></n-icon></template>
|
||||||
校验
|
校验
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button size="small" @click="handleViewPath">
|
||||||
|
<template #icon><n-icon><MapOutline /></n-icon></template>
|
||||||
|
查看路径
|
||||||
|
</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|
||||||
@ -143,6 +147,14 @@
|
|||||||
</template>
|
</template>
|
||||||
<n-form-item label="航线文件">
|
<n-form-item label="航线文件">
|
||||||
<n-input v-model:value="sub.pathLineFilePath" placeholder="航线文件路径" />
|
<n-input v-model:value="sub.pathLineFilePath" placeholder="航线文件路径" />
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
style="margin-left: 4px"
|
||||||
|
:disabled="!sub.pathLineFilePath"
|
||||||
|
@click="viewPathFile(sub.pathLineFilePath)"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</n-button>
|
||||||
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">
|
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">
|
||||||
浏览
|
浏览
|
||||||
</n-button>
|
</n-button>
|
||||||
@ -196,6 +208,29 @@
|
|||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Path viewer modal -->
|
||||||
|
<n-modal v-model:show="showPathModal" title="路径文件查看" preset="card" style="width: 95vw; height: 90vh;">
|
||||||
|
<div v-if="pathViewRecords.length > 0" style="display: flex; gap: 12px; height: 100%;">
|
||||||
|
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0;">
|
||||||
|
<n-space style="padding: 8px; background: #f5f5f5; border-radius: 4px;">
|
||||||
|
<n-statistic label="记录条数" :value="pathViewRecords.length" />
|
||||||
|
</n-space>
|
||||||
|
<n-data-table
|
||||||
|
:columns="pathColumns"
|
||||||
|
:data="pathViewRecords"
|
||||||
|
size="small"
|
||||||
|
:max-height="9999"
|
||||||
|
virtual-scroll
|
||||||
|
style="flex: 1;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div ref="pathPreviewContainer" style="flex: 2; height: 100%; background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px;">
|
||||||
|
<canvas ref="pathPreviewCanvas" style="width: 100%; height: 100%;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else description="请选择路径文件" />
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
<!-- SubTask type selector modal -->
|
<!-- SubTask type selector modal -->
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showTypeModal"
|
v-model:show="showTypeModal"
|
||||||
@ -223,18 +258,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useMessage, useDialog } from 'naive-ui';
|
import { useMessage, useDialog } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
ArrowBackOutline, SaveOutline, CalculatorOutline, CheckmarkCircleOutline,
|
ArrowBackOutline, SaveOutline, CalculatorOutline, CheckmarkCircleOutline, MapOutline,
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useMissionStore } from '../stores/mission';
|
import { useMissionStore } from '../stores/mission';
|
||||||
import { useValidationStore } from '../stores/validation';
|
import { useValidationStore } from '../stores/validation';
|
||||||
import { SUBTASK_TYPE_LABELS } from '../utils/constants';
|
import { SUBTASK_TYPE_LABELS } from '../utils/constants';
|
||||||
import { formatDuration, formatDateTime } from '../utils/constants';
|
import { formatDuration, formatDateTime } from '../utils/constants';
|
||||||
import { isHyperspectral, SubTaskType } from '../types/task';
|
import { isHyperspectral, SubTaskType } from '../types/task';
|
||||||
import type { SubTask } from '../types/task';
|
import type { SubTask } from '../types/task';
|
||||||
|
import type { PathLineRecord } from '../types/path-line';
|
||||||
|
import type { DataTableColumn } from 'naive-ui';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
@ -265,6 +303,20 @@ function getSubTaskLabel(type: string): string {
|
|||||||
return SUBTASK_TYPE_LABELS[type] || type;
|
return SUBTASK_TYPE_LABELS[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showPathModal = ref(false);
|
||||||
|
const pathViewRecords = ref<PathLineRecord[]>([]);
|
||||||
|
const pathPreviewContainer = ref<HTMLDivElement>();
|
||||||
|
const pathPreviewCanvas = ref<HTMLCanvasElement>();
|
||||||
|
const pathColumns: DataTableColumn<PathLineRecord>[] = [
|
||||||
|
{ title: 'Y 位置', key: 'targetYPosition', render: r => r.targetYPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: 'Y 速度', key: 'speedTargetYPosition', render: r => r.speedTargetYPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: 'X 起始', key: 'targetXMinPosition', render: r => r.targetXMinPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: 'X 起始速度', key: 'speedTargetXMinPosition', render: r => r.speedTargetXMinPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: 'X 结束', key: 'targetXMaxPosition', render: r => r.targetXMaxPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: 'X 扫描速度', key: 'speedTargetXMaxPosition', render: r => r.speedTargetXMaxPosition.toFixed(2), width: 80 },
|
||||||
|
{ title: '#', key: 'index', render: (_: any, i: number) => i + 1, width: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
const DEFAULT_TIME = '2000-01-01T00:00:00';
|
const DEFAULT_TIME = '2000-01-01T00:00:00';
|
||||||
function taskTitle(task: { id: number; scheduledTime: string }): string {
|
function taskTitle(task: { id: number; scheduledTime: string }): string {
|
||||||
if (!task.scheduledTime || task.scheduledTime === DEFAULT_TIME) {
|
if (!task.scheduledTime || task.scheduledTime === DEFAULT_TIME) {
|
||||||
@ -390,6 +442,146 @@ async function browsePathLine(sub: SubTask) {
|
|||||||
function goToPlanner(taskId: number, sub: SubTask) {
|
function goToPlanner(taskId: number, sub: SubTask) {
|
||||||
router.push({ path: '/planner', query: { taskId: String(taskId), subTaskId: sub.id } });
|
router.push({ path: '/planner', query: { taskId: String(taskId), subTaskId: sub.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openPathFile(path: string) {
|
||||||
|
try {
|
||||||
|
const file = await invoke('load_path_line', { path });
|
||||||
|
pathViewRecords.value = (file as any).records || [];
|
||||||
|
showPathModal.value = true;
|
||||||
|
await nextTick();
|
||||||
|
drawPathPreview();
|
||||||
|
} catch (e) {
|
||||||
|
message.error('无法打开路径文件: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawPathPreview() {
|
||||||
|
const canvas = pathPreviewCanvas.value;
|
||||||
|
const container = pathPreviewContainer.value;
|
||||||
|
if (!canvas || !container) return;
|
||||||
|
const records = pathViewRecords.value;
|
||||||
|
if (!records.length) return;
|
||||||
|
|
||||||
|
const w = container.clientWidth;
|
||||||
|
const h = container.clientHeight;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f8f8f8';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Find bounds from records
|
||||||
|
let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
|
||||||
|
for (const r of records) {
|
||||||
|
xMin = Math.min(xMin, r.targetXMinPosition, r.targetXMaxPosition);
|
||||||
|
xMax = Math.max(xMax, r.targetXMinPosition, r.targetXMaxPosition);
|
||||||
|
yMin = Math.min(yMin, r.targetYPosition);
|
||||||
|
yMax = Math.max(yMax, r.targetYPosition);
|
||||||
|
}
|
||||||
|
const rangeX = xMax - xMin || 1;
|
||||||
|
const rangeY = yMax - yMin || 1;
|
||||||
|
const pad = 30;
|
||||||
|
const drawW = w - pad * 2;
|
||||||
|
const drawH = h - pad * 2;
|
||||||
|
|
||||||
|
function toCanvas(wx: number, wy: number) {
|
||||||
|
return {
|
||||||
|
x: pad + ((wx - xMin) / rangeX) * drawW,
|
||||||
|
y: pad + ((yMax - wy) / rangeY) * drawH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background image
|
||||||
|
const bgPath = missionStore.mission.backgroundImage;
|
||||||
|
if (bgPath) {
|
||||||
|
try {
|
||||||
|
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.globalAlpha = 0.4;
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
|
||||||
|
};
|
||||||
|
img.onerror = () => drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
|
||||||
|
img.src = convertFileSrc(bgPath);
|
||||||
|
return; // drawOverlay will be called async
|
||||||
|
} catch { /* fall through to drawOverlay */ }
|
||||||
|
}
|
||||||
|
drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay(
|
||||||
|
ctx: CanvasRenderingContext2D, _w: number, _h: number, pad: number,
|
||||||
|
drawW: number, drawH: number, xMin: number, xMax: number, yMin: number, yMax: number,
|
||||||
|
_rangeX: number, _rangeY: number,
|
||||||
|
toCanvas: (wx: number, wy: number) => { x: number; y: number },
|
||||||
|
records: PathLineRecord[],
|
||||||
|
) {
|
||||||
|
// Grid
|
||||||
|
ctx.strokeStyle = '#e0e0e0';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 5; i++) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(pad + (i / 5) * drawW, pad); ctx.lineTo(pad + (i / 5) * drawW, pad + drawH); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(pad, pad + (i / 5) * drawH); ctx.lineTo(pad + drawW, pad + (i / 5) * drawH); ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan area rectangle
|
||||||
|
const sc = missionStore.mission.scanConfig;
|
||||||
|
if (sc) {
|
||||||
|
const r1 = toCanvas(sc.xMin, sc.yMin);
|
||||||
|
const r2 = toCanvas(sc.xMax, sc.yMax);
|
||||||
|
ctx.strokeStyle = '#f0a020'; ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([6, 4]);
|
||||||
|
ctx.strokeRect(r1.x, r1.y, r2.x - r1.x, r2.y - r1.y);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = '#f0a020'; ctx.font = '11px sans-serif';
|
||||||
|
ctx.fillText('扫描区域', r1.x + 4, r1.y - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan lines
|
||||||
|
for (const record of records) {
|
||||||
|
const start = toCanvas(record.targetXMinPosition, record.targetYPosition);
|
||||||
|
const end = toCanvas(record.targetXMaxPosition, record.targetYPosition);
|
||||||
|
ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y);
|
||||||
|
ctx.strokeStyle = '#2080f0'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||||
|
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(end.x, end.y);
|
||||||
|
ctx.lineTo(end.x - 6 * Math.cos(angle - 0.4), end.y - 6 * Math.sin(angle - 0.4));
|
||||||
|
ctx.lineTo(end.x - 6 * Math.cos(angle + 0.4), end.y - 6 * Math.sin(angle + 0.4));
|
||||||
|
ctx.closePath(); ctx.fillStyle = '#2080f0'; ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
ctx.fillStyle = '#999'; ctx.font = '10px sans-serif';
|
||||||
|
ctx.fillText(xMin.toFixed(0), pad - 2, pad + drawH + 14);
|
||||||
|
ctx.fillText(xMax.toFixed(0), pad + drawW - 10, pad + drawH + 14);
|
||||||
|
ctx.fillText(yMin.toFixed(0), 2, pad + drawH + 4);
|
||||||
|
ctx.fillText(yMax.toFixed(0), 2, pad + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewPath() {
|
||||||
|
try {
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const selected = await open({
|
||||||
|
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
if (selected) await openPathFile(selected);
|
||||||
|
} catch { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewPathFile(filePath: string) {
|
||||||
|
if (!filePath) return;
|
||||||
|
await openPathFile(filePath);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user