feat: add parameter help icons with editable help.csv

This commit is contained in:
xin
2026-06-18 15:43:31 +08:00
parent c1a76a5875
commit c017129c9f
13 changed files with 243 additions and 229 deletions

29
help.csv Normal file
View File

@ -0,0 +1,29 @@
key,label,description
mission.task_count,任务数,当前任务计划中包含的任务总数
mission.save_path,数据保存路径,采集数据保存的目录路径,支持浏览文件夹选择
mission.file,文件,当前任务计划文件(.json)的保存路径
mission.scheduled_time,计划时间,任务计划开始执行的时间
task.halogen_preheat,卤素灯预热(分钟),高光谱相机执行前卤素灯需要预热的时间,单位为分钟
subtask.exposure_time,曝光时间(ms),高光谱相机传感器的曝光时间,单位为毫秒
subtask.frame_rate,帧率(fps),高光谱相机每秒钟采集的帧数
subtask.capture_interval,采集间隔(s),单反或深度相机每次拍摄之间的间隔时间,单位为秒
subtask.path_file,航线文件,关联的.RecordLine3格式航线文件路径
subtask.view_path,查看,打开已存在的航线文件进行查看
subtask.browse_path,浏览,浏览并选择已存在的航线文件
subtask.generate_path,生成航线,打开路径规划器绘制区域并生成航线文件
scan_area.x_min,X min,扫描区域的X轴最小坐标值单位为cm
scan_area.x_max,X max,扫描区域的X轴最大坐标值单位为cm
scan_area.y_min,Y min,扫描区域的Y轴最小坐标值单位为cm
scan_area.y_max,Y max,扫描区域的Y轴最大坐标值单位为cm
scan_area.background,背景图,画布底图路径,用于辅助绘制扫描区域
planner.fov,FOV(°),相机视场角,不同设备有默认值(Pika L:17.6° Pika NIR:21.7° 单反:74° 深度:90°)
planner.height,高度(cm),相机安装高度,用于计算地面覆盖范围和步长
planner.coverage,覆盖率(%),相邻扫描线之间的重叠率,值越大步长越小
planner.speed_y,Y轴定位速度(cm/s),Y轴运动时的速度
planner.speed_x_scan,X扫描速度(cm/s),X轴采集扫描时的运动速度
planner.speed_x_return,X回起点速度(cm/s),X轴返回起点时的速度OneWay模式使用
planner.mode_zigzag,蛇形来回,扫描路径为"S"形来回折返,每行扫描方向交替,无需空跑回起点
planner.mode_oneway,单向回起点,每行从左到右扫描,扫描完空跑回起点再扫下一行
task.start_time,开始时间,任务实际开始执行的时间(由计算生成)
task.end_time,结束时间,任务实际结束执行的时间(由计算生成)
subtask.estimated_duration,预计耗时,子任务的预估执行时间
Can't render this file because it contains an unexpected character in line 25 and column 49.

View File

@ -1,7 +1,7 @@
{
"name": "happa-mission-plan",
"private": true,
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@ -3180,7 +3180,7 @@ dependencies = [
[[package]]
name = "spectral-insight-mission-plan"
version = "0.0.2"
version = "0.0.4"
dependencies = [
"byteorder",
"chrono",

View File

@ -1,6 +1,6 @@
[package]
name = "spectral-insight-mission-plan"
version = "0.0.4"
version = "0.0.5"
description = "Spectral Insight Mission Plan"
authors = ["you"]
edition = "2021"

View File

@ -0,0 +1,54 @@
use serde::Serialize;
use tauri::command;
use std::path::PathBuf;
#[derive(Debug, Serialize)]
pub struct HelpEntry {
pub key: String,
pub label: String,
pub description: String,
}
fn defaults_dir() -> PathBuf {
std::env::current_exe()
.unwrap_or_else(|_| PathBuf::from("."))
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf()
}
const DEFAULT_CSV: &str = include_str!("../../../help.csv");
#[command]
pub fn load_help_csv() -> Vec<HelpEntry> {
let path = defaults_dir().join("help.csv");
// Always write embedded default so file stays in sync with the binary
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, DEFAULT_CSV);
match std::fs::read_to_string(&path) {
Ok(c) => parse_csv(&c),
Err(_) => parse_csv(DEFAULT_CSV),
}
}
fn parse_csv(content: &str) -> Vec<HelpEntry> {
let mut entries = Vec::new();
for (i, line) in content.lines().enumerate() {
if i == 0 { continue; }
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
let parts: Vec<&str> = trimmed.splitn(3, ',').collect();
if parts.len() == 3 {
entries.push(HelpEntry {
key: parts[0].trim().to_string(),
label: parts[1].trim().to_string(),
description: parts[2].trim().to_string(),
});
}
}
entries
}

View File

@ -4,3 +4,4 @@ pub mod validation_commands;
pub mod path_commands;
pub mod devtools_commands;
pub mod defaults_commands;
pub mod help_commands;

View File

@ -47,6 +47,7 @@ pub fn run() {
commands::defaults_commands::load_default_background,
commands::defaults_commands::save_planner_defaults,
commands::defaults_commands::load_planner_defaults,
commands::help_commands::load_help_csv,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Spectral Insight Mission Plan",
"version": "0.0.4",
"version": "0.0.5",
"identifier": "com.spectral-insight.mission-plan",
"build": {
"beforeDevCommand": "npm run dev",
@ -39,7 +39,8 @@
],
"resources": [
"../update.md",
"../说明书.md"
"../说明书.md",
"../help.csv"
],
"windows": {
"nsis": {

View File

@ -0,0 +1,59 @@
<template>
<n-popover trigger="click" placement="right" :width="280">
<template #trigger>
<span class="help-icon" title="查看说明">?</span>
</template>
<div v-if="entry" style="font-size: 13px; line-height: 1.6;">
<div style="font-weight: bold; margin-bottom: 4px;">{{ entry.label }}</div>
<div style="color: #666;">{{ entry.description }}</div>
</div>
<div v-else style="color: #999; font-size: 13px;">暂无说明</div>
</n-popover>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { invoke } from '@tauri-apps/api/core';
const props = defineProps<{
helpKey: string;
}>();
interface HelpEntry {
key: string;
label: string;
description: string;
}
// Module-level shared state
const state = reactive<{ entries: HelpEntry[]; loaded: boolean }>({ entries: [], loaded: false });
invoke<HelpEntry[]>('load_help_csv').then(r => {
state.entries = r;
state.loaded = true;
}).catch(() => { state.loaded = true; });
const entry = computed(() => state.entries.find(e => e.key === props.helpKey));
</script>
<style scoped>
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #e0e0e0;
color: #666;
font-size: 11px;
font-weight: bold;
cursor: pointer;
margin-left: 4px;
user-select: none;
flex-shrink: 0;
}
.help-icon:hover {
background: #ccc;
color: #333;
}
</style>

View File

@ -113,7 +113,6 @@ function draw() {
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();
}

View File

@ -3,23 +3,37 @@
<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-gi>
<n-form-item>
<template #label>FOV (°) <HelpIcon helpKey="planner.fov" /></template>
<n-input-number v-model:value="localCamera.fovDegrees" :min="1" :max="180" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item>
<template #label>高度 (cm) <HelpIcon helpKey="planner.height" /></template>
<n-input-number v-model:value="localCamera.heightCm" :min="1" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="覆盖率 (%)">
<n-form-item>
<template #label>覆盖率 (%) <HelpIcon helpKey="planner.coverage" /></template>
<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-form-item>
<template #label>Y 轴定位速度 (cm/s) <HelpIcon helpKey="planner.speed_y" /></template>
<n-input-number v-model:value="localSpeedY" :min="0.1" :step="0.5" />
</n-form-item>
<n-form-item label="X 扫描速度 (cm/s)">
<n-form-item>
<template #label>X 扫描速度 (cm/s) <HelpIcon helpKey="planner.speed_x_scan" /></template>
<n-input-number v-model:value="localSpeedXScan" :min="0.1" :step="0.5" />
</n-form-item>
<n-form-item label="X 回起点速度 (cm/s)">
<n-form-item>
<template #label>X 回起点速度 (cm/s) <HelpIcon helpKey="planner.speed_x_return" /></template>
<n-input-number v-model:value="localSpeedXStart" :min="0.1" :step="0.5" />
</n-form-item>
</n-card>
@ -27,8 +41,12 @@
<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-radio value="Zigzag">
蛇形来回 (Zigzag) <HelpIcon helpKey="planner.mode_zigzag" />
</n-radio>
<n-radio value="OneWay">
单向回起点 (OneWay) <HelpIcon helpKey="planner.mode_oneway" />
</n-radio>
</n-space>
</n-radio-group>
</n-card>
@ -47,6 +65,7 @@
import { ref, watch, computed } from 'vue';
import type { CameraParams, ScanMode } from '../../types/path-plan';
import { formatDuration } from '../../utils/constants';
import HelpIcon from '../common/HelpIcon.vue';
const props = defineProps<{
camera: CameraParams;

View File

@ -2,7 +2,6 @@
<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>
@ -28,14 +27,13 @@
</n-space>
</n-space>
<!-- Mission info -->
<n-card title="任务计划信息" size="small">
<n-descriptions label-placement="left" :column="2">
<n-descriptions-item label="任务数">
{{ missionStore.taskCount }}
{{ missionStore.taskCount }} <HelpIcon helpKey="mission.task_count" />
</n-descriptions-item>
<n-descriptions-item label="文件">
{{ missionStore.currentFilePath || '未保存' }}
{{ missionStore.currentFilePath || '未保存' }} <HelpIcon helpKey="mission.file" />
</n-descriptions-item>
</n-descriptions>
<n-button block dashed style="margin-top: 8px" @click="handleAddTask">
@ -43,17 +41,16 @@
</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-gi><n-form-item><template #label>X min <HelpIcon helpKey="scan_area.x_min" /></template><n-input-number v-model:value="missionStore.mission.scanConfig.xMin" :step="1" /></n-form-item></n-gi>
<n-gi><n-form-item><template #label>X max <HelpIcon helpKey="scan_area.x_max" /></template><n-input-number v-model:value="missionStore.mission.scanConfig.xMax" :step="1" /></n-form-item></n-gi>
<n-gi><n-form-item><template #label>Y min <HelpIcon helpKey="scan_area.y_min" /></template><n-input-number v-model:value="missionStore.mission.scanConfig.yMin" :step="1" /></n-form-item></n-gi>
<n-gi><n-form-item><template #label>Y max <HelpIcon helpKey="scan_area.y_max" /></template><n-input-number v-model:value="missionStore.mission.scanConfig.yMax" :step="1" /></n-form-item></n-gi>
</n-grid>
<n-form-item label="背景图">
<n-form-item><template #label>背景图 <HelpIcon helpKey="scan_area.background" /></template>
<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>
@ -62,7 +59,6 @@
</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"
@ -71,16 +67,12 @@
: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>
<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-form-item><template #label>数据保存路径 <HelpIcon helpKey="mission.save_path" /></template>
<n-input v-model:value="task.savePath" placeholder="数据保存路径" readonly>
<template #suffix>
<n-button size="tiny" @click="browseSavePath(task)">浏览</n-button>
@ -89,7 +81,7 @@
</n-form-item>
<n-grid :cols="2" :x-gap="8">
<n-gi>
<n-form-item label="计划时间">
<n-form-item><template #label>计划时间 <HelpIcon helpKey="mission.scheduled_time" /></template>
<n-date-picker
v-model:formatted-value="task.scheduledTime"
type="datetime"
@ -100,18 +92,13 @@
</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><template #label>卤素灯预热 (分钟) <HelpIcon helpKey="task.halogen_preheat" /></template>
<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"
@ -120,47 +107,34 @@
:name="sub.id"
>
<template #header-extra>
<n-button text type="error" size="tiny" @click.stop="handleRemoveSubTask(task.id, sub.id)">
删除
</n-button>
<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-form-item><template #label>曝光时间 (ms) <HelpIcon helpKey="subtask.exposure_time" /></template>
<n-input-number v-model:value="sub.exposureTime" :min="0" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="帧率 (fps)">
<n-form-item><template #label>帧率 (fps) <HelpIcon helpKey="subtask.frame_rate" /></template>
<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-form-item><template #label>采集间隔 (s) <HelpIcon helpKey="subtask.capture_interval" /></template>
<n-input-number v-model:value="sub.captureIntervalSeconds" :min="0" />
</n-form-item>
</template>
<n-form-item label="航线文件">
<n-form-item><template #label>航线文件 <HelpIcon helpKey="subtask.path_file" /></template>
<n-input v-model:value="sub.pathLineFilePath" placeholder="航线文件路径" />
<n-button
size="tiny"
style="margin-left: 4px"
:disabled="!sub.pathLineFilePath"
@click="viewPathFile(sub.pathLineFilePath)"
>
查看
</n-button>
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">
浏览
</n-button>
<n-button size="tiny" style="margin-left: 4px" @click="goToPlanner(task.id, sub)">
生成航线
</n-button>
<n-button size="tiny" style="margin-left: 4px" :disabled="!sub.pathLineFilePath" @click="viewPathFile(sub.pathLineFilePath)">查看</n-button>
<n-button size="tiny" style="margin-left: 4px" @click="browsePathLine(sub)">浏览</n-button>
<n-button size="tiny" style="margin-left: 4px" @click="goToPlanner(task.id, sub)">生成航线</n-button>
</n-form-item>
</n-form>
@ -180,27 +154,17 @@
<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-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"
<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"
>
:title="issue.rule" closable style="margin-bottom: 4px">
{{ issue.message }}
</n-alert>
</n-collapse-item>
@ -208,21 +172,13 @@
</n-space>
</div>
<!-- Path viewer modal -->
<n-modal v-model:show="showPathModal" title="路径文件查看" preset="card" style="width: 95vw; height: 90vh;">
<div v-if="pathViewRecords.length > 0" style="display: flex; gap: 12px; height: 100%;">
<div style="flex: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0;">
<n-space style="padding: 8px; background: #f5f5f5; border-radius: 4px;">
<n-statistic label="记录条数" :value="pathViewRecords.length" />
</n-space>
<n-data-table
:columns="pathColumns"
:data="pathViewRecords"
size="small"
:max-height="9999"
virtual-scroll
style="flex: 1;"
/>
<n-data-table :columns="pathColumns" :data="pathViewRecords" size="small" :max-height="9999" virtual-scroll style="flex: 1;" />
</div>
<div ref="pathPreviewContainer" style="flex: 2; height: 100%; background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px;">
<canvas ref="pathPreviewCanvas" style="width: 100%; height: 100%;" />
@ -231,22 +187,9 @@
<n-empty v-else description="请选择路径文件" />
</n-modal>
<!-- SubTask type selector modal -->
<n-modal
v-model:show="showTypeModal"
title="选择子任务类型"
:mask-closable="false"
preset="card"
style="width: 320px"
>
<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)"
>
<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>
@ -273,6 +216,7 @@ import { isHyperspectral, SubTaskType } from '../types/task';
import type { SubTask } from '../types/task';
import type { PathLineRecord } from '../types/path-line';
import type { DataTableColumn } from 'naive-ui';
import HelpIcon from '../components/common/HelpIcon.vue';
const router = useRouter();
const message = useMessage();
@ -293,10 +237,7 @@ const ALL_TASK_TYPES: { value: SubTaskType; label: string }[] = [
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),
}));
return ALL_TASK_TYPES.map(t => ({ ...t, disabled: existing.includes(t.value) }));
});
function getSubTaskLabel(type: string): string {
@ -319,12 +260,9 @@ const pathColumns: DataTableColumn<PathLineRecord>[] = [
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;
}
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;
return 'Task #' + task.id + ' ' + (t.length >= 16 ? t.slice(0, 10) + ' ' + t.slice(11, 16) : t);
}
function goBack() { router.push('/'); }
@ -340,10 +278,7 @@ async function browseSavePath(task: any) {
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,
});
const selected = await open({ filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'gif'] }], multiple: false });
if (selected) missionStore.setBackgroundImage(selected);
} catch { /* cancelled */ }
}
@ -353,8 +288,7 @@ function clearBackground() { missionStore.setBackgroundImage(null); }
async function handleSave() {
try {
if (missionStore.currentFilePath) {
await missionStore.saveMission();
message.success('保存成功');
await missionStore.saveMission(); message.success('保存成功');
} else {
const { save } = await import('@tauri-apps/plugin-dialog');
const path = await save({ filters: [{ name: 'Mission JSON', extensions: ['json'] }] });
@ -390,8 +324,7 @@ async function handleCopyTask(taskId: number) {
function handleRemoveTask(taskId: number) {
dialog.warning({
title: '确认删除',
content: `确定删除 Task #${taskId}?此操作不可撤销`,
title: '确认删除', content: `确定删除 Task #${taskId}?此操作不可撤销`,
positiveText: '删除', negativeText: '取消',
onPositiveClick: async () => {
try { await missionStore.removeTask(taskId); message.success('删除成功'); }
@ -402,8 +335,7 @@ function handleRemoveTask(taskId: number) {
function handleRemoveSubTask(taskId: number, subTaskId: string) {
dialog.warning({
title: '确认删除',
content: '确定删除该子任务?',
title: '确认删除', content: '确定删除该子任务?',
positiveText: '删除', negativeText: '取消',
onPositiveClick: async () => {
try { await missionStore.removeSubTask(taskId, subTaskId); message.success('删除成功'); }
@ -412,15 +344,10 @@ function handleRemoveSubTask(taskId: number, subTaskId: string) {
});
}
function showAddSubTask(taskId: number) {
typeModalTaskId.value = taskId;
showTypeModal.value = true;
}
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;
addSubTask(typeModalTaskId.value, typeValue); showTypeModal.value = false;
}
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
@ -431,10 +358,7 @@ async function addSubTask(taskId: number, subTaskType: SubTaskType) {
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,
});
const selected = await open({ filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }], multiple: false });
if (selected) sub.pathLineFilePath = selected;
} catch { /* cancelled */ }
}
@ -450,9 +374,7 @@ async function openPathFile(path: string) {
showPathModal.value = true;
await nextTick();
drawPathPreview();
} catch (e) {
message.error('无法打开路径文件: ' + e);
}
} catch (e) { message.error('无法打开路径文件: ' + e); }
}
async function drawPathPreview() {
@ -461,127 +383,56 @@ async function drawPathPreview() {
if (!canvas || !container) return;
const records = pathViewRecords.value;
if (!records.length) return;
const w = container.clientWidth;
const h = container.clientHeight;
const w = container.clientWidth, h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
canvas.width = w * dpr; canvas.height = h * dpr;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(0, 0, w, h);
// Find bounds from records
if (!ctx) return; ctx.scale(dpr, dpr);
ctx.fillStyle = '#f8f8f8'; ctx.fillRect(0, 0, w, h);
let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity;
for (const r of records) {
xMin = Math.min(xMin, r.targetXMinPosition, r.targetXMaxPosition);
xMax = Math.max(xMax, r.targetXMinPosition, r.targetXMaxPosition);
yMin = Math.min(yMin, r.targetYPosition);
yMax = Math.max(yMax, r.targetYPosition);
}
const rangeX = xMax - xMin || 1;
const rangeY = yMax - yMin || 1;
const pad = 30;
const drawW = w - pad * 2;
const drawH = h - pad * 2;
function toCanvas(wx: number, wy: number) {
return {
x: pad + ((wx - xMin) / rangeX) * drawW,
y: pad + ((yMax - wy) / rangeY) * drawH,
};
}
// Background image
for (const r of records) { xMin = Math.min(xMin, r.targetXMinPosition, r.targetXMaxPosition); xMax = Math.max(xMax, r.targetXMinPosition, r.targetXMaxPosition); yMin = Math.min(yMin, r.targetYPosition); yMax = Math.max(yMax, r.targetYPosition); }
const rangeX = xMax - xMin || 1, rangeY = yMax - yMin || 1, pad = 30, drawW = w - pad * 2, drawH = h - pad * 2;
const toCanvas = (wx: number, wy: number) => ({ x: pad + ((wx - xMin) / rangeX) * drawW, y: pad + ((yMax - wy) / rangeY) * drawH });
const bgPath = missionStore.mission.backgroundImage;
if (bgPath) {
try {
const { convertFileSrc } = await import('@tauri-apps/api/core');
const img = new Image();
img.onload = () => {
ctx.globalAlpha = 0.4;
ctx.drawImage(img, 0, 0, w, h);
ctx.globalAlpha = 1.0;
drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
};
img.onload = () => { ctx.globalAlpha = 0.4; ctx.drawImage(img, 0, 0, w, h); ctx.globalAlpha = 1.0; drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records); };
img.onerror = () => drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
img.src = convertFileSrc(bgPath);
return; // drawOverlay will be called async
} catch { /* fall through to drawOverlay */ }
img.src = convertFileSrc(bgPath); return;
} catch { /* fall through */ }
}
drawOverlay(ctx, w, h, pad, drawW, drawH, xMin, xMax, yMin, yMax, rangeX, rangeY, toCanvas, records);
}
function drawOverlay(
ctx: CanvasRenderingContext2D, _w: number, _h: number, pad: number,
drawW: number, drawH: number, xMin: number, xMax: number, yMin: number, yMax: number,
_rangeX: number, _rangeY: number,
toCanvas: (wx: number, wy: number) => { x: number; y: number },
records: PathLineRecord[],
) {
// Grid
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
ctx.beginPath(); ctx.moveTo(pad + (i / 5) * drawW, pad); ctx.lineTo(pad + (i / 5) * drawW, pad + drawH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(pad, pad + (i / 5) * drawH); ctx.lineTo(pad + drawW, pad + (i / 5) * drawH); ctx.stroke();
}
// Scan area rectangle
function drawOverlay(ctx: CanvasRenderingContext2D, _w: number, _h: number, pad: number, drawW: number, drawH: number, xMin: number, xMax: number, yMin: number, yMax: number, _rangeX: number, _rangeY: number, toCanvas: (wx: number, wy: number) => { x: number; y: number }, records: PathLineRecord[]) {
ctx.strokeStyle = '#e0e0e0'; ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) { ctx.beginPath(); ctx.moveTo(pad + (i / 5) * drawW, pad); ctx.lineTo(pad + (i / 5) * drawW, pad + drawH); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pad, pad + (i / 5) * drawH); ctx.lineTo(pad + drawW, pad + (i / 5) * drawH); ctx.stroke(); }
const sc = missionStore.mission.scanConfig;
if (sc) {
const r1 = toCanvas(sc.xMin, sc.yMin);
const r2 = toCanvas(sc.xMax, sc.yMax);
ctx.strokeStyle = '#f0a020'; ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(r1.x, r1.y, r2.x - r1.x, r2.y - r1.y);
ctx.setLineDash([]);
ctx.fillStyle = '#f0a020'; ctx.font = '11px sans-serif';
ctx.fillText('扫描区域', r1.x + 4, r1.y - 4);
}
// Scan lines
if (sc) { const r1 = toCanvas(sc.xMin, sc.yMin); const r2 = toCanvas(sc.xMax, sc.yMax); ctx.strokeStyle = '#f0a020'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.strokeRect(r1.x, r1.y, r2.x - r1.x, r2.y - r1.y); ctx.setLineDash([]); ctx.fillStyle = '#f0a020'; ctx.font = '11px sans-serif'; ctx.fillText('扫描区域', r1.x + 4, r1.y - 4); }
for (const record of records) {
const start = toCanvas(record.targetXMinPosition, record.targetYPosition);
const end = toCanvas(record.targetXMaxPosition, record.targetYPosition);
ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y);
ctx.strokeStyle = '#2080f0'; ctx.lineWidth = 1.5; ctx.stroke();
const angle = Math.atan2(end.y - start.y, end.x - start.x);
ctx.beginPath();
ctx.moveTo(end.x, end.y);
ctx.lineTo(end.x - 6 * Math.cos(angle - 0.4), end.y - 6 * Math.sin(angle - 0.4));
ctx.lineTo(end.x - 6 * Math.cos(angle + 0.4), end.y - 6 * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fillStyle = '#2080f0'; ctx.fill();
const s = toCanvas(record.targetXMinPosition, record.targetYPosition);
const e = toCanvas(record.targetXMaxPosition, record.targetYPosition);
ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(e.x, e.y); ctx.strokeStyle = '#2080f0'; ctx.lineWidth = 1.5; ctx.stroke();
const a = Math.atan2(e.y - s.y, e.x - s.x);
ctx.beginPath(); ctx.moveTo(e.x, e.y); ctx.lineTo(e.x - 6 * Math.cos(a - 0.4), e.y - 6 * Math.sin(a - 0.4)); ctx.lineTo(e.x - 6 * Math.cos(a + 0.4), e.y - 6 * Math.sin(a + 0.4)); ctx.closePath(); ctx.fillStyle = '#2080f0'; ctx.fill();
}
// Labels
ctx.fillStyle = '#999'; ctx.font = '10px sans-serif';
ctx.fillText(xMin.toFixed(0), pad - 2, pad + drawH + 14);
ctx.fillText(xMax.toFixed(0), pad + drawW - 10, pad + drawH + 14);
ctx.fillText(yMin.toFixed(0), 2, pad + drawH + 4);
ctx.fillText(yMax.toFixed(0), 2, pad + 4);
ctx.fillText(xMin.toFixed(0), pad - 2, pad + drawH + 14); ctx.fillText(xMax.toFixed(0), pad + drawW - 10, pad + drawH + 14);
ctx.fillText(yMin.toFixed(0), 2, pad + drawH + 4); ctx.fillText(yMax.toFixed(0), 2, pad + 4);
}
async function handleViewPath() {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({
filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }],
multiple: false,
});
const selected = await open({ filters: [{ name: 'RecordLine3', extensions: ['RecordLine3'] }], multiple: false });
if (selected) await openPathFile(selected);
} catch { /* cancelled */ }
}
async function viewPathFile(filePath: string) {
if (!filePath) return;
await openPathFile(filePath);
}
async function viewPathFile(filePath: string) { if (filePath) await openPathFile(filePath); }
</script>
<style scoped>

View File

@ -1,8 +1,8 @@
# Spectral Insight Mission Plan - 更新日志
## v0.0.4 (2026-06-18)
## v0.0.5 (2026-06-18)
- feat: add circle drawing support in path planner
- fix: correct update.md for v0.0.3 and fix pre-commit hook
## v0.0.3 (2026-06-18)