feat: 路径查看弹窗改为全屏双栏布局,新增画布预览

- 路径查看弹窗改为 95vw×90vh 大窗口
- 左侧统计+表格,右侧 Canvas 画布预览
- 画布显示扫描区域虚线框、路径扫描线箭头、背景图
- 支持从工具栏打开查看路径文件
- 子任务航线文件行新增"查看"按钮(空路径时禁用)
This commit is contained in:
xin
2026-06-18 10:52:24 +08:00
parent 80b641258b
commit f2ecf7b5f9

View File

@ -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>