Initial commit: Happa Mission Plan v0.1.0
Task planning and path generation software for automated data collection equipment (hyperspectral cameras, DSLR, depth cameras). - Generate mission plans (JSON) with subtasks per device - Generate scan path binary files (.RecordLine3) - Multi-rectangle area planning with reference map support - Device-specific FOV defaults (Pika L 17.6°, Pika NIR 21.7°, etc.) - Timeline scheduling with constraint validation - Tauri 2.x + Vue 3 + Naive UI + Pinia
This commit is contained in:
32
src/App.vue
Normal file
32
src/App.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<n-loading-bar-provider>
|
||||
<n-notification-provider>
|
||||
<router-view />
|
||||
</n-notification-provider>
|
||||
</n-loading-bar-provider>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'F12') {
|
||||
e.preventDefault();
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||||
invoke('toggle_devtools').catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
210
src/components/planner/ScanCanvas.vue
Normal file
210
src/components/planner/ScanCanvas.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div ref="canvasContainer" class="scan-canvas">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
/>
|
||||
<div class="canvas-info" v-if="regions.length > 0">
|
||||
{{ regions.length }} 个框 | 点击「画框」开始绘制新框
|
||||
</div>
|
||||
<div class="canvas-info" v-else>
|
||||
点击「画框」在画布上绘制扫描区域
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import type { ScanRegion, ScanMode } from '../../types/path-plan';
|
||||
import type { PathLineRecord } from '../../types/path-line';
|
||||
|
||||
const props = defineProps<{
|
||||
regions: ScanRegion[];
|
||||
records: PathLineRecord[];
|
||||
mode: ScanMode;
|
||||
backgroundImage?: string;
|
||||
drawingMode: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-region': [region: ScanRegion];
|
||||
}>();
|
||||
|
||||
const canvasContainer = ref<HTMLDivElement>();
|
||||
const canvas = ref<HTMLCanvasElement>();
|
||||
const isDrawing = ref(false);
|
||||
const dragStart = ref({ x: 0, y: 0 });
|
||||
const drawingRect = ref<ScanRegion | null>(null);
|
||||
const bgImage = ref<HTMLImageElement | null>(null);
|
||||
const padding = 40;
|
||||
|
||||
function getCanvasSize() {
|
||||
const rect = canvasContainer.value?.getBoundingClientRect();
|
||||
return { w: rect?.width || 600, h: rect?.height || 400 };
|
||||
}
|
||||
|
||||
function toCanvas(clientX: number, clientY: number) {
|
||||
const rect = canvas.value!.getBoundingClientRect();
|
||||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||
}
|
||||
|
||||
function toWorld(cx: number, cy: number) {
|
||||
const { w, h } = getCanvasSize();
|
||||
const drawW = w - padding * 2;
|
||||
const drawH = h - padding * 2;
|
||||
const worldX = (cx - padding) / drawW * 100;
|
||||
const worldY = (cy - padding) / drawH * 100;
|
||||
return { x: Math.max(0, Math.min(100, worldX)), y: Math.max(0, Math.min(100, 100 - worldY)) };
|
||||
}
|
||||
|
||||
async function loadBackgroundImage(path: string | undefined) {
|
||||
if (!path) { bgImage.value = null; draw(); return; }
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||
const assetUrl = convertFileSrc(path);
|
||||
const img = new Image();
|
||||
img.onload = () => { bgImage.value = img; draw(); };
|
||||
img.onerror = () => { bgImage.value = null; draw(); };
|
||||
img.src = assetUrl;
|
||||
} catch { bgImage.value = null; draw(); }
|
||||
}
|
||||
|
||||
function worldToCanvas(worldX: number, worldY: number) {
|
||||
const { w, h } = getCanvasSize();
|
||||
const drawW = w - padding * 2;
|
||||
const drawH = h - padding * 2;
|
||||
const x = padding + (worldX / 100) * drawW;
|
||||
const y = padding + ((100 - worldY) / 100) * drawH;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const COLORS = ['#2080f0', '#f0a020', '#18a058', '#d03050', '#a060e0', '#e06080'];
|
||||
|
||||
function draw() {
|
||||
const ctx = canvas.value?.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { w, h } = getCanvasSize();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.value!.width = w * dpr;
|
||||
canvas.value!.height = h * dpr;
|
||||
canvas.value!.style.width = w + 'px';
|
||||
canvas.value!.style.height = h + 'px';
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.fillStyle = '#f8f8f8';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (bgImage.value) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.drawImage(bgImage.value, 0, 0, w, h);
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
const drawW = w - padding * 2;
|
||||
const drawH = h - padding * 2;
|
||||
|
||||
// Grid
|
||||
ctx.strokeStyle = '#e8e8e8';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const x = padding + (i / 10) * drawW;
|
||||
const y = padding + (i / 10) * drawH;
|
||||
ctx.beginPath(); ctx.moveTo(x, padding); ctx.lineTo(x, padding + drawH); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(padding, y); ctx.lineTo(padding + drawW, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw all rectangles
|
||||
for (let i = 0; i < props.regions.length; i++) {
|
||||
const r = props.regions[i];
|
||||
const p1 = worldToCanvas(r.xMin, r.yMin);
|
||||
const p2 = worldToCanvas(r.xMax, r.yMax);
|
||||
ctx.strokeStyle = COLORS[i % COLORS.length];
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
|
||||
// Label
|
||||
ctx.fillStyle = COLORS[i % COLORS.length];
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.fillText(`#${i + 1}`, p1.x + 4, p1.y - 4);
|
||||
}
|
||||
|
||||
// Draw current drawing rect
|
||||
if (drawingRect.value) {
|
||||
const p1 = worldToCanvas(drawingRect.value.xMin, drawingRect.value.yMin);
|
||||
const p2 = worldToCanvas(drawingRect.value.xMax, drawingRect.value.yMax);
|
||||
ctx.strokeStyle = '#f00';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Draw scan paths
|
||||
if (props.records.length > 0) {
|
||||
for (const record of props.records) {
|
||||
const start = worldToCanvas(record.targetXMinPosition, record.targetYPosition);
|
||||
const end = worldToCanvas(record.targetXMaxPosition, record.targetYPosition);
|
||||
ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y);
|
||||
ctx.strokeStyle = '#18a058'; 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 - 8 * Math.cos(angle - 0.4), end.y - 8 * Math.sin(angle - 0.4));
|
||||
ctx.lineTo(end.x - 8 * Math.cos(angle + 0.4), end.y - 8 * Math.sin(angle + 0.4));
|
||||
ctx.closePath(); ctx.fillStyle = '#18a058'; ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (!props.drawingMode) return;
|
||||
isDrawing.value = true;
|
||||
dragStart.value = toCanvas(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDrawing.value || !props.drawingMode) return;
|
||||
const p = toCanvas(e.clientX, e.clientY);
|
||||
const p1 = toWorld(dragStart.value.x, dragStart.value.y);
|
||||
const p2 = toWorld(p.x, p.y);
|
||||
drawingRect.value = {
|
||||
xMin: Math.min(p1.x, p2.x), xMax: Math.max(p1.x, p2.x),
|
||||
yMin: Math.min(p1.y, p2.y), yMax: Math.max(p1.y, p2.y),
|
||||
};
|
||||
draw();
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (!isDrawing.value) return;
|
||||
isDrawing.value = false;
|
||||
if (drawingRect.value) {
|
||||
const r = drawingRect.value;
|
||||
if (r.xMax - r.xMin > 1 && r.yMax - r.yMin > 1) {
|
||||
emit('add-region', r);
|
||||
}
|
||||
drawingRect.value = null;
|
||||
draw();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBackgroundImage(props.backgroundImage);
|
||||
window.addEventListener('resize', draw);
|
||||
});
|
||||
|
||||
watch(() => [props.regions, props.records, props.mode, props.drawingMode], () => { draw(); });
|
||||
watch(() => props.backgroundImage, (v) => { loadBackgroundImage(v); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scan-canvas { position: relative; width: 100%; height: 100%; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
.canvas-info {
|
||||
position: absolute; bottom: 8px; left: 8px;
|
||||
background: rgba(0,0,0,0.6); color: white;
|
||||
padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
93
src/components/planner/ScanParamPanel.vue
Normal file
93
src/components/planner/ScanParamPanel.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="param-panel">
|
||||
<n-form size="small" label-placement="top" label-width="auto">
|
||||
<n-card title="相机参数" size="small">
|
||||
<n-grid :cols="2" :x-gap="8">
|
||||
<n-gi><n-form-item label="FOV (°)"><n-input-number v-model:value="localCamera.fovDegrees" :min="1" :max="180" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="高度 (cm)"><n-input-number v-model:value="localCamera.heightCm" :min="1" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="覆盖率 (%)">
|
||||
<n-slider v-model:value="localCoverageRate" :min="0" :max="100" :step="1" />
|
||||
<n-text style="margin-left: 8px">{{ localCoverageRate }}%</n-text>
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-card title="速度参数" size="small">
|
||||
<n-form-item label="Y 轴定位速度 (cm/s)">
|
||||
<n-input-number v-model:value="localSpeedY" :min="0.1" :step="0.5" />
|
||||
</n-form-item>
|
||||
<n-form-item label="X 扫描速度 (cm/s)">
|
||||
<n-input-number v-model:value="localSpeedXScan" :min="0.1" :step="0.5" />
|
||||
</n-form-item>
|
||||
<n-form-item label="X 回起点速度 (cm/s)">
|
||||
<n-input-number v-model:value="localSpeedXStart" :min="0.1" :step="0.5" />
|
||||
</n-form-item>
|
||||
</n-card>
|
||||
|
||||
<n-card title="扫描模式" size="small">
|
||||
<n-radio-group v-model:value="localMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="Zigzag">蛇形来回 (Zigzag)</n-radio>
|
||||
<n-radio value="OneWay">单向回起点 (OneWay)</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-card>
|
||||
|
||||
<n-card title="计算结果" size="small">
|
||||
<n-descriptions label-placement="left" :column="1">
|
||||
<n-descriptions-item label="扫描线数">{{ lineCount }}</n-descriptions-item>
|
||||
<n-descriptions-item label="预估耗时">{{ estTimeFormatted }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</n-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import type { CameraParams, ScanMode } from '../../types/path-plan';
|
||||
import { formatDuration } from '../../utils/constants';
|
||||
|
||||
const props = defineProps<{
|
||||
camera: CameraParams;
|
||||
coverageRate: number;
|
||||
speedY: number;
|
||||
speedXScan: number;
|
||||
speedXStart: number;
|
||||
mode: ScanMode;
|
||||
lineCount: number;
|
||||
estTime: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [params: any];
|
||||
}>();
|
||||
|
||||
const localCamera = ref({ ...props.camera });
|
||||
const localCoverageRate = ref(props.coverageRate);
|
||||
const localSpeedY = ref(props.speedY);
|
||||
const localSpeedXScan = ref(props.speedXScan);
|
||||
const localSpeedXStart = ref(props.speedXStart);
|
||||
const localMode = ref(props.mode);
|
||||
|
||||
const estTimeFormatted = computed(() => formatDuration(props.estTime));
|
||||
|
||||
watch([localCamera, localCoverageRate, localSpeedY, localSpeedXScan, localSpeedXStart, localMode], () => {
|
||||
emit('update', {
|
||||
camera: localCamera.value,
|
||||
coverageRate: localCoverageRate.value,
|
||||
speedY: localSpeedY.value,
|
||||
speedXScan: localSpeedXScan.value,
|
||||
speedXStart: localSpeedXStart.value,
|
||||
mode: localMode.value,
|
||||
});
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.param-panel {
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
142
src/components/timeline/TimelineGantt.vue
Normal file
142
src/components/timeline/TimelineGantt.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="timeline-gantt">
|
||||
<n-empty v-if="!tasks.length" description="尚无任务,在左侧添加" style="margin-top: 60px" />
|
||||
<div v-else class="gantt-container">
|
||||
<div class="gantt-header">
|
||||
<span v-for="tick in timeTicks" :key="tick" class="tick" :style="{ left: tickPosition(tick) + '%' }">
|
||||
{{ formatTick(tick) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="gantt-body">
|
||||
<div v-for="task in tasks" :key="task.id" class="task-row">
|
||||
<div class="task-label">Task #{{ task.id }}</div>
|
||||
<div class="task-bars">
|
||||
<div
|
||||
v-for="sub in task.subTasks"
|
||||
:key="sub.id"
|
||||
class="subtask-bar"
|
||||
:style="barStyle(sub)"
|
||||
:title="barTooltip(sub)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useMissionStore } from '../../stores/mission';
|
||||
import { SUBTASK_TYPE_COLORS, formatDuration } from '../../utils/constants';
|
||||
|
||||
const missionStore = useMissionStore();
|
||||
const tasks = computed(() => missionStore.mission.tasks);
|
||||
|
||||
// Find global time range
|
||||
const timeRange = computed(() => {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const task of tasks.value) {
|
||||
for (const sub of task.subTasks) {
|
||||
if (sub.startTime) {
|
||||
const t = new Date(sub.startTime).getTime();
|
||||
if (t < min) min = t;
|
||||
}
|
||||
if (sub.endTime) {
|
||||
const t = new Date(sub.endTime).getTime();
|
||||
if (t > max) max = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isFinite(min) || !isFinite(max)) return null;
|
||||
return { min, max, duration: max - min };
|
||||
});
|
||||
|
||||
const timeTicks = computed(() => {
|
||||
if (!timeRange.value || timeRange.value.duration <= 0) return [];
|
||||
const count = 10;
|
||||
return Array.from({ length: count + 1 }, (_, i) => i / count);
|
||||
});
|
||||
|
||||
function tickPosition(fraction: number): number {
|
||||
return fraction * 100;
|
||||
}
|
||||
|
||||
function formatTick(fraction: number): string {
|
||||
if (!timeRange.value) return '';
|
||||
const time = timeRange.value.min + fraction * timeRange.value.duration;
|
||||
const d = new Date(time);
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function barStyle(sub: any) {
|
||||
if (!sub.startTime || !sub.endTime || !timeRange.value || timeRange.value.duration <= 0) {
|
||||
return { display: 'none' };
|
||||
}
|
||||
const start = new Date(sub.startTime).getTime();
|
||||
const end = new Date(sub.endTime).getTime();
|
||||
const left = ((start - timeRange.value.min) / timeRange.value.duration) * 100;
|
||||
const width = ((end - start) / timeRange.value.duration) * 100;
|
||||
const color = SUBTASK_TYPE_COLORS[sub.type] || '#888';
|
||||
return {
|
||||
left: left + '%',
|
||||
width: Math.max(width, 0.5) + '%',
|
||||
backgroundColor: color,
|
||||
};
|
||||
}
|
||||
|
||||
function barTooltip(sub: any): string {
|
||||
const times = sub.startTime && sub.endTime
|
||||
? `${sub.startTime} → ${sub.endTime}`
|
||||
: '未计算';
|
||||
return `${sub.type}\n${times}\n${formatDuration(sub.estimatedDurationMinutes)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timeline-gantt {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
.gantt-header {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.tick {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.task-label {
|
||||
width: 70px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-bars {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
.subtask-bar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
min-width: 2px;
|
||||
}
|
||||
.subtask-bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
46
src/components/validation/ValidationPanel.vue
Normal file
46
src/components/validation/ValidationPanel.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="validation-panel">
|
||||
<n-space style="padding: 8px" align="center" justify="space-between">
|
||||
<n-text strong>校验结果</n-text>
|
||||
<n-space>
|
||||
<n-tag v-if="errors > 0" type="error" size="small">{{ errors }} 错误</n-tag>
|
||||
<n-tag v-if="warnings > 0" type="warning" size="small">{{ warnings }} 警告</n-tag>
|
||||
<n-tag v-if="!hasIssues" type="success" size="small">通过</n-tag>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<n-empty v-if="!hasIssues" description="暂无问题" style="margin-top: 40px" />
|
||||
|
||||
<div v-for="(issue, i) in issues" :key="i" class="issue-item">
|
||||
<n-alert
|
||||
:type="issue.severity === 'Error' ? 'error' : 'warning'"
|
||||
:title="issue.rule"
|
||||
closable
|
||||
style="margin: 4px 8px"
|
||||
>
|
||||
{{ issue.message }}
|
||||
</n-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useValidationStore } from '../../stores/validation';
|
||||
|
||||
const validationStore = useValidationStore();
|
||||
const issues = computed(() => validationStore.issues);
|
||||
const errors = computed(() => validationStore.errorCount);
|
||||
const warnings = computed(() => validationStore.warningCount);
|
||||
const hasIssues = computed(() => validationStore.hasIssues);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.validation-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.issue-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import NaiveUI from 'naive-ui';
|
||||
import router from './router';
|
||||
import App from './App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(NaiveUI);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
19
src/router/index.ts
Normal file
19
src/router/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import MissionEditorView from '../views/MissionEditorView.vue';
|
||||
import PathPlannerView from '../views/PathPlannerView.vue';
|
||||
import PathLineViewer from '../views/PathLineViewer.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/editor', name: 'mission-editor', component: MissionEditorView },
|
||||
{ path: '/planner', name: 'path-planner', component: PathPlannerView },
|
||||
{ path: '/pathline', name: 'pathline-viewer', component: PathLineViewer },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
161
src/stores/mission.ts
Normal file
161
src/stores/mission.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { MissionPlan, MissionScanConfig } from '../types/mission';
|
||||
import { createEmptyMission } from '../types/mission';
|
||||
import type { Task, SubTask, SubTaskType } from '../types/task';
|
||||
import type { ValidationIssue } from '../types/validation';
|
||||
|
||||
export const useMissionStore = defineStore('mission', () => {
|
||||
const mission = ref<MissionPlan>(createEmptyMission());
|
||||
const currentFilePath = ref<string | null>(null);
|
||||
const isDirty = ref(false);
|
||||
|
||||
const taskCount = computed(() => mission.value.tasks.length);
|
||||
|
||||
// Auto-persist scan config and background when they change
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(
|
||||
() => ({ sc: mission.value.scanConfig, bg: mission.value.backgroundImage }),
|
||||
() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
invoke('save_default_scan_config', { config: mission.value.scanConfig }).catch(() => {});
|
||||
invoke('save_default_background', { path: mission.value.backgroundImage || '' }).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
async function createNew() {
|
||||
mission.value = createEmptyMission();
|
||||
// Load persisted defaults
|
||||
try {
|
||||
const defaults = await invoke<MissionScanConfig>('load_default_scan_config');
|
||||
mission.value.scanConfig = defaults;
|
||||
} catch {
|
||||
// use defaults
|
||||
}
|
||||
try {
|
||||
const bg = await invoke<string | null>('load_default_background');
|
||||
mission.value.backgroundImage = bg;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
currentFilePath.value = null;
|
||||
isDirty.value = false;
|
||||
}
|
||||
|
||||
async function loadMission(path: string) {
|
||||
const result = await invoke<MissionPlan>('load_mission', { path });
|
||||
mission.value = result;
|
||||
currentFilePath.value = path;
|
||||
isDirty.value = false;
|
||||
}
|
||||
|
||||
async function saveMission(path?: string) {
|
||||
const savePath = path || currentFilePath.value;
|
||||
if (!savePath) throw new Error('No save path specified');
|
||||
await invoke('save_mission', { path: savePath, mission: mission.value });
|
||||
currentFilePath.value = savePath;
|
||||
isDirty.value = false;
|
||||
}
|
||||
|
||||
async function addTask() {
|
||||
const result = await invoke<MissionPlan>('add_task', { mission: mission.value });
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function copyTask(taskId: number) {
|
||||
const result = await invoke<MissionPlan>('copy_task', { mission: mission.value, taskId });
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function removeTask(taskId: number) {
|
||||
const result = await invoke<MissionPlan>('remove_task', { mission: mission.value, taskId });
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
|
||||
const result = await invoke<MissionPlan>('add_sub_task', {
|
||||
mission: mission.value,
|
||||
taskId,
|
||||
subTaskType,
|
||||
});
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function removeSubTask(taskId: number, subTaskId: string) {
|
||||
const result = await invoke<MissionPlan>('remove_sub_task', {
|
||||
mission: mission.value,
|
||||
taskId,
|
||||
subTaskId,
|
||||
});
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function updateSubTask(taskId: number, subTask: SubTask) {
|
||||
const result = await invoke<MissionPlan>('update_sub_task', {
|
||||
mission: mission.value,
|
||||
taskId,
|
||||
subTask,
|
||||
});
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function updateTask(task: Task) {
|
||||
const result = await invoke<MissionPlan>('update_task', {
|
||||
mission: mission.value,
|
||||
task,
|
||||
});
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function calculateSchedule() {
|
||||
const result = await invoke<MissionPlan>('calculate_schedule', { mission: mission.value });
|
||||
mission.value = result;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
async function runValidation(): Promise<ValidationIssue[]> {
|
||||
return await invoke<ValidationIssue[]>('validate_mission', { mission: mission.value });
|
||||
}
|
||||
|
||||
function updateScanConfig(config: Partial<MissionScanConfig>) {
|
||||
mission.value.scanConfig = { ...mission.value.scanConfig, ...config };
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
function setBackgroundImage(path: string | null) {
|
||||
mission.value.backgroundImage = path;
|
||||
isDirty.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
mission,
|
||||
currentFilePath,
|
||||
isDirty,
|
||||
taskCount,
|
||||
createNew,
|
||||
loadMission,
|
||||
saveMission,
|
||||
addTask,
|
||||
copyTask,
|
||||
removeTask,
|
||||
addSubTask,
|
||||
removeSubTask,
|
||||
updateSubTask,
|
||||
updateTask,
|
||||
calculateSchedule,
|
||||
runValidation,
|
||||
updateScanConfig,
|
||||
setBackgroundImage,
|
||||
};
|
||||
});
|
||||
46
src/stores/ui.ts
Normal file
46
src/stores/ui.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export interface UiState {
|
||||
sidebarCollapsed: boolean;
|
||||
showValidationPanel: boolean;
|
||||
globalLoading: boolean;
|
||||
globalError: string | null;
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const sidebarCollapsed = ref(false);
|
||||
const showValidationPanel = ref(true);
|
||||
const globalLoading = ref(false);
|
||||
const globalError = ref<string | null>(null);
|
||||
|
||||
const hasError = computed(() => globalError.value !== null);
|
||||
|
||||
function showError(msg: string) {
|
||||
globalError.value = msg;
|
||||
}
|
||||
|
||||
function dismissError() {
|
||||
globalError.value = null;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function toggleValidationPanel() {
|
||||
showValidationPanel.value = !showValidationPanel.value;
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
showValidationPanel,
|
||||
globalLoading,
|
||||
globalError,
|
||||
hasError,
|
||||
showError,
|
||||
dismissError,
|
||||
toggleSidebar,
|
||||
toggleValidationPanel,
|
||||
};
|
||||
});
|
||||
28
src/stores/validation.ts
Normal file
28
src/stores/validation.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { ValidationIssue } from '../types/validation';
|
||||
|
||||
export const useValidationStore = defineStore('validation', () => {
|
||||
const issues = ref<ValidationIssue[]>([]);
|
||||
|
||||
const errorCount = computed(() => issues.value.filter(i => i.severity === 'Error').length);
|
||||
const warningCount = computed(() => issues.value.filter(i => i.severity === 'Warning').length);
|
||||
const hasIssues = computed(() => issues.value.length > 0);
|
||||
|
||||
function setIssues(newIssues: ValidationIssue[]) {
|
||||
issues.value = newIssues;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
issues.value = [];
|
||||
}
|
||||
|
||||
return {
|
||||
issues,
|
||||
errorCount,
|
||||
warningCount,
|
||||
hasIssues,
|
||||
setIssues,
|
||||
clear,
|
||||
};
|
||||
});
|
||||
49
src/types/mission.ts
Normal file
49
src/types/mission.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { Task } from './task';
|
||||
|
||||
export interface MissionScanConfig {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
export interface PlannerDefaults {
|
||||
fovDegrees: number;
|
||||
heightCm: number;
|
||||
coverageRate: number;
|
||||
speedYCmS: number;
|
||||
speedXStartCmS: number;
|
||||
speedXScanCmS: number;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export interface MissionPlan {
|
||||
version: string;
|
||||
taskCount: number;
|
||||
tasks: Task[];
|
||||
scanConfig: MissionScanConfig;
|
||||
backgroundImage?: string | null;
|
||||
}
|
||||
|
||||
export function createDefaultScanConfig(): MissionScanConfig {
|
||||
return { xMin: 0, xMax: 100, yMin: 0, yMax: 100 };
|
||||
}
|
||||
|
||||
export function createDefaultPlannerParams(): PlannerDefaults {
|
||||
return {
|
||||
fovDegrees: 30, heightCm: 50,
|
||||
coverageRate: 30,
|
||||
speedYCmS: 5, speedXStartCmS: 20, speedXScanCmS: 10,
|
||||
mode: 'Zigzag',
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMission(): MissionPlan {
|
||||
return {
|
||||
version: '1.0',
|
||||
taskCount: 0,
|
||||
tasks: [],
|
||||
scanConfig: createDefaultScanConfig(),
|
||||
backgroundImage: null,
|
||||
};
|
||||
}
|
||||
13
src/types/path-line.ts
Normal file
13
src/types/path-line.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface PathLineRecord {
|
||||
targetYPosition: number;
|
||||
speedTargetYPosition: number;
|
||||
targetXMinPosition: number;
|
||||
speedTargetXMinPosition: number;
|
||||
targetXMaxPosition: number;
|
||||
speedTargetXMaxPosition: number;
|
||||
}
|
||||
|
||||
export interface PathLineFile {
|
||||
count: number;
|
||||
records: PathLineRecord[];
|
||||
}
|
||||
23
src/types/path-plan.ts
Normal file
23
src/types/path-plan.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export interface ScanRegion {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
export interface CameraParams {
|
||||
fovDegrees: number;
|
||||
heightCm: number;
|
||||
}
|
||||
|
||||
export type ScanMode = 'Zigzag' | 'OneWay';
|
||||
|
||||
export interface ScanParams {
|
||||
region: ScanRegion;
|
||||
camera: CameraParams;
|
||||
coverageRate: number;
|
||||
speedYCmS: number;
|
||||
speedXStartCmS: number;
|
||||
speedXScanCmS: number;
|
||||
mode: ScanMode;
|
||||
}
|
||||
42
src/types/task.ts
Normal file
42
src/types/task.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export enum SubTaskType {
|
||||
HyperSpectual400_1000nm = 'HyperSpectual400_1000nm',
|
||||
HyperSpectual1000_1700nm = 'HyperSpectual1000_1700nm',
|
||||
SingleLensReflex = 'SingleLensReflex',
|
||||
DepthCamera = 'DepthCamera',
|
||||
}
|
||||
|
||||
export function isHyperspectral(type: SubTaskType): boolean {
|
||||
return type === SubTaskType.HyperSpectual400_1000nm || type === SubTaskType.HyperSpectual1000_1700nm;
|
||||
}
|
||||
|
||||
export function isCameraType(type: SubTaskType): boolean {
|
||||
return type === SubTaskType.SingleLensReflex || type === SubTaskType.DepthCamera;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
id: string;
|
||||
type: SubTaskType;
|
||||
captureIntervalSeconds: number | null;
|
||||
defaultRenderBand: number | null;
|
||||
exposureTime: number | null;
|
||||
frameRate: number | null;
|
||||
pathLineFilePath: string;
|
||||
durationMinutes: number;
|
||||
estimatedDurationMinutes: number;
|
||||
endTime: string | null;
|
||||
startTime: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
savePath: string;
|
||||
scheduledTime: string;
|
||||
HalogenLampPreheatingTime_Minute: number;
|
||||
durationMinutes: number;
|
||||
estimatedDurationMinutes: number;
|
||||
endTime: string | null;
|
||||
startTime: string | null;
|
||||
status: string;
|
||||
subTasks: SubTask[];
|
||||
}
|
||||
10
src/types/validation.ts
Normal file
10
src/types/validation.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type ValidationSeverity = 'Error' | 'Warning';
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: ValidationSeverity;
|
||||
rule: string;
|
||||
message: string;
|
||||
taskId: number | null;
|
||||
subTaskId: string | null;
|
||||
fieldPath: string | null;
|
||||
}
|
||||
47
src/utils/constants.ts
Normal file
47
src/utils/constants.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export const SUBTASK_TYPE_LABELS: Record<string, string> = {
|
||||
HyperSpectual400_1000nm: 'Pika L',
|
||||
HyperSpectual1000_1700nm: 'Pika NIR',
|
||||
SingleLensReflex: '单反相机',
|
||||
DepthCamera: '深度相机',
|
||||
};
|
||||
|
||||
export const SUBTASK_TYPE_COLORS: Record<string, string> = {
|
||||
HyperSpectual400_1000nm: '#18a058',
|
||||
HyperSpectual1000_1700nm: '#2080f0',
|
||||
SingleLensReflex: '#f0a020',
|
||||
DepthCamera: '#d03050',
|
||||
};
|
||||
|
||||
export const DEVICE_FOV: Record<string, number> = {
|
||||
HyperSpectual400_1000nm: 17.6,
|
||||
HyperSpectual1000_1700nm: 21.7,
|
||||
SingleLensReflex: 74,
|
||||
DepthCamera: 90,
|
||||
};
|
||||
|
||||
export const DEFAULT_VALUES = {
|
||||
halogenLampPreheatingTime_Minute: 0.1,
|
||||
captureIntervalSeconds: 5,
|
||||
exposureTime: 1,
|
||||
frameRate: 30,
|
||||
defaultRenderBand: 550,
|
||||
};
|
||||
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes < 0) return '0min';
|
||||
if (minutes < 1) return `${(minutes * 60).toFixed(0)}s`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = Math.round(minutes % 60);
|
||||
if (h > 0) return `${h}h${m}min`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
export function formatDateTime(isoString: string | null): string {
|
||||
if (!isoString) return '-';
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
81
src/views/HomeView.vue
Normal file
81
src/views/HomeView.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<n-space vertical size="large" class="home-content">
|
||||
<h1>Happa Mission Plan</h1>
|
||||
<p class="subtitle">设备自动采集任务规划系统</p>
|
||||
|
||||
<n-space size="large">
|
||||
<n-button type="primary" size="large" @click="createNew">
|
||||
<template #icon><n-icon><AddOutline /></n-icon></template>
|
||||
新建任务计划
|
||||
</n-button>
|
||||
<n-button size="large" @click="openFile">
|
||||
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
||||
打开已有文件
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-card v-if="lastFilePath" title="最近打开" size="small" style="margin-top: 16px">
|
||||
<p>{{ lastFilePath }}</p>
|
||||
<n-button size="small" @click="openLastFile">打开</n-button>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { AddOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
||||
import { useMissionStore } from '../stores/mission';
|
||||
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
const missionStore = useMissionStore();
|
||||
const lastFilePath = ref<string | null>(null);
|
||||
|
||||
async function createNew() {
|
||||
missionStore.createNew();
|
||||
router.push('/editor');
|
||||
}
|
||||
|
||||
async function openFile() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
filters: [{ name: 'Mission JSON', extensions: ['json'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (selected) {
|
||||
await missionStore.loadMission(selected);
|
||||
lastFilePath.value = selected;
|
||||
router.push('/editor');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('打开文件失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function openLastFile() {
|
||||
if (lastFilePath.value) {
|
||||
router.push('/editor');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.home-content {
|
||||
text-align: center;
|
||||
}
|
||||
.subtitle {
|
||||
color: #888;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
398
src/views/MissionEditorView.vue
Normal file
398
src/views/MissionEditorView.vue
Normal file
@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="mission-editor">
|
||||
<div class="main-panel">
|
||||
<n-space vertical :size="8" style="padding: 12px">
|
||||
<!-- Toolbar -->
|
||||
<n-space align="center" justify="space-between">
|
||||
<n-button size="small" @click="goBack">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
返回
|
||||
</n-button>
|
||||
<n-space>
|
||||
<n-button size="small" @click="handleSave">
|
||||
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||
保存
|
||||
</n-button>
|
||||
<n-button size="small" @click="handleCalculate">
|
||||
<template #icon><n-icon><CalculatorOutline /></n-icon></template>
|
||||
计算
|
||||
</n-button>
|
||||
<n-button size="small" @click="handleValidate">
|
||||
<template #icon><n-icon><CheckmarkCircleOutline /></n-icon></template>
|
||||
校验
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
|
||||
<!-- Mission info -->
|
||||
<n-card title="任务计划信息" size="small">
|
||||
<n-descriptions label-placement="left" :column="2">
|
||||
<n-descriptions-item label="任务数">
|
||||
{{ missionStore.taskCount }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="文件">
|
||||
{{ missionStore.currentFilePath || '未保存' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-button block dashed style="margin-top: 8px" @click="handleAddTask">
|
||||
添加任务
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- Scan area + background (collapsible) -->
|
||||
<n-collapse>
|
||||
<n-collapse-item title="扫描区域配置" name="scanConfig">
|
||||
<n-form size="small" label-placement="top">
|
||||
<n-grid :cols="4" :x-gap="6">
|
||||
<n-gi><n-form-item label="X min"><n-input-number v-model:value="missionStore.mission.scanConfig.xMin" :step="1" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="X max"><n-input-number v-model:value="missionStore.mission.scanConfig.xMax" :step="1" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="Y min"><n-input-number v-model:value="missionStore.mission.scanConfig.yMin" :step="1" /></n-form-item></n-gi>
|
||||
<n-gi><n-form-item label="Y max"><n-input-number v-model:value="missionStore.mission.scanConfig.yMax" :step="1" /></n-form-item></n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="背景图">
|
||||
<n-input v-model:value="missionStore.mission.backgroundImage" placeholder="背景图片路径" readonly size="small" />
|
||||
<n-button size="tiny" style="margin-left: 4px" @click="browseBackground">浏览</n-button>
|
||||
<n-button size="tiny" style="margin-left: 4px" @click="clearBackground" v-if="missionStore.mission.backgroundImage">清除</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<!-- Task tree -->
|
||||
<n-collapse v-if="missionStore.mission.tasks.length > 0" accordion>
|
||||
<n-collapse-item
|
||||
v-for="task in missionStore.mission.tasks"
|
||||
:key="task.id"
|
||||
:title="taskTitle(task)"
|
||||
:name="String(task.id)"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button text size="tiny" @click.stop="handleCopyTask(task.id)" style="margin-right: 4px">
|
||||
复制
|
||||
</n-button>
|
||||
<n-button text type="error" size="tiny" @click.stop="handleRemoveTask(task.id)">
|
||||
删除
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-form size="small" label-placement="top">
|
||||
<n-form-item label="数据保存路径">
|
||||
<n-input v-model:value="task.savePath" placeholder="数据保存路径" readonly>
|
||||
<template #suffix>
|
||||
<n-button size="tiny" @click="browseSavePath(task)">浏览</n-button>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
<n-grid :cols="2" :x-gap="8">
|
||||
<n-gi>
|
||||
<n-form-item label="计划时间">
|
||||
<n-date-picker
|
||||
v-model:formatted-value="task.scheduledTime"
|
||||
type="datetime"
|
||||
value-format="yyyy-MM-dd'T'HH:mm:ss"
|
||||
placeholder="选择计划时间"
|
||||
clearable
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="卤素灯预热 (分钟)">
|
||||
<n-input-number
|
||||
v-model:value="task.HalogenLampPreheatingTime_Minute"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
|
||||
<!-- Subtask children -->
|
||||
<n-collapse>
|
||||
<n-collapse-item
|
||||
v-for="sub in task.subTasks"
|
||||
:key="sub.id"
|
||||
:title="getSubTaskLabel(sub.type)"
|
||||
:name="sub.id"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button text type="error" size="tiny" @click.stop="handleRemoveSubTask(task.id, sub.id)">
|
||||
删除
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-form size="small" label-placement="top">
|
||||
<template v-if="isHyperspectral(sub.type)">
|
||||
<n-grid :cols="2" :x-gap="8">
|
||||
<n-gi>
|
||||
<n-form-item label="曝光时间 (ms)">
|
||||
<n-input-number v-model:value="sub.exposureTime" :min="0" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="帧率 (fps)">
|
||||
<n-input-number v-model:value="sub.frameRate" :min="0" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-form-item label="captureInterval (s)">
|
||||
<n-input-number v-model:value="sub.captureIntervalSeconds" :min="0" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
<n-form-item label="航线文件">
|
||||
<n-input v-model:value="sub.pathLineFilePath" placeholder="航线文件路径" />
|
||||
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">
|
||||
浏览
|
||||
</n-button>
|
||||
<n-button size="tiny" style="margin-left: 4px" @click="goToPlanner(task.id, sub)">
|
||||
生成航线
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-descriptions size="small" label-placement="left" :column="2">
|
||||
<n-descriptions-item label="开始">{{ formatDateTime(sub.startTime) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="结束">{{ formatDateTime(sub.endTime) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="预计耗时">{{ formatDuration(sub.estimatedDurationMinutes) }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<n-button block dashed size="small" style="margin-top: 4px" @click="showAddSubTask(task.id)">
|
||||
添加子任务
|
||||
</n-button>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
<n-empty v-if="missionStore.mission.tasks.length === 0" description="暂无任务,点击上方添加" />
|
||||
|
||||
<!-- Validation results (collapsible) -->
|
||||
<n-collapse v-if="validationStore.issues.length > 0">
|
||||
<n-collapse-item title="校验结果" name="validation">
|
||||
<template #header-extra>
|
||||
<n-space size="small">
|
||||
<n-tag v-if="validationStore.errorCount > 0" type="error" size="small">
|
||||
{{ validationStore.errorCount }} 错误
|
||||
</n-tag>
|
||||
<n-tag v-if="validationStore.warningCount > 0" type="warning" size="small">
|
||||
{{ validationStore.warningCount }} 警告
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<n-alert
|
||||
v-for="(issue, i) in validationStore.issues"
|
||||
:key="i"
|
||||
:type="issue.severity === 'Error' ? 'error' : 'warning'"
|
||||
:title="issue.rule"
|
||||
closable
|
||||
style="margin-bottom: 4px"
|
||||
>
|
||||
{{ issue.message }}
|
||||
</n-alert>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- SubTask type selector modal -->
|
||||
<n-modal
|
||||
v-model:show="showTypeModal"
|
||||
title="选择子任务类型"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
style="width: 320px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-button
|
||||
v-for="t in availableTypes"
|
||||
:key="t.value"
|
||||
:disabled="t.disabled"
|
||||
block
|
||||
@click="confirmAddSubTask(t.value)"
|
||||
>
|
||||
{{ t.label }}{{ t.disabled ? ' (已添加)' : '' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-button block @click="showTypeModal = false">取消</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessage, useDialog } from 'naive-ui';
|
||||
import {
|
||||
ArrowBackOutline, SaveOutline, CalculatorOutline, CheckmarkCircleOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import { useMissionStore } from '../stores/mission';
|
||||
import { useValidationStore } from '../stores/validation';
|
||||
import { SUBTASK_TYPE_LABELS } from '../utils/constants';
|
||||
import { formatDuration, formatDateTime } from '../utils/constants';
|
||||
import { isHyperspectral, SubTaskType } from '../types/task';
|
||||
import type { SubTask } from '../types/task';
|
||||
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const missionStore = useMissionStore();
|
||||
const validationStore = useValidationStore();
|
||||
|
||||
const showTypeModal = ref(false);
|
||||
const typeModalTaskId = ref<number | null>(null);
|
||||
|
||||
const ALL_TASK_TYPES: { value: SubTaskType; label: string }[] = [
|
||||
{ value: SubTaskType.HyperSpectual400_1000nm, label: 'Pika L' },
|
||||
{ value: SubTaskType.HyperSpectual1000_1700nm, label: 'Pika NIR' },
|
||||
{ value: SubTaskType.SingleLensReflex, label: '单反相机' },
|
||||
{ value: SubTaskType.DepthCamera, label: '深度相机' },
|
||||
];
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const task = missionStore.mission.tasks.find(t => t.id === typeModalTaskId.value);
|
||||
const existing: SubTaskType[] = task?.subTasks.map(s => s.type) || [];
|
||||
return ALL_TASK_TYPES.map(t => ({
|
||||
...t,
|
||||
disabled: existing.includes(t.value),
|
||||
}));
|
||||
});
|
||||
|
||||
function getSubTaskLabel(type: string): string {
|
||||
return SUBTASK_TYPE_LABELS[type] || type;
|
||||
}
|
||||
|
||||
const DEFAULT_TIME = '2000-01-01T00:00:00';
|
||||
function taskTitle(task: { id: number; scheduledTime: string }): string {
|
||||
if (!task.scheduledTime || task.scheduledTime === DEFAULT_TIME) {
|
||||
return 'Task #' + task.id;
|
||||
}
|
||||
const t = task.scheduledTime;
|
||||
const dateTime = t.length >= 16 ? t.slice(0, 10) + ' ' + t.slice(11, 16) : t;
|
||||
return 'Task #' + task.id + ' ' + dateTime;
|
||||
}
|
||||
|
||||
function goBack() { router.push('/'); }
|
||||
|
||||
async function browseSavePath(task: any) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({ directory: true, multiple: false, title: '选择数据保存目录' });
|
||||
if (selected) task.savePath = selected;
|
||||
} catch { /* cancelled */ }
|
||||
}
|
||||
|
||||
async function browseBackground() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'gif'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (selected) missionStore.setBackgroundImage(selected);
|
||||
} catch { /* cancelled */ }
|
||||
}
|
||||
|
||||
function clearBackground() { missionStore.setBackgroundImage(null); }
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (missionStore.currentFilePath) {
|
||||
await missionStore.saveMission();
|
||||
message.success('保存成功');
|
||||
} else {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const path = await save({ filters: [{ name: 'Mission JSON', extensions: ['json'] }] });
|
||||
if (path) { await missionStore.saveMission(path); message.success('保存成功'); }
|
||||
}
|
||||
} catch (e) { message.error('保存失败: ' + e); }
|
||||
}
|
||||
|
||||
async function handleCalculate() {
|
||||
try { await missionStore.calculateSchedule(); message.success('计算完成'); }
|
||||
catch (e) { message.error('计算失败: ' + e); }
|
||||
}
|
||||
|
||||
async function handleValidate() {
|
||||
try {
|
||||
const issues = await missionStore.runValidation();
|
||||
validationStore.setIssues(issues);
|
||||
const errCount = issues.filter(i => i.severity === 'Error').length;
|
||||
if (errCount > 0) message.warning(`发现 ${errCount} 个错误`);
|
||||
else message.success('校验通过');
|
||||
} catch (e) { message.error('校验失败: ' + e); }
|
||||
}
|
||||
|
||||
async function handleAddTask() {
|
||||
try { await missionStore.addTask(); message.success('添加任务成功'); }
|
||||
catch (e) { message.error('添加任务失败: ' + e); }
|
||||
}
|
||||
|
||||
async function handleCopyTask(taskId: number) {
|
||||
try { await missionStore.copyTask(taskId); message.success('复制成功'); }
|
||||
catch (e) { message.error('复制失败: ' + e); }
|
||||
}
|
||||
|
||||
function handleRemoveTask(taskId: number) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: `确定删除 Task #${taskId}?此操作不可撤销`,
|
||||
positiveText: '删除', negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try { await missionStore.removeTask(taskId); message.success('删除成功'); }
|
||||
catch (e) { message.error('删除失败: ' + e); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveSubTask(taskId: number, subTaskId: string) {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定删除该子任务?',
|
||||
positiveText: '删除', negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try { await missionStore.removeSubTask(taskId, subTaskId); message.success('删除成功'); }
|
||||
catch (e) { message.error('删除失败: ' + e); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function showAddSubTask(taskId: number) {
|
||||
typeModalTaskId.value = taskId;
|
||||
showTypeModal.value = true;
|
||||
}
|
||||
|
||||
function confirmAddSubTask(typeValue: SubTaskType) {
|
||||
if (typeModalTaskId.value === null) return;
|
||||
addSubTask(typeModalTaskId.value, typeValue);
|
||||
showTypeModal.value = false;
|
||||
}
|
||||
|
||||
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
|
||||
try { await missionStore.addSubTask(taskId, subTaskType); message.success('添加子任务成功'); }
|
||||
catch (e) { message.error('添加子任务失败: ' + e); }
|
||||
}
|
||||
|
||||
async function browsePathLine(sub: SubTask) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (selected) sub.pathLineFilePath = selected;
|
||||
} catch { /* cancelled */ }
|
||||
}
|
||||
|
||||
function goToPlanner(taskId: number, sub: SubTask) {
|
||||
router.push({ path: '/planner', query: { taskId: String(taskId), subTaskId: sub.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-editor { height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.main-panel { flex: 1; overflow-y: auto; }
|
||||
</style>
|
||||
82
src/views/PathLineViewer.vue
Normal file
82
src/views/PathLineViewer.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="pathline-viewer">
|
||||
<n-space style="padding: 8px" align="center">
|
||||
<n-button size="small" @click="goBack">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
返回
|
||||
</n-button>
|
||||
<n-button size="small" @click="openFile">
|
||||
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
||||
打开航线文件
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-empty v-if="!records.length" description="请打开一个 .RecordLine3 文件" style="margin-top: 60px" />
|
||||
|
||||
<div v-else style="padding: 8px">
|
||||
<n-statistic label="记录条数" :value="records.length" />
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="records"
|
||||
:bordered="true"
|
||||
:single-line="false"
|
||||
size="small"
|
||||
:max-height="600"
|
||||
virtual-scroll
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ArrowBackOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { DataTableColumn } from 'naive-ui';
|
||||
import type { PathLineRecord } from '../types/path-line';
|
||||
|
||||
const router = useRouter();
|
||||
const message = useMessage();
|
||||
const records = ref<PathLineRecord[]>([]);
|
||||
|
||||
const columns: DataTableColumn<PathLineRecord>[] = [
|
||||
{ title: 'Y 位置', key: 'targetYPosition', render: r => r.targetYPosition.toFixed(2) },
|
||||
{ title: 'Y 速度', key: 'speedTargetYPosition', render: r => r.speedTargetYPosition.toFixed(2) },
|
||||
{ title: 'X 起始', key: 'targetXMinPosition', render: r => r.targetXMinPosition.toFixed(2) },
|
||||
{ title: 'X 起始速度', key: 'speedTargetXMinPosition', render: r => r.speedTargetXMinPosition.toFixed(2) },
|
||||
{ title: 'X 结束', key: 'targetXMaxPosition', render: r => r.targetXMaxPosition.toFixed(2) },
|
||||
{ title: 'X 扫描速度', key: 'speedTargetXMaxPosition', render: r => r.speedTargetXMaxPosition.toFixed(2) },
|
||||
{ title: '#', key: 'index', render: (_, i) => i + 1, width: 50 },
|
||||
];
|
||||
|
||||
async function openFile() {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (selected) {
|
||||
const file = await invoke('load_path_line', { path: selected });
|
||||
records.value = (file as any).records;
|
||||
message.success(`加载成功: ${(file as any).records.length} 条记录`);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('打开文件失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/editor');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pathline-viewer {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
215
src/views/PathPlannerView.vue
Normal file
215
src/views/PathPlannerView.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="path-planner">
|
||||
<n-space style="padding: 8px" align="center">
|
||||
<n-button size="small" @click="goBack">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
返回
|
||||
</n-button>
|
||||
<n-button size="small" :type="drawingMode ? 'warning' : 'default'" @click="toggleDrawing">
|
||||
<template #icon><n-icon><SquareOutline /></n-icon></template>
|
||||
{{ drawingMode ? '完成画框' : '画框' }}
|
||||
</n-button>
|
||||
<n-button size="small" @click="clearRects" :disabled="regions.length === 0">
|
||||
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||
清除
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" @click="generatePath" :disabled="regions.length === 0">
|
||||
<template #icon><n-icon><MapOutline /></n-icon></template>
|
||||
生成航线
|
||||
</n-button>
|
||||
<n-button size="small" @click="savePath" :disabled="!generatedRecords.length">
|
||||
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||
保存路径
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-split direction="horizontal" :default-size="0.6">
|
||||
<template #1>
|
||||
<ScanCanvas
|
||||
:regions="regions"
|
||||
:records="previewRecords"
|
||||
:mode="scanMode"
|
||||
:background-image="missionStore.mission.backgroundImage || undefined"
|
||||
:drawing-mode="drawingMode"
|
||||
@add-region="onAddRegion"
|
||||
/>
|
||||
</template>
|
||||
<template #2>
|
||||
<ScanParamPanel
|
||||
:camera="camera"
|
||||
:coverage-rate="coverageRate"
|
||||
:speed-y="speedY"
|
||||
:speed-x-scan="speedXScan"
|
||||
:speed-x-start="speedXStart"
|
||||
:mode="scanMode"
|
||||
:line-count="generatedRecords.length"
|
||||
:est-time="estimatedTime"
|
||||
@update="onParamChange"
|
||||
/>
|
||||
</template>
|
||||
</n-split>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ArrowBackOutline, SquareOutline, TrashOutline, MapOutline, SaveOutline } from '@vicons/ionicons5';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import ScanCanvas from '../components/planner/ScanCanvas.vue';
|
||||
import ScanParamPanel from '../components/planner/ScanParamPanel.vue';
|
||||
import { useMissionStore } from '../stores/mission';
|
||||
import type { PathLineRecord, PathLineFile } from '../types/path-line';
|
||||
import type { ScanRegion, CameraParams, ScanMode } from '../types/path-plan';
|
||||
import type { PlannerDefaults } from '../types/mission';
|
||||
import { DEVICE_FOV } from '../utils/constants';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const message = useMessage();
|
||||
const missionStore = useMissionStore();
|
||||
|
||||
const taskId = route.query.taskId ? Number(route.query.taskId) : null;
|
||||
const subTaskId = route.query.subTaskId ? String(route.query.subTaskId) : null;
|
||||
|
||||
const drawingMode = ref(false);
|
||||
|
||||
function getDeviceFov(): number {
|
||||
if (taskId === null || subTaskId === null) return 30;
|
||||
const task = missionStore.mission.tasks.find(t => t.id === taskId);
|
||||
const sub = task?.subTasks.find(s => s.id === subTaskId);
|
||||
return (sub && DEVICE_FOV[sub.type]) || 30;
|
||||
}
|
||||
|
||||
const regions = ref<ScanRegion[]>([]);
|
||||
const camera = ref<CameraParams>({ fovDegrees: getDeviceFov(), heightCm: 50 });
|
||||
const coverageRate = ref(30);
|
||||
const speedY = ref(5);
|
||||
const speedXScan = ref(10);
|
||||
const speedXStart = ref(20);
|
||||
const scanMode = ref<ScanMode>('Zigzag');
|
||||
|
||||
const generatedRecords = ref<PathLineRecord[]>([]);
|
||||
const previewRecords = computed(() => generatedRecords.value);
|
||||
const estimatedTime = ref(0);
|
||||
|
||||
// Load saved planner defaults
|
||||
invoke<PlannerDefaults>('load_planner_defaults').then(p => {
|
||||
coverageRate.value = p.coverageRate;
|
||||
speedY.value = p.speedYCmS;
|
||||
speedXScan.value = p.speedXScanCmS;
|
||||
speedXStart.value = p.speedXStartCmS;
|
||||
scanMode.value = p.mode as ScanMode;
|
||||
camera.value = { fovDegrees: camera.value.fovDegrees, heightCm: p.heightCm };
|
||||
}).catch(() => {});
|
||||
|
||||
function toggleDrawing() {
|
||||
drawingMode.value = !drawingMode.value;
|
||||
}
|
||||
|
||||
function onAddRegion(region: ScanRegion) {
|
||||
regions.value.push(region);
|
||||
message.success(`已添加框 #${regions.value.length}`);
|
||||
}
|
||||
|
||||
function clearRects() {
|
||||
regions.value = [];
|
||||
generatedRecords.value = [];
|
||||
estimatedTime.value = 0;
|
||||
message.info('已清除所有框');
|
||||
}
|
||||
|
||||
function savePlannerDefaults() {
|
||||
const p: PlannerDefaults = {
|
||||
fovDegrees: camera.value.fovDegrees,
|
||||
heightCm: camera.value.heightCm,
|
||||
coverageRate: coverageRate.value,
|
||||
speedYCmS: speedY.value,
|
||||
speedXStartCmS: speedXStart.value,
|
||||
speedXScanCmS: speedXScan.value,
|
||||
mode: scanMode.value,
|
||||
};
|
||||
invoke('save_planner_defaults', { config: p }).catch(() => {});
|
||||
}
|
||||
|
||||
function onParamChange(params: any) {
|
||||
if (params.camera) camera.value = params.camera;
|
||||
if (params.coverageRate !== undefined) coverageRate.value = params.coverageRate;
|
||||
if (params.speedY !== undefined) speedY.value = params.speedY;
|
||||
if (params.speedXScan !== undefined) speedXScan.value = params.speedXScan;
|
||||
if (params.speedXStart !== undefined) speedXStart.value = params.speedXStart;
|
||||
if (params.mode) scanMode.value = params.mode;
|
||||
savePlannerDefaults();
|
||||
}
|
||||
|
||||
async function generatePath() {
|
||||
if (!regions.value.length) return;
|
||||
try {
|
||||
const file = await invoke<PathLineFile>('generate_scan_paths_multi', {
|
||||
regions: regions.value,
|
||||
camera: camera.value,
|
||||
coverageRate: coverageRate.value,
|
||||
speedYCmS: speedY.value,
|
||||
speedXStartCmS: speedXStart.value,
|
||||
speedXScanCmS: speedXScan.value,
|
||||
mode: scanMode.value,
|
||||
});
|
||||
generatedRecords.value = file.records;
|
||||
const estTime = await invoke<number>('estimate_scan_time_multi_minutes', {
|
||||
regions: regions.value,
|
||||
camera: camera.value,
|
||||
coverageRate: coverageRate.value,
|
||||
speedYCmS: speedY.value,
|
||||
speedXStartCmS: speedXStart.value,
|
||||
speedXScanCmS: speedXScan.value,
|
||||
mode: scanMode.value,
|
||||
});
|
||||
estimatedTime.value = estTime;
|
||||
message.success(`航线生成完成: ${file.count} 条记录`);
|
||||
} catch (e) {
|
||||
message.error('航线生成失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePath() {
|
||||
if (!generatedRecords.value.length) return;
|
||||
try {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const path = await save({
|
||||
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
|
||||
});
|
||||
if (path) {
|
||||
const finalPath = path.endsWith('.RecordLine3') ? path : path + '.RecordLine3';
|
||||
const file: PathLineFile = {
|
||||
count: generatedRecords.value.length,
|
||||
records: generatedRecords.value,
|
||||
};
|
||||
await invoke('save_path_line', { path: finalPath, file });
|
||||
message.success('航线保存成功');
|
||||
|
||||
if (taskId !== null && subTaskId !== null) {
|
||||
const task = missionStore.mission.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
const sub = task.subTasks.find(s => s.id === subTaskId);
|
||||
if (sub) {
|
||||
sub.pathLineFilePath = finalPath;
|
||||
await missionStore.updateSubTask(taskId, { ...sub });
|
||||
message.success('已自动填入子任务航线路径');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('保存失败: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/editor');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.path-planner { height: 100vh; display: flex; flex-direction: column; }
|
||||
</style>
|
||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user