feat: add parameter help icons with editable help.csv
This commit is contained in:
29
help.csv
Normal file
29
help.csv
Normal 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.
|
@ -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
2
src-tauri/Cargo.lock
generated
@ -3180,7 +3180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spectral-insight-mission-plan"
|
||||
version = "0.0.2"
|
||||
version = "0.0.4"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"chrono",
|
||||
|
||||
@ -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"
|
||||
|
||||
54
src-tauri/src/commands/help_commands.rs
Normal file
54
src-tauri/src/commands/help_commands.rs
Normal 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
|
||||
}
|
||||
@ -4,3 +4,4 @@ pub mod validation_commands;
|
||||
pub mod path_commands;
|
||||
pub mod devtools_commands;
|
||||
pub mod defaults_commands;
|
||||
pub mod help_commands;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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": {
|
||||
|
||||
59
src/components/common/HelpIcon.vue
Normal file
59
src/components/common/HelpIcon.vue
Normal 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>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user