feat: canvas drag/resize/selection, modal planner, default OneWay (唐超)

- Canvas: 坐标轴、顶点坐标、框拖拽、角点缩放、点击选中高亮
- 路径规划改为弹窗覆盖层,编辑状态不丢失
- 展开任务状态持久化到 Pinia store
- 扫描模式默认 OneWay
- 保存时 startTime/endTime/durationMinutes 清空为 ""
- exposureTime/frameRate/captureIntervalSeconds 默认 0
- 去除生成航线后的扫描线文字标注
This commit is contained in:
xin
2026-06-18 16:35:39 +08:00
parent c017129c9f
commit 02d490e714
12 changed files with 221 additions and 342 deletions

View File

@ -1,7 +1,7 @@
{
"name": "happa-mission-plan",
"private": true,
"version": "0.0.5",
"version": "0.0.6",
"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.4"
version = "0.0.5"
dependencies = [
"byteorder",
"chrono",

View File

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

View File

@ -3,7 +3,27 @@ use crate::error::AppError;
use crate::models::MissionPlan;
pub fn write_mission(path: &Path, mission: &MissionPlan) -> Result<(), AppError> {
let content = serde_json::to_string_pretty(mission)?;
// Clear computed timing fields before saving
let mut cleaned = mission.clone();
for task in &mut cleaned.tasks {
task.duration_minutes = 0.0;
task.estimated_duration_minutes = 0.0;
task.start_time = Some("".to_string());
task.end_time = Some("".to_string());
for sub in &mut task.sub_tasks {
sub.duration_minutes = 0.0;
sub.estimated_duration_minutes = 0.0;
sub.start_time = Some("".to_string());
sub.end_time = Some("".to_string());
if sub.capture_interval_seconds.is_none() { sub.capture_interval_seconds = Some(0); }
if sub.exposure_time.is_none() { sub.exposure_time = Some(0); }
if sub.frame_rate.is_none() { sub.frame_rate = Some(0); }
}
}
let content = serde_json::to_string_pretty(&cleaned)?;
let content = content
.replace("\"durationMinutes\": 0.0", "\"durationMinutes\": \"\"")
.replace("\"estimatedDurationMinutes\": 0.0", "\"estimatedDurationMinutes\": \"\"");
std::fs::write(path, content)?;
Ok(())
}

View File

@ -10,8 +10,12 @@ pub struct ScanRegion {
pub y_min: f64,
#[serde(rename = "yMax")]
pub y_max: f64,
#[serde(default = "default_shape")]
pub shape: String,
}
fn default_shape() -> String { "Rect".to_string() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraParams {
#[serde(rename = "fovDegrees")]
@ -67,7 +71,7 @@ impl Default for PlannerDefaults {
speed_y_cm_s: 5.0,
speed_x_start_cm_s: 20.0,
speed_x_scan_cm_s: 10.0,
mode: "Zigzag".to_string(),
mode: "OneWay".to_string(),
}
}
}

View File

@ -68,10 +68,10 @@ impl SubTask {
Self {
id: default_subtask_id(),
sub_task_type,
capture_interval_seconds: None,
capture_interval_seconds: Some(0),
default_render_band: Some(550),
exposure_time: None,
frame_rate: None,
exposure_time: Some(0),
frame_rate: Some(0),
path_line_file_path: String::new(),
duration_minutes: 0.0,
estimated_duration_minutes: 0.0,

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Spectral Insight Mission Plan",
"version": "0.0.5",
"version": "0.0.6",
"identifier": "com.spectral-insight.mission-plan",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -1,18 +1,8 @@
<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>
<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>
@ -22,210 +12,112 @@ import type { ScanRegion, ScanMode, ScanShape } from '../../types/path-plan';
import type { PathLineRecord } from '../../types/path-line';
const props = defineProps<{
regions: ScanRegion[];
records: PathLineRecord[];
mode: ScanMode;
backgroundImage?: string;
drawingMode: boolean;
drawingShape?: ScanShape;
records: PathLineRecord[]; mode: ScanMode; backgroundImage?: string;
drawingMode: boolean; drawingShape?: ScanShape; selectedIndex?: number;
}>();
const regions = defineModel<ScanRegion[]>('regions', { required: true });
const emit = defineEmits<{ 'add-region': [region: ScanRegion]; 'select-region': [index: number]; 'deselect': [] }>();
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 canvasContainer = ref<HTMLDivElement>(); const canvas = ref<HTMLCanvasElement>();
const isDrawing = ref(false); const isDragging = ref(false); const isResizing = ref(false);
const resizeCorner = ref(''); const dragTarget = ref(-1);
const dragStart = ref({ x: 0, y: 0 }); const regionStart = ref<ScanRegion | null>(null);
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 gs() { const r = canvasContainer.value?.getBoundingClientRect(); return { w: r?.width || 600, h: r?.height || 400 }; }
function tc(cx: number, cy: number) { const r = canvas.value!.getBoundingClientRect(); return { x: cx - r.left, y: cy - r.top }; }
function tw(cx: number, cy: number) { const { w, h } = gs(); const dw = w - padding * 2, dh = h - padding * 2; return { x: Math.max(0, Math.min(100, (cx - padding) / dw * 100)), y: Math.max(0, Math.min(100, 100 - (cy - padding) / dh * 100)) }; }
function w2c(wx: number, wy: number) { const { w, h } = gs(); const dw = w - padding * 2, dh = h - padding * 2; return { x: padding + (wx / 100) * dw, y: padding + ((100 - wy) / 100) * dh }; }
function findR(p: { x: number; y: number }): number { const r = regions.value; for (let i = r.length - 1; i >= 0; i--) { if (p.x >= r[i].xMin && p.x <= r[i].xMax && p.y >= r[i].yMin && p.y <= r[i].yMax) return i; } return -1; }
function findC(p: { x: number; y: number }): { i: number; c: string } | null { const th = 3; for (let i = 0; i < regions.value.length; i++) { const r = regions.value[i]; for (const [c, cx, cy] of [['tl', r.xMin, r.yMax] as const, ['tr', r.xMax, r.yMax] as const, ['bl', r.xMin, r.yMin] as const, ['br', r.xMax, r.yMin] as const]) { if (Math.abs(p.x - cx) < th && Math.abs(p.y - cy) < th) return { i, c }; } } return null; }
async function loadBG(p: string | undefined) {
if (!p) { bgImage.value = null; draw(); return; }
try { const { convertFileSrc } = await import('@tauri-apps/api/core'); const img = new Image(); img.onload = () => { bgImage.value = img; draw(); }; img.onerror = () => { bgImage.value = null; draw(); }; img.src = convertFileSrc(p); } catch { bgImage.value = null; draw(); }
}
function draw() {
const ctx = canvas.value?.getContext('2d');
if (!ctx) return;
const ctx = canvas.value?.getContext('2d'); if (!ctx) return;
const { w, h } = gs(); 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 dw = w - padding * 2, dh = h - padding * 2, ax = 16;
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.strokeStyle = '#e8e8e8'; ctx.lineWidth = 1;
for (let i = 0; i <= 10; i++) { const x = padding + (i / 10) * dw; ctx.beginPath(); ctx.moveTo(x, padding); ctx.lineTo(x, padding + dh); ctx.stroke(); }
for (let i = 0; i <= 10; i++) { const y = padding + (i / 10) * dh; ctx.beginPath(); ctx.moveTo(padding, y); ctx.lineTo(padding + dw, y); ctx.stroke(); }
ctx.strokeStyle = '#666'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(padding - ax, padding + dh); ctx.lineTo(padding + dw + ax, padding + dh); ctx.stroke();
ctx.beginPath(); ctx.moveTo(padding, padding + dh + ax); ctx.lineTo(padding, padding - ax); ctx.stroke();
ctx.fillStyle = '#888'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
for (let i = 0; i <= 10; i++) { const x = padding + (i / 10) * dw; ctx.fillText(String(i * 10), x, padding + dh + ax + 12); ctx.beginPath(); ctx.moveTo(x, padding + dh + ax - 3); ctx.lineTo(x, padding + dh + ax + 3); ctx.stroke(); }
ctx.textAlign = 'right';
for (let i = 0; i <= 10; i++) { const y = padding + (i / 10) * dh; ctx.fillText(String((10 - i) * 10), padding - ax - 4, y + 4); ctx.beginPath(); ctx.moveTo(padding - ax - 3, y); ctx.lineTo(padding - ax + 3, y); ctx.stroke(); }
ctx.textAlign = 'start'; ctx.font = '11px sans-serif';
ctx.fillText('X (cm)', padding + dw + ax - 10, padding + dh + ax + 24);
ctx.save(); ctx.translate(padding - ax - 24, 10); ctx.rotate(-Math.PI / 2); ctx.fillText('Y (cm)', 0, 0); ctx.restore();
ctx.strokeStyle = '#f0a020'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); const z1 = w2c(0, 0), z2 = w2c(100, 100); ctx.strokeRect(z1.x, z1.y, z2.x - z1.x, z2.y - z1.y); ctx.setLineDash([]);
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;
for (let i = 0; i < regions.value.length; i++) {
const r = regions.value[i]; const p1 = w2c(r.xMin, r.yMin), p2 = w2c(r.xMax, r.yMax);
const c = COLORS[i % COLORS.length]; const sel = i === props.selectedIndex;
ctx.strokeStyle = sel ? '#ff0000' : c; ctx.lineWidth = sel ? 3 : 2;
if (r.shape === 'Circle') { ctx.beginPath(); ctx.ellipse((p1.x + p2.x) / 2, (p1.y + p2.y) / 2, Math.abs(p2.x - p1.x) / 2, Math.abs(p2.y - p1.y) / 2, 0, 0, Math.PI * 2); ctx.stroke(); }
else { ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y); ctx.fillStyle = '#fff'; for (const [cx, cy] of [[p1.x, p1.y], [p2.x, p1.y], [p1.x, p2.y], [p2.x, p2.y]]) { ctx.fillRect(cx - 4, cy - 4, 8, 8); ctx.strokeRect(cx - 4, cy - 4, 8, 8); } }
ctx.fillStyle = c; ctx.font = 'bold 12px sans-serif'; ctx.fillText(`#${i + 1}`, p1.x + 4, p1.y - 4);
ctx.font = '10px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`(${r.xMin.toFixed(1)}, ${r.yMin.toFixed(1)})`, p1.x + 4, p2.y + 14);
ctx.textAlign = 'right'; ctx.fillText(`(${r.xMax.toFixed(1)}, ${r.yMax.toFixed(1)})`, p2.x - 4, p1.y - 8); ctx.textAlign = 'start';
}
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;
ctx.beginPath(); ctx.moveTo(x, padding); ctx.lineTo(x, padding + drawH); ctx.stroke();
}
// Draw all regions (rect or circle)
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;
if (r.shape === 'Circle') {
const cx = (p1.x + p2.x) / 2;
const cy = (p1.y + p2.y) / 2;
const rx = Math.abs(p2.x - p1.x) / 2;
const ry = Math.abs(p2.y - p1.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
} else {
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 shape
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]);
if (props.drawingShape === 'Circle') {
const cx = (p1.x + p2.x) / 2;
const cy = (p1.y + p2.y) / 2;
const rx = Math.abs(p2.x - p1.x) / 2;
const ry = Math.abs(p2.y - p1.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
}
ctx.setLineDash([]);
const p1 = w2c(drawingRect.value.xMin, drawingRect.value.yMin), p2 = w2c(drawingRect.value.xMax, drawingRect.value.yMax);
ctx.strokeStyle = '#f00'; ctx.lineWidth = 2; ctx.setLineDash([5, 5]);
if (props.drawingShape === 'Circle') { ctx.beginPath(); ctx.ellipse((p1.x + p2.x) / 2, (p1.y + p2.y) / 2, Math.abs(p2.x - p1.x) / 2, Math.abs(p2.y - p1.y) / 2, 0, 0, Math.PI * 2); ctx.stroke(); }
else { 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();
}
for (const r of props.records) { const s = w2c(r.targetXMinPosition, r.targetYPosition), e = w2c(r.targetXMaxPosition, r.targetYPosition); ctx.beginPath(); ctx.moveTo(s.x, s.y); ctx.lineTo(e.x, e.y); ctx.strokeStyle = '#18a058'; 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 = '#18a058'; ctx.fill(); }
}
}
function onMouseDown(e: MouseEvent) {
if (!props.drawingMode) return;
isDrawing.value = true;
dragStart.value = toCanvas(e.clientX, e.clientY);
const cp = tc(e.clientX, e.clientY);
if (props.drawingMode) { isDrawing.value = true; dragStart.value = cp; return; }
const wp = tw(cp.x, cp.y);
const corner = findC(wp);
if (corner && regions.value[corner.i].shape !== 'Circle') {
isResizing.value = true; resizeCorner.value = corner.c; dragTarget.value = corner.i;
regionStart.value = { ...regions.value[corner.i] }; dragStart.value = cp; return;
}
const hit = findR(wp);
if (hit >= 0) { isDragging.value = true; dragTarget.value = hit; regionStart.value = { ...regions.value[hit] }; dragStart.value = cp; emit('select-region', hit); return; }
// Clicked empty space
if (!props.drawingMode) { emit('deselect'); }
}
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 = {
shape: props.drawingShape ? props.drawingShape : "Rect",
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();
const cp = tc(e.clientX, e.clientY);
if (isDrawing.value && props.drawingMode) { const p1 = tw(dragStart.value.x, dragStart.value.y), p2 = tw(cp.x, cp.y); drawingRect.value = { shape: props.drawingShape || 'Rect', 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(); return; }
if (isDragging.value && dragTarget.value >= 0 && regionStart.value) { const dx = (cp.x - dragStart.value.x) / (gs().w - padding * 2) * 100, dy = -(cp.y - dragStart.value.y) / (gs().h - padding * 2) * 100; const rs = regionStart.value, rw = rs.xMax - rs.xMin, rh = rs.yMax - rs.yMin; const arr = [...regions.value]; arr[dragTarget.value] = { ...arr[dragTarget.value], xMin: Math.max(0, Math.min(100 - rw, rs.xMin + dx)), xMax: Math.max(rs.xMin + dx + 1, Math.min(100, rs.xMax + dx)), yMin: Math.max(0, Math.min(100 - rh, rs.yMin + dy)), yMax: Math.max(rs.yMin + dy + 1, Math.min(100, rs.yMax + dy)) }; regions.value = arr; draw(); return; }
if (isResizing.value && dragTarget.value >= 0 && regionStart.value) { const p1 = tw(dragStart.value.x, dragStart.value.y), p2 = tw(cp.x, cp.y); const dx = p2.x - p1.x, dy = p2.y - p1.y, rs = regionStart.value; let xMin = rs.xMin, xMax = rs.xMax, yMin = rs.yMin, yMax = rs.yMax; const c = resizeCorner.value; if (c.includes('l')) xMin = Math.min(rs.xMin + dx, rs.xMax - 1); if (c.includes('r')) xMax = Math.max(rs.xMax + dx, rs.xMin + 1); if (c.includes('b')) yMin = Math.min(rs.yMin + dy, rs.yMax - 1); if (c.includes('t')) yMax = Math.max(rs.yMax + dy, rs.yMin + 1); xMin = Math.max(0, xMin); xMax = Math.min(100, xMax); yMin = Math.max(0, yMin); yMax = Math.min(100, yMax); if (xMax - xMin > 1 && yMax - yMin > 1) { const arr = [...regions.value]; arr[dragTarget.value] = { ...arr[dragTarget.value], xMin, xMax, yMin, yMax }; regions.value = arr; 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, shape: props.drawingShape ? props.drawingShape : 'Rect' });
}
drawingRect.value = null;
draw();
}
if (isDrawing.value) { 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, shape: props.drawingShape || 'Rect' }); drawingRect.value = null; draw(); } return; }
if (isDragging.value || isResizing.value) { isDragging.value = false; isResizing.value = false; dragTarget.value = -1; regionStart.value = null; }
}
onMounted(() => {
loadBackgroundImage(props.backgroundImage);
window.addEventListener('resize', draw);
});
watch(() => [props.regions, props.records, props.mode, props.drawingMode, props.drawingShape], () => { draw(); });
watch(() => props.backgroundImage, (v) => { loadBackgroundImage(v); });
onMounted(() => { loadBG(props.backgroundImage); window.addEventListener('resize', draw); });
watch(() => [regions.value, props.records, props.mode, props.drawingMode, props.drawingShape, props.selectedIndex], () => { draw(); });
watch(() => props.backgroundImage, (v) => { loadBG(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;
}
.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

@ -10,38 +10,20 @@ export const useMissionStore = defineStore('mission', () => {
const mission = ref<MissionPlan>(createEmptyMission());
const currentFilePath = ref<string | null>(null);
const isDirty = ref(false);
const expandedTaskId = ref<string | null>(null);
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
}
} catch { /* ignore */ }
try {
const bg = await invoke<string | null>('load_default_background');
mission.value.backgroundImage = bg;
} catch {
// ignore
}
} catch { /* ignore */ }
currentFilePath.value = null;
isDirty.value = false;
}
@ -80,40 +62,25 @@ export const useMissionStore = defineStore('mission', () => {
}
async function addSubTask(taskId: number, subTaskType: SubTaskType) {
const result = await invoke<MissionPlan>('add_sub_task', {
mission: mission.value,
taskId,
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,
});
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,
});
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,
});
const result = await invoke<MissionPlan>('update_task', { mission: mission.value, task });
mission.value = result;
isDirty.value = true;
}
@ -138,24 +105,26 @@ export const useMissionStore = defineStore('mission', () => {
isDirty.value = true;
}
// 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 }
);
return {
mission,
currentFilePath,
isDirty,
taskCount,
createNew,
loadMission,
saveMission,
addTask,
copyTask,
removeTask,
addSubTask,
removeSubTask,
updateSubTask,
updateTask,
calculateSchedule,
runValidation,
updateScanConfig,
setBackgroundImage,
mission, currentFilePath, isDirty, expandedTaskId, taskCount,
createNew, loadMission, saveMission,
addTask, copyTask, removeTask,
addSubTask, removeSubTask, updateSubTask, updateTask,
calculateSchedule, runValidation,
updateScanConfig, setBackgroundImage,
};
});

View File

@ -59,7 +59,7 @@
</n-collapse-item>
</n-collapse>
<n-collapse v-if="missionStore.mission.tasks.length > 0" accordion>
<n-collapse v-if="missionStore.mission.tasks.length > 0" accordion v-model:expanded-name="expandedTask">
<n-collapse-item
v-for="task in missionStore.mission.tasks"
:key="task.id"
@ -197,6 +197,14 @@
<n-button block @click="showTypeModal = false">取消</n-button>
</template>
</n-modal>
<!-- Planner modal overlay -->
<div v-if="showPlanner" style="position:fixed;inset:0;z-index:1000;background:#fff;overflow:hidden;">
<PathPlannerView
:task-id="plannerTaskId"
:sub-task-id="plannerSubTaskId"
@close="showPlanner = false"
/>
</div>
</div>
</template>
@ -217,6 +225,7 @@ 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';
import PathPlannerView from '../views/PathPlannerView.vue';
const router = useRouter();
const message = useMessage();
@ -224,6 +233,16 @@ const dialog = useDialog();
const missionStore = useMissionStore();
const validationStore = useValidationStore();
const expandedTask = computed({
get: () => missionStore.expandedTaskId,
set: (v) => (missionStore.expandedTaskId = v),
});
const showPlanner = ref(false);
const plannerTaskId = ref<number | null>(null);
const plannerSubTaskId = ref<string | null>(null);
// function onExpandedChange(val: string | null) {
const showTypeModal = ref(false);
const typeModalTaskId = ref<number | null>(null);
@ -364,9 +383,10 @@ async function browsePathLine(sub: SubTask) {
}
function goToPlanner(taskId: number, sub: SubTask) {
router.push({ path: '/planner', query: { taskId: String(taskId), subTaskId: sub.id } });
plannerTaskId.value = taskId;
plannerSubTaskId.value = sub.id;
showPlanner.value = true;
}
async function openPathFile(path: string) {
try {
const file = await invoke('load_path_line', { path });

View File

@ -1,13 +1,13 @@
<template>
<div class="path-planner">
<n-space style="padding: 8px" align="center">
<n-button size="small" @click="goBack">
<n-button size="small" @click="$emit('close')">
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
返回
</n-button>
<n-button size="small" :type="drawingMode && currentShape === 'Rect' ? 'warning' : 'default'" @click="startDraw('Rect')">
<template #icon><n-icon><SquareOutline /></n-icon></template>
画框
矩形
</n-button>
<n-button size="small" :type="drawingMode && currentShape === 'Circle' ? 'warning' : 'default'" @click="startDraw('Circle')">
<template #icon><n-icon><EllipseOutline /></n-icon></template>
@ -15,7 +15,11 @@
</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" @click="deleteSelected" :disabled="selectedIndex < 0">
<template #icon><n-icon><CloseOutline /></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>
@ -30,12 +34,15 @@
<n-split direction="horizontal" :default-size="0.6">
<template #1>
<ScanCanvas
:regions="regions"
v-model:regions="regions"
:records="previewRecords"
:mode="scanMode"
:background-image="missionStore.mission.backgroundImage || undefined"
:drawing-mode="drawingMode"
:drawing-shape="currentShape"
:selected-index="selectedIndex"
@select-region="selectedIndex = $event"
@deselect="selectedIndex = -1"
@add-region="onAddRegion"
/>
</template>
@ -58,9 +65,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMessage } from 'naive-ui';
import { ArrowBackOutline, SquareOutline, EllipseOutline, TrashOutline, MapOutline, SaveOutline } from '@vicons/ionicons5';
import { ArrowBackOutline, SquareOutline, EllipseOutline, TrashOutline, CloseOutline, 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';
@ -70,21 +76,23 @@ import type { ScanRegion, CameraParams, ScanMode, ScanShape } from '../types/pat
import type { PlannerDefaults } from '../types/mission';
import { DEVICE_FOV } from '../utils/constants';
const router = useRouter();
const route = useRoute();
const props = defineProps<{
taskId: number | null;
subTaskId: string | null;
}>();
defineEmits<{ close: [] }>();
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);
const currentShape = ref<ScanShape>('Rect');
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);
if (props.taskId === null || props.subTaskId === null) return 30;
const task = missionStore.mission.tasks.find(t => t.id === props.taskId);
const sub = task?.subTasks.find(s => s.id === props.subTaskId);
return (sub && DEVICE_FOV[sub.type]) || 30;
}
@ -94,13 +102,12 @@ const coverageRate = ref(30);
const speedY = ref(5);
const speedXScan = ref(10);
const speedXStart = ref(20);
const scanMode = ref<ScanMode>('Zigzag');
const scanMode = ref<ScanMode>('OneWay');
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;
@ -111,38 +118,32 @@ invoke<PlannerDefaults>('load_planner_defaults').then(p => {
}).catch(() => {});
function startDraw(shape: ScanShape) {
if (drawingMode.value && currentShape.value === shape) {
drawingMode.value = false;
} else {
drawingMode.value = true;
currentShape.value = shape;
}
drawingMode.value = true;
currentShape.value = shape;
}
function onAddRegion(region: ScanRegion) {
region.shape = currentShape.value;
regions.value.push(region);
drawingMode.value = false;
message.success(`已添加框 #${regions.value.length}`);
}
function clearRects() {
regions.value = [];
generatedRecords.value = [];
estimatedTime.value = 0;
message.info('已清除所有框');
}
const selectedIndex = ref(-1);
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(() => {});
invoke('save_planner_defaults', {
config: { fovDegrees: camera.value.fovDegrees, heightCm: camera.value.heightCm, coverageRate: coverageRate.value, speedYCmS: speedY.value, speedXStartCmS: speedXStart.value, speedXScanCmS: speedXScan.value, mode: scanMode.value },
}).catch(() => {});
}
function deleteSelected() {
if (selectedIndex.value < 0) return;
regions.value.splice(selectedIndex.value, 1);
selectedIndex.value = -1;
generatedRecords.value = [];
estimatedTime.value = 0;
}
function onParamChange(params: any) {
@ -159,68 +160,37 @@ async function generatePath() {
if (!regions.value.length) return;
try {
const file = await invoke<PathLineFile>('generate_scan_paths_multi', {
regions: regions.value,
shapes: regions.value.map(r => r.shape),
camera: camera.value,
coverageRate: coverageRate.value,
speedYCmS: speedY.value,
speedXStartCmS: speedXStart.value,
speedXScanCmS: speedXScan.value,
mode: scanMode.value,
regions: regions.value, shapes: regions.value.map(r => r.shape),
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,
shapes: regions.value.map(r => r.shape),
camera: camera.value,
coverageRate: coverageRate.value,
speedYCmS: speedY.value,
speedXStartCmS: speedXStart.value,
speedXScanCmS: speedXScan.value,
mode: scanMode.value,
regions: regions.value, shapes: regions.value.map(r => r.shape),
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);
}
} 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'] }],
});
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 });
await invoke('save_path_line', { path: finalPath, file: { count: generatedRecords.value.length, records: generatedRecords.value } });
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('已自动填入子任务航线路径');
}
}
if (props.taskId !== null && props.subTaskId !== null) {
const task = missionStore.mission.tasks.find(t => t.id === props.taskId);
const sub = task?.subTasks.find(s => s.id === props.subTaskId);
if (sub) { sub.pathLineFilePath = finalPath; await missionStore.updateSubTask(props.taskId, { ...sub }); message.success('已自动填入子任务航线路径'); }
}
}
} catch (e) {
message.error('保存失败: ' + e);
}
}
function goBack() {
router.push('/editor');
} catch (e) { message.error('保存失败: ' + e); }
}
</script>

View File

@ -1,5 +1,9 @@
# Spectral Insight Mission Plan - 更新日志
## v0.0.6 (2026-06-18)
- feat: add parameter help icons with editable help.csv
## v0.0.5 (2026-06-18)
- fix: correct update.md for v0.0.3 and fix pre-commit hook