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

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>