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:
xin
2026-06-17 17:17:39 +08:00
commit d732580c3e
80 changed files with 10842 additions and 0 deletions

32
src/App.vue Normal file
View 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
View 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

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

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

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

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

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

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

View 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
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}