codex整理结果

This commit is contained in:
ywb
2026-05-30 00:16:10 +08:00
parent 3d892bfaf6
commit 74ec30ba3f
14 changed files with 45 additions and 4100 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
+28 -48
View File
@@ -1208,35 +1208,16 @@ def api_agv_stop():
@app.route("/api/agv/reset", methods=["POST"])
def api_agv_reset():
"""撞物体后复位 - 停止运动并尝试重新上电"""
"""撞物体后复位 - 停止运动并重新检查 ROS2 连接"""
import time
if not gs.agv_controller:
return jsonify({"ok": False, "error": "AGV 控制器未初始化"}), 400
try:
# 1. 先停止运动
gs.agv_controller.stop()
time.sleep(0.5)
# 2. 检查 AGV 对象是否存在
agv = gs.agv_controller._agv
if not agv:
return jsonify({"ok": False, "error": "AGV 未连接,请重新连接"}), 400
# 3. 检查电源状态
power_on = agv.is_power_on()
if not power_on:
# 撞物体后可能自动断电保护,尝试重新上电
agv.power_on()
time.sleep(2)
power_on = agv.is_power_on()
if power_on:
gs.agv_controller._connected = True
return jsonify({"ok": True, "message": "复位成功,已重新上电"})
else:
return jsonify({"ok": False, "error": "上电失败,请检查 AGV 状态"}), 500
else:
# 电源正常,只需要停止
return jsonify({"ok": True, "message": "复位成功,AGV 已停止"})
if gs.agv_controller.connect():
return jsonify({"ok": True, "message": "复位成功,AGV 已停止并重新连接"})
return jsonify({"ok": False, "error": "AGV 已停止,但 ROS2 连接检查失败"}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@@ -1259,13 +1240,10 @@ def api_mission_start():
}
print(f"[Mission] options: {options}")
# 清除可能存在的旧实例,确保可以启动
if hasattr(MissionExecutorV3, "_instance"):
MissionExecutorV3._instance = None
gs.state = State.IDLE
if hasattr(MissionExecutorV3, "_instance") and MissionExecutorV3._instance:
existing = getattr(MissionExecutorV3, "_instance", None)
if existing and existing.report.get("status") not in ("idle", "completed"):
return jsonify({"ok": False, "error": "任务已在运行中"}), 400
MissionExecutorV3._instance = None
def run(single_step):
from config import AGV_CONFIG
@@ -1276,28 +1254,30 @@ def api_mission_start():
}
executor = MissionExecutorV3(config)
conn = executor.connect_all()
if not conn.get("agv") or not conn.get("arm"):
gs.mission_report = {"error": "连接失败", "details": conn}
gs.state = State.IDLE
return
try:
conn = executor.connect_all()
if not conn.get("agv") or not conn.get("arm"):
gs.mission_report = {"error": "连接失败", "details": conn}
gs.state = State.IDLE
return
gs.state = State.RUNNING
gs.state = State.RUNNING
machines_list = gs.machines_config if isinstance(gs.machines_config, list) else gs.machines_config.get("machines", [])
models_list = gs.models_config if isinstance(gs.models_config, list) else gs.models_config.get("models", [])
machines_list = gs.machines_config if isinstance(gs.machines_config, list) else gs.machines_config.get("machines", [])
models_list = gs.models_config if isinstance(gs.models_config, list) else gs.models_config.get("models", [])
report = executor.execute_mission(
mission_config=gs.mission_config,
machines=machines_list,
qr_configs=gs.qr_config,
models=models_list,
single_step=single_step,
options=options,
)
gs.mission_report = report
executor.disconnect_all()
gs.state = State.IDLE if report.get("error") is None else State.PAUSED
report = executor.execute_mission(
mission_config=gs.mission_config,
machines=machines_list,
qr_configs=gs.qr_config,
models=models_list,
single_step=single_step,
options=options,
)
gs.mission_report = report
gs.state = State.IDLE if report.get("error") is None else State.PAUSED
finally:
executor.disconnect_all()
thread = threading.Thread(target=run, args=(single_step,), daemon=True)
thread.start()
-906
View File
@@ -1,906 +0,0 @@
# -*- coding: utf-8 -*-
"""
任务执行器 V3 — M×N 网格蛇形路径拍摄
工作流:
1. 根据 grid 生成蛇形路径(奇数行左→右,偶数行右→左)
2. 逐台机器:
- 正面:导航 → 扫码(多姿态重试) → 查机型 → 按姿态拍照
- 背面:导航 → 按姿态拍照
3. 回到 (0,0)
"""
import os
import time
import json
import logging
import threading
import subprocess
import math
from typing import Optional, Dict, List
from enum import Enum
import requests
import cv2
import numpy as np
from utils.nav2_navigator import Nav2Navigator, Nav2Status
logger = logging.getLogger(__name__)
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
from config import ARM_CAMERA_CONFIG
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
PHOTOS_DIR = "/home/elephant/photos"
# 二维码扫描重试参数
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
QR_POSE_WAIT = 1.5 # 调整姿态后等待时间
MANUAL_QR_TIMEOUT = 300 # 5分钟超时
class MissionStatus(str, Enum):
IDLE = "idle"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
WAITING_QR = "waiting_qr"
WAITING_ERROR = "waiting_error"
WAITING_STEP = "waiting_step"
class MissionExecutorV3:
"""任务执行器 V3 — M×N 网格蛇形路径"""
_instance = None # 单例,供外部停止用
def __init__(self, config: dict):
self.config = config
self.status = MissionStatus.IDLE
MissionExecutorV3._instance = self
# 实时状态报告
self.report = {
"status": "idle",
"step": "",
"progress": 0,
"total": 0,
"log": [],
"error": None,
}
# 线程同步
self._stop = threading.Event()
self._pause = threading.Event()
self._pause.set() # 初始不暂停
self._qr_event = threading.Event()
self._qr_value: Optional[str] = None
# 错误弹窗
self._error_choice = None # "skip" or "abort"
# 单步执行
self._single_step_mode = False
self._step_choice = None # "confirm", "retry", "abort"
self._error_mode = False # True when waiting for error resolution
# 错误弹窗
self._error_choice = None # "skip" or "abort"
# 单步执行
self._single_step_mode = False
self._step_choice = None # "confirm", "retry", "abort"
self._error_mode = False # True when waiting for error resolution
# 设备
from .arm_client import ArmClient
self.arm_client = ArmClient(
config["arm"]["host"],
config["arm"]["port"]
)
from .agv_controller_ros2 import AGVController
self.agv = AGVController(
device=config.get("device", "/dev/agvpro_controller"),
baudrate=config.get("baudrate", 1000000)
)
# Nav2 导航器(直接使用 rclpy BasicNavigator API,比 subprocess 更可靠)
self._nav = Nav2Navigator()
# 速度控制(默认值,可在 execute_mission 时覆写)
self.arm_speed = 500
self.agv_speed = 0.5
# ==================== 连接 ====================
def connect_all(self) -> Dict[str, bool]:
results = {}
results["agv"] = self.agv.connect()
results["arm"] = self.arm_client.connect()
return results
def disconnect_all(self):
if self.arm_client:
self.arm_client.close()
self.agv.disconnect()
# ==================== 主任务流程 ====================
def execute_mission(
self,
mission_config: dict,
machines: list,
qr_configs: list,
models: list,
single_step: bool = False,
options: dict = None,
) -> dict:
"""
执行完整拍摄任务。
Args:
options: 任务步骤控制开关
- arm_init: 是否执行机械臂位置初始化
- agv_move: 是否执行AGV移动
- qr_scan: 是否执行二维码识别
- front_photo: 是否执行正面拍照
- back_photo: 是否执行背面拍照
"""
"""
执行完整拍摄任务。
Args:
mission_config: {rows, cols, grid, positions}
machines: [{id, row, col, front: {coords}, back: {coords}}]
qr_configs: [{id, name, joint_angles: [a1..a6]}]
models: [{id, name, poses: [{name, arm_angles}], poses_back: [...]}]
Returns:
执行报告 dict
"""
self.status = MissionStatus.RUNNING
self.report = {"status": "running", "step": "初始化", "progress": 0, "total": 0, "log": [], "error": None}
self._stop.clear()
self._pause.set()
start_time = time.time()
try:
rows = int(mission_config.get("rows", 1))
cols = int(mission_config.get("cols", 1))
grid = mission_config.get("grid", [])
positions = mission_config.get("positions", [])
# 如果 grid 为空,从 machines 重建
if not grid or all(not any(rw) if isinstance(rw, list) else True for rw in grid):
try:
cfg_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
with open(os.path.join(cfg_dir, "machines_config.json")) as jf:
machines = json.load(jf)
grid = [[False] * cols for _ in range(rows)]
for m in machines:
r = int(m.get("row", 0))
c = int(m.get("col", 0))
if 0 <= r < rows and 0 <= c < cols:
grid[r][c] = True
except Exception:
grid = [[False] * cols for _ in range(rows)]
# 1. 生成蛇形路径
path = MissionExecutorV3._build_snake_path(rows, cols, grid)
if not path:
self._log("❌ 网格中没有点位,任务终止")
self.report["error"] = "No points in grid"
return self._finish(0)
# 统计总机器数(用于进度计算)
total_machines = 0
for rw in grid:
for v in rw:
if v:
total_machines += 1
self.report["total"] = total_machines
# 扫码结果缓存:正面扫到的 QR 传给背面
qr_cache = {}
# 初始化任务列表(机器级别)
self.report["tasks"] = []
for r in range(rows):
for c in range(cols):
if MissionExecutorV3._has_machine(grid, r, c):
self.report["tasks"].append({
"row": r, "col": c,
"machine_id": f"m_{r}_{c}",
"label": f"{r+1}-{c+1}",
"status": "pending",
"step": "等待",
"qr_value": None,
"photos_front": 0,
"photos_back": 0,
})
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
# 任务步骤控制开关
if options is None:
options = {}
opt_arm_init = options.get("arm_init", True)
opt_agv_move = options.get("agv_move", True)
if opt_agv_move:
opt_arm_init = True
opt_qr_scan = options.get("qr_scan", True)
opt_front_photo = options.get("front_photo", True)
opt_back_photo = options.get("back_photo", True)
self._log(f"⚙️ 任务步骤: agv_move={opt_agv_move}, "
f"qr_scan={opt_qr_scan}, front={opt_front_photo}, back={opt_back_photo}")
# 机械臂初始姿态(AGV 移动前恢复)
arm_initial_pose = mission_config.get("arm_initial_pose", [0.0] * 6)
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
# 速度控制(从前端传入)
self.arm_speed = int(options.get("arm_speed", 500))
self.agv_speed = float(options.get("agv_speed", 0.5))
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
# 设置 Nav2 导航速度(仅在任务开始时设一次)
if opt_agv_move:
self._set_nav_speed()
# 进度统计
max_actions = total_machines * 2 # 每台机器正面+背面
completed_actions = 0
# 2. 逐点位蛇形执行
pi = 0
while pi < len(path):
if self._stop.is_set():
self._log("⏹️ 任务已停止")
break
self._wait_pause()
pr, c = path[pi] # pr = 点位行, c = 列
# 判断该点位需要做什么
has_front = pr < rows and MissionExecutorV3._has_machine(grid, pr, c)
has_back = pr > 0 and MissionExecutorV3._has_machine(grid, pr - 1, c)
if not has_front and not has_back:
self._log(f"📍 点位 ({pr},{c}) → 空位")
pi += 1
continue
# 日志 & 步骤更新
rl_front = pr + 1 if has_front else 0
cl_front = c + 1 if has_front else 0
rl_back = pr if has_back else 0
cl_back = c + 1 if has_back else 0
log_parts = []
if has_front:
log_parts.append(f"正面:机器{rl_front}-{cl_front}")
task = self._get_task(pr, c)
if task:
task["status"] = "active"
task["step"] = "正面扫码"
if has_back:
log_parts.append(f"背面:机器{rl_back}-{cl_back}")
task = self._get_task(pr - 1, c)
if task:
task["step"] = "背面拍照"
self._log(f"📍 点位 ({pr},{c}) → {' & '.join(log_parts)}")
self._step(f"点位({pr},{c})")
# 恢复机械臂初始姿态(AGV 移动前)
if opt_arm_init and has_arm_pose and opt_agv_move:
self._log(" 🦾 恢复机械臂初始姿态")
try:
self.arm_client.set_angles(arm_initial_pose, speed=self.arm_speed)
self._wait_arm_ready(arm_initial_pose)
except Exception as e:
self._log(f" ⚠️ 机械臂初始化失败: {e}")
# 导航到该点位的坐标
if opt_agv_move:
# 找该点位的任意有效坐标(正面/背面坐标相同)
pos = MissionExecutorV3._find_any_position(positions, pr, c)
if pos and MissionExecutorV3._has_coords(pos):
if not self._navigate(pos, f"点位({pr},{c})"):
self._log(f"⚠️ 导航失败,尝试继续")
choice = self._wait_error(f"点位({pr},{c})导航失败")
if choice == "abort":
break
else:
self._log(f"⚠️ 点位({pr},{c})无有效坐标")
else:
self._log(" ⏭️ 跳过AGV移动")
# --- 正面操作(机器 pr,c 的正面) ---
qr_value = None
if has_front and not self._stop.is_set():
self._wait_pause()
if opt_qr_scan:
qr_value = self._scan_qr_with_poses(qr_configs)
if self._stop.is_set():
break
else:
self._log(" ⏭️ 跳过二维码识别(正面)")
qr_cache[(pr, c)] = qr_value
task = self._get_task(pr, c)
if task and qr_value:
task["qr_value"] = qr_value
if task:
task["step"] = "正面拍照"
model_name = self._lookup_model(qr_value)
self._log(f" 🏷️ 机型: {model_name}")
if opt_front_photo and not self._stop.is_set():
model = self._find_model(models, model_name)
if model:
self._shoot(model, "front", rl_front, cl_front, qr_value or "unknown")
else:
self._log(f" ⚠️ 未找到机型 {model_name}")
else:
self._log(" ⏭️ 跳过正面拍照")
completed_actions += 1
# --- 背面操作(机器 pr-1,c 的背面) ---
if has_back and not self._stop.is_set():
self._wait_pause()
back_qr = qr_cache.get((pr - 1, c), "unknown")
task = self._get_task(pr - 1, c)
if task:
task["step"] = "背面拍照"
model_name = self._lookup_model(back_qr)
self._log(f" 🏷️ 机型(背面): {model_name}")
if opt_back_photo and not self._stop.is_set():
model = self._find_model(models, model_name)
if model:
self._shoot(model, "back", rl_back, cl_back, back_qr)
else:
self._log(f" ⚠️ 未找到机型 {model_name}")
else:
self._log(" ⏭️ 跳过背面拍照")
completed_actions += 1
if task:
task["status"] = "completed"
task["step"] = "完成"
# 更新进度
if max_actions:
self.report["progress"] = min(int(completed_actions / max_actions * 100), 99)
# 单步执行
if single_step and not self._stop.is_set():
choice = self._wait_step_confirm(
rl_front if has_front else rl_back,
cl_front if has_front else cl_back
)
if choice == "abort":
break
elif choice == "retry":
if has_front:
task = self._get_task(pr, c)
if task:
task["status"] = "pending"
task["step"] = "重试开始"
completed_actions = max(0, completed_actions - 2)
continue
pi += 1
# 3. 回到出发点
if not self._stop.is_set() and opt_agv_move:
self._step("返回出发点")
self._log("→ 返回 (0, 0)")
self._nav2_go_to_point(0, 0, 0, timeout_sec=60)
elif not self._stop.is_set():
self._log("⏭️ 跳过返回出发点")
elapsed = time.time() - start_time
return self._finish(elapsed)
except Exception as e:
self._log(f"❌ 任务异常: {e}")
logger.exception("execute_mission 崩溃")
self.report["error"] = str(e)
self.status = MissionStatus.IDLE
self.report["status"] = "idle"
return self.report
def _finish(self, elapsed: float) -> dict:
if self._stop.is_set():
self._step("已停止")
else:
self._step("完成")
self._log(f"✅ 任务完成 ({elapsed:.0f}s)")
self.report["progress"] = 100
self.status = MissionStatus.IDLE
self.report["status"] = "idle"
return self.report
# ==================== 蛇形路径 ====================
@staticmethod
def _build_snake_path(rows: int, cols: int, grid: list) -> list:
"""生成点位级蛇形路径:遍历点位行 0→rows,奇数行左→右,偶数行右→左
每个点位 (pr, c) 同时服务:
- 正面:机器 (pr, c)(如果 pr < rows 且 grid[pr][c] 为真)
- 背面:机器 (pr-1, c)(如果 pr > 0 且 grid[pr-1][c] 为真)
按此路径走完所有点位,是最短的蛇形走位,不再反复横跳。
"""
path = []
for pr in range(rows + 1): # 点位行 = 机器行 + 1
if pr % 2 == 0:
for c in range(cols):
path.append((pr, c))
else:
for c in range(cols - 1, -1, -1):
path.append((pr, c))
return path
@staticmethod
def _has_machine(grid: list, r: int, c: int) -> bool:
if not grid or r >= len(grid):
return False
row = grid[r]
if isinstance(row, list):
return c < len(row) and bool(row[c])
return False
@staticmethod
def _build_grid_from_machines(rows: int, cols: int, machines: list) -> list:
"""从机器列表重建 grid 矩阵"""
if not machines:
return [[False] * cols for _ in range(rows)]
max_r = max(int(m.get("row", 0)) for m in machines) + 1
max_c = max(int(m.get("col", 0)) for m in machines) + 1
gr = max(rows, max_r)
gc = max(cols, max_c)
grid = [[False] * gc for _ in range(gr)]
for m in machines:
r = int(m.get("row", 0))
c = int(m.get("col", 0))
if 0 <= r < gr and 0 <= c < gc:
grid[r][c] = True
return grid
@staticmethod
def pre_generate_tasks(mission_config: dict) -> list:
"""从网格配置预生成任务列表(用于 UI 展示,无需启动执行器)"""
rows = int(mission_config.get("rows", 1))
cols = int(mission_config.get("cols", 1))
grid = mission_config.get("grid", [])
# 如果 grid 为空但从 machines 重建
if not grid and machines:
grid = MissionExecutorV3._build_grid_from_machines(rows, cols, machines)
if grid:
rows = len(grid)
cols = len(grid[0]) if grid else cols
path = MissionExecutorV3._build_snake_path(rows, cols, grid)
tasks = []
for (r, c) in path:
tasks.append({
"row": r, "col": c,
"machine_id": f"m_{r}_{c}",
"label": f"{r+1}-{c+1}",
"status": "pending",
"step": "等待",
"qr_value": None,
"photos_front": 0,
"photos_back": 0,
})
return tasks
# ==================== 点位查找 ====================
@staticmethod
def _find_any_position(positions: list, row: int, col: int) -> Optional[dict]:
"""查找点位的任意有效坐标(正面/背面坐标相同,取第一个有坐标的)"""
for side in ("front", "back"):
p = MissionExecutorV3._find_point(positions, row, col, side)
if p and MissionExecutorV3._has_coords(p):
return p
return None
@staticmethod
def _find_point(positions: list, row: int, col: int, side: str) -> Optional[dict]:
for p in positions:
if p.get("row") == row and p.get("col") == col and p.get("side") == side:
return p
return None
@staticmethod
def _has_coords(point: dict) -> bool:
coords = point.get("coords", [])
return len(coords) >= 2 and (coords[0] != 0 or coords[1] != 0)
# ==================== 导航 ====================
def _set_nav_speed(self) -> bool:
"""动态设置 Nav2 控制器最大速度参数"""
try:
# 尝试设置 controller_server 的线速度参数
vel = self.agv_speed
cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 param set /controller_server FollowPath.desired_linear_vel {vel:.2f} 2>/dev/null || true'"
subprocess.run(cmd, shell=True, timeout=5, capture_output=True)
self._log(f" 🚀 AGV 速度设为 {vel:.1f} m/s")
return True
except Exception as e:
logger.warning(f"设置 AGV 速度失败: {e}")
return False
def _navigate(self, point: dict, label: str) -> bool:
coords = point["coords"]
x, y = float(coords[0]), float(coords[1])
yaw = float(coords[2]) if len(coords) >= 3 else 0.0
self._log(f" 🧭 导航到{label}点位 ({x:.2f}, {y:.2f}, yaw={math.degrees(yaw):.0f}°)")
return self._nav2_go_to_point(x, y, yaw)
# ==================== 二维码扫描 ====================
def _wait_arm_ready(self, target_angles: list, timeout: float = 15.0, tolerance: float = 2.0) -> bool:
"""等待机械臂稳定到目标角度(±tolerance 度),超时返回 False"""
if not self.arm_client:
return True
deadline = time.time() + timeout
while time.time() < deadline:
try:
ok, current = self.arm_client.get_angles()
if ok and current and len(current) >= 6:
# get_angles() 返回角度(度),与 target_angles 直接比较
if all(abs(current[i] - target_angles[i]) < tolerance for i in range(6)):
return True
except Exception:
pass
time.sleep(0.5)
self._log(f" ⚠️ 机械臂稳定等待超时 (target={target_angles})")
return False
def _scan_qr_with_poses(self, qr_configs: list) -> Optional[str]:
"""用二维码配置中的姿态依次尝试,逐一调整姿态+等2秒+扫码,全部失败才弹框"""
if not qr_configs:
self._log(f" ⚠️ 无二维码配置")
return self._request_manual_qr()
self._log(f" 🔍 尝试 {len(qr_configs)} 个二维码姿态...")
for i, qc in enumerate(qr_configs):
if self._stop.is_set():
return None
self._wait_pause()
angles = qc.get("joint_angles", [])
if not angles or len(angles) < 6:
continue
name = qc.get("name", f"姿态{i+1}")
self._log(f" [{i+1}/{len(qr_configs)}] {name}")
# 调整机械臂姿态
if self.arm_client:
self.arm_client.set_angles(angles, speed=self.arm_speed)
self._wait_arm_ready(angles)
# 读取摄像头并扫码
qr = self._decode_qr_from_arm()
if qr:
self._log(f" ✅ 识别成功: {qr}")
return qr
self._log(f"{name} 未识别到二维码")
self._log(f" ⚠️ 全部 {len(qr_configs)} 个姿态均未识别到二维码")
return self._request_manual_qr()
def _decode_qr_from_arm(self) -> Optional[str]:
"""从机械臂摄像头取一帧,识别二维码"""
for attempt in range(3):
try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
if resp.status_code != 200 or not resp.content:
continue
arr = np.frombuffer(resp.content, dtype=np.uint8)
frame = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if frame is None:
continue
detector = cv2.QRCodeDetector()
data, bbox, _ = detector.detectAndDecode(frame)
if data and len(data) > 0:
return data
except Exception:
pass
time.sleep(0.5)
return None
def _request_manual_qr(self) -> Optional[str]:
"""暂停任务,等待手动输入"""
self.status = MissionStatus.WAITING_QR
self.report["status"] = "waiting_qr"
self.report["step"] = "等待手动输入二维码"
self._log(" ⌨️ 弹窗等待手动输入二维码...")
self._qr_event.clear()
if self._qr_event.wait(timeout=MANUAL_QR_TIMEOUT):
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
self._log(f" ✏️ 手动输入: {self._qr_value}")
return self._qr_value
else:
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
self._log(f" ⚠️ 等待超时({MANUAL_QR_TIMEOUT}s),跳过")
return None
def set_manual_qr(self, value: str):
self._qr_value = value.strip()
self._qr_event.set()
# ==================== 机型查询 ====================
def _lookup_model(self, qr_value: Optional[str]) -> str:
"""TODO: 后续通过 HTTP 接口查询机型"""
return "机器1"
@staticmethod
def _find_model(models: list, name: str) -> Optional[dict]:
"""在机型列表中找到匹配的机型"""
for m in models:
if m.get("name") == name or m.get("id") == name:
return m
# fallback: 第一个机型
return models[0] if models else None
# ==================== 姿态拍照 ====================
def _shoot(self, model: dict, side: str, row: int, col: int, qr_value: str):
"""按机型配置的所有姿态依次拍照"""
# 更新任务照片计数
task = self._get_task(row - 1, col - 1)
side_label = "正面" if side == "front" else "背面"
poses = model.get("poses", []) if side == "front" else model.get("poses_back", [])
if not poses:
self._log(f" ⚠️ 机型无{side_label}姿态配置")
return
self._log(f" 📷 {side_label}拍照 ({len(poses)} 个姿态)")
for pi, pose in enumerate(poses):
if self._stop.is_set():
break
self._wait_pause()
angles = pose.get("arm_angles", [])
if not angles or len(angles) < 6:
self._log(f" 跳过 {pose.get('name', f'姿态{pi+1}')}: 无效角度")
continue
name = pose.get("name", f"{side_label}-{pi+1}")
self._log(f" 🎯 {name}")
# 调整机械臂
if self.arm_client:
self.arm_client.set_angles(angles, speed=self.arm_speed)
self._wait_arm_ready(angles)
# 拍照
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value)
if path:
self._log(f" 💾 {os.path.basename(path)}")
def _capture_arm_photo(self, row: int, col: int, side: str,
pose_idx: int, qr_value: str) -> Optional[str]:
"""从机械臂摄像头拍照存本地"""
try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
if resp.status_code != 200 or not resp.content:
logger.error("arm snapshot 请求失败")
return None
os.makedirs(PHOTOS_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
fname = f"{ts}_r{row}c{col}_{side}_p{pose_idx}_{qr_value[:20]}.jpg"
fpath = os.path.join(PHOTOS_DIR, fname)
with open(fpath, "wb") as f:
f.write(resp.content)
return fpath
except Exception as e:
logger.error(f"拍照异常: {e}")
return None
# ==================== 控制 ====================
def _wait_pause(self):
"""等待暂停状态解除"""
self._pause.wait()
def pause(self):
self._pause.clear()
self.status = MissionStatus.PAUSED
self.report["status"] = "paused"
self.report["step"] = "已暂停"
self._log("⏸️ 任务已暂停")
def resume(self):
self._pause.set()
self.status = MissionStatus.RUNNING
self.report["status"] = "running"
self._log("▶️ 任务已恢复")
def stop(self):
self._stop.set()
self._pause.set() # 解除暂停
self._qr_event.set() # 解除 QR 等待
if self.arm_client:
try:
self.arm_client.task_stop()
except Exception:
pass
self.agv.stop()
self.status = MissionStatus.IDLE
self.report["status"] = "idle"
def get_status(self) -> dict:
return {
"status": self.report["status"],
"step": self.report["step"],
"progress": self.report["progress"],
"total": self.report["total"],
"tasks": self.report.get("tasks", []),
}
def get_logs(self) -> dict:
"""返回实时日志和完整状态"""
return self.report
# ==================== 状态报告 ====================
def _log(self, msg: str):
self.report["log"].append(msg)
# Keep last 500 entries
if len(self.report["log"]) > 500:
self.report["log"] = self.report["log"][-500:]
logger.info(msg)
def _step(self, text: str):
self.report["step"] = text
def _get_task(self, row: int, col: int) -> Optional[dict]:
"""获取指定行列的任务记录"""
for t in self.report.get("tasks", []):
if t["row"] == row and t["col"] == col:
return t
return None
def _progress(self, machine_idx: int, side_code: int):
"""side_code: 1=正面完成, 2=背面完成"""
if self.report["total"]:
self.report["progress"] = min(
int((machine_idx * 2 + side_code) / (self.report["total"] * 2) * 100),
99
)
# ==================== 错误弹窗 ====================
def _wait_error(self, msg: str) -> str:
"""阻塞等待用户选择:skip(跳过)或 abort(中断)"""
self.status = MissionStatus.WAITING_ERROR
self.report["status"] = "waiting_error"
self.report["step"] = msg
self.report["error"] = msg
self._log(f"⚠️ 错误处理: {msg}")
self._error_choice = None
self._error_mode = True
start = time.time()
while self._error_choice is None and not self._stop.is_set():
time.sleep(0.2)
if time.time() - start > 600: # 10分钟超时 → 跳过
self._error_choice = "skip"
choice = self._error_choice or "skip"
self._error_choice = None
self._error_mode = False
if choice == "abort":
self._stop.set()
self._log("⏹️ 用户选择中断")
else:
self._log("⏭️ 用户选择跳过")
# 恢复状态
self.status = MissionStatus.RUNNING if not self._single_step_mode else MissionStatus.WAITING_STEP
self.report["status"] = self.status.value
self.report["error"] = None
return choice
def set_error_choice(self, choice: str):
"""外部 API 设置错误处理选择"""
self._error_choice = choice
# ==================== 单步执行 ====================
def _wait_step_confirm(self, row_label: int, col_label: int) -> str:
"""单步执行:等待用户确认/重试/中断"""
self.status = MissionStatus.WAITING_STEP
self.report["status"] = "waiting_step"
self.report["step"] = f"机器 {row_label}-{col_label} 完成,等待确认"
self.report["current_step"] = {
"row": row_label - 1, "col": col_label - 1,
"label": f"{row_label}-{col_label}"
}
self._log(f"⏸️ 单步执行: 机器 {row_label}-{col_label} 完成,等待确认...")
self._step_choice = None
start = time.time()
while self._step_choice is None and not self._stop.is_set():
time.sleep(0.2)
if time.time() - start > 600: # 10分钟超时 → 确认
self._step_choice = "confirm"
choice = self._step_choice or "confirm"
self._step_choice = None
self.report.pop("current_step", None)
if choice == "abort":
self._stop.set()
self._log("⏹️ 用户选择中断")
elif choice == "retry":
self._log(f"🔄 用户选择重试机器 {row_label}-{col_label}")
else:
self._log(f"✅ 用户确认机器 {row_label}-{col_label}")
return choice
def set_step_choice(self, choice: str):
"""外部 API 设置单步执行选择"""
self._step_choice = choice
# ==================== Nav2 导航 ====================
# (保留原实现)
def _nav2_check_available(self) -> bool:
try:
rc, out, err = self._run_ros2_cmd("ros2 action list")
if rc != 0:
return False
return "/navigate_to_pose" in out
except:
return False
def _nav2_go_to_point(self, x: float, y: float, yaw: float = 0.0,
timeout_sec: float = 120.0) -> bool:
"""使用 Nav2Navigator 直接发送导航目标(blocking 模式,等待完成)"""
try:
logger.info(f"🧭 导航到目标: ({x:.3f}, {y:.3f}), yaw={math.degrees(yaw):.1f}°")
ok = self._nav.navigate_to_pose(x, y, yaw, timeout_sec=timeout_sec, blocking=True)
if ok:
logger.info(f"✅ 导航成功到达 ({x:.3f}, {y:.3f})")
else:
logger.warning(f"⚠️ 导航失败或超时 ({x:.3f}, {y:.3f})")
return ok
except Exception as e:
logger.error(f"Nav2 异常: {e}")
return False
def _nav2_cancel(self):
cancel_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 action cancel /navigate_to_pose 2>/dev/null || true'"
try:
subprocess.run(cancel_cmd, shell=True, timeout=3)
except:
pass
def _run_ros2_cmd(self, cmd: str, timeout: float = 5.0) -> tuple:
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && {cmd}'"
try:
result = subprocess.run(
full_cmd, shell=True,
capture_output=True, text=True, timeout=timeout
)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return -1, "", "Timeout"
except Exception as e:
return -1, "", str(e)
-216
View File
@@ -1,216 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运行监控 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260527b">
</head>
<body>
<div id="app">
<header class="topbar">
<div class="logo">▶️ 任务运行</div>
<nav class="nav">
<a href="/" class="nav-link">🏠 首页</a>
<a href="/setting" class="nav-link">⚙️ 设置</a>
<a href="/running" class="nav-link active">▶️ 运行</a>
</nav>
</header>
<main class="container">
<!-- 状态概览 -->
<section class="card">
<div class="running-header">
<div class="running-status" :class="missionState">
<span class="pulse"></span>
[[ missionStateText ]]
</div>
<div class="running-progress" v-if="missionState === 'running' || missionState === 'waiting_qr'">
<span>进度 [[ Math.round(progress) ]]%</span>
<div class="progress-bar">
<div class="progress-fill" :style="{width: progress + '%'}"></div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-success btn-large" @click="startMission" :disabled="missionState !== 'idle'">
▶️ 开始任务
</button>
<button class="btn btn-warning btn-large" @click="pauseMission" :disabled="missionState !== 'running'">
⏸️ 暂停
</button>
<button class="btn btn-primary btn-large" @click="resumeMission" :disabled="missionState !== 'paused'">
▶️ 继续
</button>
<button class="btn btn-error btn-large" @click="stopMission" :disabled="missionState === 'idle'">
⏹️ 停止
</button>
</div>
</section>
<!-- 任务步骤控制开关 -->
<section class="card">
<h2>🎛️ 任务步骤控制</h2>
<p class="hint" style="margin-bottom:12px">关闭的步骤将在本次任务中跳过</p>
<div class="toggle-grid">
<div class="toggle-item">
<label class="toggle-switch">
<input type="checkbox" v-model="agvMoveEnabled">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">🚗 AGV移动</span>
<span class="toggle-hint">含机械臂初始化,按之字形路线移动到各点位</span>
</div>
<div class="toggle-item">
<label class="toggle-switch">
<input type="checkbox" v-model="qrScanEnabled">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">📷 识别二维码</span>
<span class="toggle-hint">调整机械臂姿态扫描二维码</span>
</div>
<div class="toggle-item">
<label class="toggle-switch">
<input type="checkbox" v-model="frontPhotoEnabled">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">📸 拍正面照</span>
<span class="toggle-hint">按机型正面姿态拍照</span>
</div>
<div class="toggle-item">
<label class="toggle-switch">
<input type="checkbox" v-model="backPhotoEnabled">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">📸 拍背面照</span>
<span class="toggle-hint">按机型背面姿态拍照</span>
</div>
</div>
</section>
<!-- 速度控制 -->
<section class="card">
<h2>🚀 速度控制</h2>
<p class="hint" style="margin-bottom:12px">调节任务执行时的 AGV 和机械臂速度</p>
<div class="speed-panel">
<div class="speed-row">
<label class="speed-label">
<span>🚗 AGV 移动速度</span>
<span class="speed-val">[[ agvSpeed.toFixed(1) ]] m/s</span>
</label>
<input type="range" class="speed-slider" min="0.1" max="1.0" step="0.1" v-model.number="agvSpeed">
</div>
<div class="speed-row">
<label class="speed-label">
<span>🦾 机械臂速度</span>
<span class="speed-val">[[ armSpeed ]]</span>
</label>
<input type="range" class="speed-slider" min="100" max="1000" step="50" v-model.number="armSpeed">
</div>
</div>
</section>
<!-- 任务清单 -->
<section class="card" v-if="tasks.length > 0">
<h2>📋 任务清单 ([[ tasks.length ]] 台机器)</h2>
<div class="task-grid">
<div v-for="task in tasks" :key="task.machine_id"
class="task-cell" :class="'task-' + task.status"
:title="task.step">
<div class="task-pos">[[ task.label ]]</div>
<div class="task-status-icon">
<span v-if="task.status === 'pending'"></span>
<span v-else-if="task.status === 'active'" class="pulse-icon">🔄</span>
<span v-else-if="task.status === 'completed'"></span>
<span v-else></span>
</div>
<div class="task-step-text">[[ task.step ]]</div>
<div class="task-info">
<div v-if="task.qr_value" class="task-qr">🏷 [[ task.qr_value.substring(0,8) ]]</div>
<div class="task-photos" v-if="task.photos_front || task.photos_back">
📷 [[ task.photos_front ]]正 [[ task.photos_back ]]背
</div>
</div>
</div>
</div>
</section>
<!-- 实时日志 -->
<section class="card">
<h2>📜 任务日志</h2>
<div class="log-box" ref="logBox">
<div v-for="(log, i) in logs" :key="i" class="log-line">[[ log ]]</div>
<div v-if="logs.length === 0" class="log-empty">等待任务开始...</div>
</div>
</section>
<!-- 实时预览 -->
<section class="card">
<h2>📷 摄像头预览</h2>
<div class="camera-dual">
<div class="camera-box">
<div class="camera-label">🎥 AGV 摄像头</div>
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
</div>
<div class="camera-box">
<div class="camera-label">🦾 机械臂摄像头</div>
<img :src="armPreviewUrl" @error="onArmPreviewError" class="camera-img">
</div>
</div>
</section>
<!-- 任务报告 -->
<section class="card" v-if="report">
<h2>📊 任务报告</h2>
<div class="report-summary">
<div class="stat ok">✅ 完成: [[ report.completed ]]</div>
<div class="stat error">❌ 失败: [[ report.failed ]]</div>
<div class="stat">总计: [[ report.total_points ]]</div>
</div>
</section>
<!-- 手动输入二维码弹窗 -->
<div class="modal-overlay" v-if="showQrModal">
<div class="modal">
<h3>⌨️ 手动输入二维码</h3>
<p>所有姿态均未识别到二维码,请手动输入:</p>
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
<div class="modal-actions">
<button class="btn btn-primary" @click="submitQr">确认</button>
<button class="btn" @click="cancelQr">跳过</button>
</div>
</div>
</div>
<!-- 错误弹窗 -->
<div class="modal-overlay" v-if="waitingError">
<div class="modal">
<h3>⚠️ 执行错误</h3>
<p>[[ errorMsg ]]</p>
<div class="modal-actions">
<button class="btn btn-warning" @click="skipError">跳过</button>
<button class="btn btn-error" @click="abortError">中断</button>
</div>
</div>
</div>
<!-- 步骤确认弹窗 -->
<div class="modal-overlay" v-if="waitingStep">
<div class="modal">
<h3>🦶 单步执行确认</h3>
<p>机器 [[ stepLabel ]] 执行完成,请确认结果是否正确:</p>
<div class="modal-actions">
<button class="btn btn-success" @click="confirmStep">✅ 正确,继续</button>
<button class="btn btn-warning" @click="retryStep">🔄 不正确,重试</button>
<button class="btn btn-error" @click="abortStep">⏹️ 中断</button>
</div>
</div>
</div>
</main>
</div>
<script src="/static/js/vue3.global.prod.js"></script>
<script src="/static/js/running.js?v=20260527b"></script>
</body>
</html>
-212
View File
@@ -1,212 +0,0 @@
const { createApp } = Vue
const API = ''
createApp({
delimiters: ['[[', ']]'],
data() {
return {
missionState: 'idle',
progress: 0,
tasks: [],
report: null,
agvPreviewUrl: API + '/api/camera/preview',
armPreviewUrl: API + '/api/camera/arm_refresh',
polling: null,
logs: [],
showQrModal: false,
qrValue: '',
// 错误弹窗 / 单步执行
waitingError: false,
errorMsg: '',
waitingStep: false,
stepLabel: '',
// 任务步骤控制开关(机械臂初始化并入AGV移动)
agvMoveEnabled: true,
qrScanEnabled: true,
frontPhotoEnabled: true,
backPhotoEnabled: true,
// 速度控制
agvSpeed: 0.5,
armSpeed: 500,
}
},
computed: {
missionStateText() {
const map = {
idle: '空闲',
running: '任务运行中',
paused: '已暂停',
completed: '已完成',
waiting_qr: '等待输入二维码',
waiting_error: '⚠️ 执行错误',
waiting_step: '🦶 等待步骤确认',
}
return map[this.missionState] || '未知'
},
},
mounted() {
this.poll()
},
beforeUnmount() {
if (this.polling) clearInterval(this.polling)
},
methods: {
poll() {
this.refresh()
this.pollLogs()
this.polling = setInterval(() => {
this.refresh()
this.pollLogs()
}, 2000)
},
async refresh() {
try {
const res = await fetch(API + '/api/mission/state')
const data = await res.json()
this.missionState = data.status || 'idle'
this.progress = data.progress || 0
if (data.tasks) this.tasks = data.tasks
// 错误弹窗
if (data.waiting_error) {
this.waitingError = true
this.errorMsg = data.error_msg || '任务执行出错'
} else {
this.waitingError = false
}
// 步骤确认弹窗
if (data.waiting_step) {
this.waitingStep = true
this.stepLabel = data.step_label || ''
} else {
this.waitingStep = false
}
// QR 弹窗
if (this.missionState === 'waiting_qr' && !this.showQrModal) {
this.showQrModal = true
this.qrValue = ''
}
// 完成后获取报告
if (this.missionState === 'idle' && this.tasks.length > 0) {
const reportRes = await fetch(API + '/api/mission/report')
const reportData = await reportRes.json()
this.report = reportData.report
}
} catch (e) {}
},
async pollLogs() {
if (this.missionState !== 'running' && this.missionState !== 'waiting_qr' && this.missionState !== 'waiting_error' && this.missionState !== 'waiting_step') return
try {
const res = await fetch(API + '/api/mission/log')
const data = await res.json()
if (data.log) this.logs = data.log
if (data.progress != null) this.progress = data.progress
if (data.tasks) this.tasks = data.tasks
// 自动滚到底
this.$nextTick(() => {
const box = this.$refs.logBox
if (box) box.scrollTop = box.scrollHeight
})
} catch (e) {}
},
async startMission() {
if (this.missionState !== 'idle') return
this.logs = []
this.progress = 0
this.report = null
this.showQrModal = false
await fetch(API + '/api/mission/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agv_move: this.agvMoveEnabled,
qr_scan: this.qrScanEnabled,
front_photo: this.frontPhotoEnabled,
back_photo: this.backPhotoEnabled,
agv_speed: this.agvSpeed,
arm_speed: this.armSpeed,
})
})
this.missionState = 'running'
},
async startSingleStep() {
if (this.missionState !== 'idle') return
this.logs = []
this.progress = 0
this.report = null
this.showQrModal = false
await fetch(API + '/api/mission/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ single_step: true })
})
if (this.polling) clearInterval(this.polling)
this.poll()
},
async skipError() {
await fetch(API + '/api/mission/error-skip', { method: 'POST' })
this.waitingError = false
},
async abortError() {
await fetch(API + '/api/mission/error-abort', { method: 'POST' })
this.waitingError = false
},
async confirmStep() {
await fetch(API + '/api/mission/singlestep/confirm', { method: 'POST' })
this.waitingStep = false
},
async retryStep() {
await fetch(API + '/api/mission/singlestep/retry', { method: 'POST' })
this.waitingStep = false
},
async abortStep() {
await fetch(API + '/api/mission/singlestep/abort', { method: 'POST' })
this.waitingStep = false
},
async pauseMission() {
await fetch(API + '/api/mission/pause', { method: 'POST' })
this.missionState = 'paused'
},
async resumeMission() {
await fetch(API + '/api/mission/resume', { method: 'POST' })
this.missionState = 'running'
this.showQrModal = false
},
async stopMission() {
await fetch(API + '/api/mission/stop', { method: 'POST' })
this.missionState = 'idle'
this.showQrModal = false
this.waitingError = false
this.waitingStep = false
},
async submitQr() {
const val = this.qrValue.trim()
await fetch(API + '/api/mission/manual-qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qr: val || ' ' })
})
this.showQrModal = false
this.qrValue = ''
},
cancelQr() {
this.showQrModal = false
this.qrValue = ''
fetch(API + '/api/mission/manual-qr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qr: 'SKIP' })
})
},
onAgvPreviewError(e) {
e.target.style.display = 'none'
},
onArmPreviewError(e) {
e.target.style.display = 'none'
}
}
}).mount('#app')
-589
View File
@@ -1,589 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260527a">
</head>
<body>
<div id="app">
<header class="topbar">
<div class="logo">⚙️ 系统设置</div>
<nav class="nav">
<a href="/" class="nav-link">🏠 首页</a>
<a href="/setting" class="nav-link active">⚙️ 设置</a>
<a href="/running" class="nav-link">▶️ 运行</a>
</nav>
</header>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{active: tab === 'map'}" @click="tab = 'map'">🗺️ 地图</button>
<button class="tab" :class="{active: tab === 'mission'}" @click="tab = 'mission'">🎯 任务配置</button>
<button class="tab" :class="{active: tab === 'qr'}" @click="tab = 'qr'">📷 二维码配置</button>
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
</div>
<main class="container">
<!-- 地图配置 (保持不变) -->
<div v-if="tab === 'map'">
<section class="card">
<h2>地图配置</h2>
<div class="form-row">
<div class="form-group">
<label>地图目录</label>
<input type="text" v-model="mapForm.map_dir" placeholder="/home/elephant/...">
</div>
<div class="form-group">
<label>地图文件</label>
<input type="text" v-model="mapForm.map_file" placeholder="map.yaml">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="loadMap">📂 加载地图</button>
<button class="btn btn-secondary" @click="saveMap" style="margin-left:6px">💾 保存</button>
</div>
</div>
<p v-if="mapMsg" class="hint">{% raw %}{{ mapMsg }}{% endraw %}</p>
</section>
<section class="card" v-if="mapLoaded" style="margin-top:16px">
<div style="display:flex;align-items:center;justify-content:space-between">
<h2>地图可视化</h2>
<div style="display:flex;gap:8px;align-items:center">
<span style="font-size:12px;color:#888">旋转:</span>
<button class="btn btn-secondary" style="padding:4px 10px;font-size:12px" @click="rotateMap(-90)">↶ 90°</button>
<button class="btn btn-secondary" style="padding:4px 10px;font-size:12px" @click="rotateMap(90)">↷ 90°</button>
<button class="btn btn-secondary" style="padding:4px 10px;font-size:12px" @click="resetMapView">重置</button>
</div>
</div>
<div class="map-container" style="position:relative;background:#111;border-radius:8px;overflow:hidden">
<!-- 地图旋转 wrapper -->
<div :style="{ transform: 'rotate(' + mapRotation + 'deg)', transition: 'transform 0.3s ease' }">
<img :src="mapImageUrl" @error="onMapError" @click="onMapClick" style="width:100%;display:block;cursor:crosshair" title="点击地图导航到该位置">
<!-- 地图覆盖层:显示点位坐标 -->
<div class="map-overlay">
<!-- AGV 实时位置 -->
<div v-if="navCurrentPos && nav2Available"
class="map-dot agv-dot"
:style="{ left: getMapX(navCurrentPos) + '%', top: getMapY(navCurrentPos) + '%' }"
title="AGV 当前位置">
</div>
<!-- 点位坐标点 -->
<div v-for="(p, pi) in missionConfig.positions" :key="'pdot-'+mapVersion+'-'+pi"
class="map-dot point-dot"
:style="{ left: getMapX(p.coords) + '%', top: getMapY(p.coords) + '%' }"
:title="p.coords ? p.coords.map(c => c.toFixed(2)).join(', ') : ''">
</div>
<!-- 二维码位置点 -->
<div v-for="(m, mi) in missionConfig.machines" :key="'qrdot-'+mapVersion+'-'+mi"
v-if="machineHasQr(m)"
class="map-dot qr-dot"
:style="qrMarkerStyle(m)"
:title="qrMarkerTitle(m)">
</div>
</div>
</div>
</div>
</section>
</div>
<!-- ========== 机型配置 Tab ========== -->
<div v-if="tab === 'model'">
<section class="card">
<h2>📦 机型配置</h2>
<!-- 添加机型按钮 -->
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
<button class="btn btn-primary" @click="showAddModelModal = true"> 添加机型</button>
</div>
<!-- 机型表格列表 -->
<div v-if="models.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
<p>暂无机型配置,请点击上方按钮添加</p>
</div>
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
<thead>
<tr style="background:#1a2332;text-align:left">
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型名称</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.id" style="border-bottom:1px solid #1a2332">
<td style="padding:10px 12px">{% raw %}{{ m.id }}{% endraw %}</td>
<td style="padding:10px 12px"><strong>{% raw %}{{ m.name }}{% endraw %}</strong></td>
<td style="padding:10px 12px;color:#9aa0a6">{% raw %}{{ m.description || '—' }}{% endraw %}</td>
<td style="padding:10px 12px;color:#9aa0a6">{% raw %}{{ m.notes || '—' }}{% endraw %}</td>
<td style="padding:10px 12px;text-align:center;white-space:nowrap">
<button class="btn btn-secondary btn-small" @click="expandedModelId = expandedModelId === m.id ? null : m.id">🤲 姿态</button>
<button class="btn btn-danger btn-small" @click="deleteModel(m.id)" style="margin-left:6px">🗑️ 删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 姿态展开面板 -->
<div v-if="expandedModelId" style="border:1px solid #2a3441;border-radius:8px;overflow:hidden;margin-bottom:16px">
<div v-for="m in models.filter(m => m.id === expandedModelId)" :key="m.id">
<!-- 正面姿态 -->
<div style="padding:16px;background:#0f1923">
<h4 style="margin:0 0 12px 0;color:#388e3c">🟢 正面姿态</h4>
<div v-for="pose in (m.poses || []).filter(p => p.photo_type === 'front')" :key="pose.id" style="background:#0f1923;padding:12px;border:1px solid #2a3441;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>{% raw %}{{ pose.name || '正面姿态' }}{% endraw %}</strong>
<button class="btn btn-danger btn-small" @click="deletePose(m.id, pose.id)">删除</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="j in 6" :key="j" style="display:flex;align-items:center;gap:4px">
<span style="font-size:12px;color:#9aa0a6">J{% raw %}{{ j }}{% endraw %}</span>
<button class="btn btn-small" @click="adjustPoseAngle(m.id, pose.id, j-1, -0.5)" style="width:24px;height:24px;padding:0;font-size:12px">-</button>
<input type="number" step="0.5"
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
@change="updatePoseAngleAndMove(m.id, pose.id, j-1, $event.target.value)"
style="width:70px;padding:4px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-small" @click="adjustPoseAngle(m.id, pose.id, j-1, 0.5)" style="width:24px;height:24px;padding:0;font-size:12px">+</button>
<span style="font-size:11px;color:#999">°</span>
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="btn btn-secondary btn-small" @click="refreshPoseAngles(m.id, pose.id)">🔄 刷新角度</button>
<button class="btn btn-primary btn-small" @click="applyPoseAngles(m.id, pose.id)">✅ 应用角度</button>
</div>
</div>
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_front']"
placeholder="姿态名称(如:取料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])"> 添加正面姿态(当前角度)</button>
</div>
<div style="margin-top:6px;font-size:12px;color:#9aa0a6">
当前机械臂角度:
<span v-if="currentAngles && currentAngles.length">
J{% raw %}{{ currentAngles[0] ? currentAngles[0].toFixed(1) : '—' }}{% endraw %}°
J{% raw %}{{ currentAngles[1] ? currentAngles[1].toFixed(1) : '—' }}{% endraw %}°
J{% raw %}{{ currentAngles[2] ? currentAngles[2].toFixed(1) : '—' }}{% endraw %}°
J{% raw %}{{ currentAngles[3] ? currentAngles[3].toFixed(1) : '—' }}{% endraw %}°
J{% raw %}{{ currentAngles[4] ? currentAngles[4].toFixed(1) : '—' }}{% endraw %}°
J{% raw %}{{ currentAngles[5] ? currentAngles[5].toFixed(1) : '—' }}{% endraw %}°
</span>
<span v-else>(未连接机械臂)</span>
</div>
</div>
</div>
<!-- 背面姿态 -->
<div style="padding:16px;background:#0d1420">
<h4 style="margin:0 0 12px 0;color:#d32f2f">🔴 背面姿态</h4>
<div v-for="pose in (m.poses || []).filter(p => p.photo_type === 'back')" :key="pose.id" style="background:#0f1923;padding:12px;border:1px solid #2a3441;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>{% raw %}{{ pose.name || '背面姿态' }}{% endraw %}</strong>
<button class="btn btn-danger btn-small" @click="deletePose(m.id, pose.id)">删除</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="j in 6" :key="j" style="display:flex;align-items:center;gap:4px">
<span style="font-size:12px;color:#9aa0a6">J{% raw %}{{ j }}{% endraw %}</span>
<button class="btn btn-small" @click="adjustPoseAngle(m.id, pose.id, j-1, -0.5)" style="width:24px;height:24px;padding:0;font-size:12px">-</button>
<input type="number" step="0.5"
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
@change="updatePoseAngleAndMove(m.id, pose.id, j-1, $event.target.value)"
style="width:70px;padding:4px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-small" @click="adjustPoseAngle(m.id, pose.id, j-1, 0.5)" style="width:24px;height:24px;padding:0;font-size:12px">+</button>
<span style="font-size:11px;color:#999">°</span>
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="btn btn-secondary btn-small" @click="refreshPoseAngles(m.id, pose.id)">🔄 刷新角度</button>
<button class="btn btn-primary btn-small" @click="applyPoseAngles(m.id, pose.id)">✅ 应用角度</button>
</div>
</div>
<div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_back']"
placeholder="姿态名称(如:放料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])"> 添加背面姿态(当前角度)</button>
</div>
</div>
</div>
</div>
</div>
<!-- 添加机型 Modal -->
<div v-if="showAddModelModal" class="modal-overlay" @click.self="showAddModelModal = false">
<div class="modal-box" style="min-width:420px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<h3 style="margin:0">📦 添加新机型</h3>
<button class="btn-icon" @click="showAddModelModal = false"></button>
</div>
<div class="form-group" style="margin-bottom:12px">
<label>机型名称</label>
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div>
<div class="form-group" style="margin-bottom:12px">
<label>机型ID(数值)</label>
<input type="number" v-model.number="newModelId" placeholder="例如:100" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div>
<div class="form-group" style="margin-bottom:12px">
<label>描述</label>
<input type="text" v-model="newModelDesc" placeholder="描述信息" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div>
<div class="form-group" style="margin-bottom:12px">
<label>备注</label>
<input type="text" v-model="newModelNotes" placeholder="备注信息" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="addModel">✅ 确认添加</button>
<button class="btn btn-secondary" @click="showAddModelModal = false">取消</button>
</div>
</div>
</div>
</section>
</div>
<!-- ========== 任务配置 Tab ========== -->
<div v-if="tab === 'mission'">
<!-- 上:网格配置 -->
<section class="card">
<h2>① 网格配置 (M×N)</h2>
<div class="form-row">
<div class="form-group">
<label>行数 M</label>
<input type="number" v-model.number="missionConfig.rows" min="1" max="20" placeholder="3">
</div>
<div class="form-group">
<label>列数 N</label>
<input type="number" v-model.number="missionConfig.cols" min="1" max="20" placeholder="4">
</div>
<div class="form-group" style="align-self:end;display:flex;gap:6px;flex-wrap:nowrap">
<button class="btn btn-primary" @click="generateGrid">🔲 生成网格</button>
<button class="btn btn-secondary" @click="saveMissionConfig">💾 保存网格</button>
<button class="btn btn-warning" @click="initPose" :disabled="initPoseLoading">📍 初始化位置</button>
<button class="btn btn-primary" @click="goToOrigin">🏠 回到原点</button>
</div>
</div>
<!-- 网格可视化 - 点位行独立于机器,始终可配置 -->
<div v-if="missionConfig.rows > 0" class="mission-grid-wrap" style="margin-top:12px">
<div class="mission-grid" :style="{ gridTemplateColumns: '90px repeat(' + missionConfig.cols + ', 100px)' }">
<!-- 表头: 列号 -->
<div class="grid-cell grid-header"></div>
<div v-for="c in missionConfig.cols" :key="'h'+c" class="grid-cell grid-header">第{% raw %}{{ c }}{% endraw %}列</div>
<!-- 循环渲染: 点位行(0) → 机器行(1) → 点位行(1) → 机器行(2) → ... → 点位行(rows) -->
<!-- pointRow 从 0 到 rows(共 rows+1 个点位行)-->
<!-- machineRow 从 1 到 rows(共 rows 个机器行)-->
<!-- 第一个点位行 (pointRow=0): 所有机器的正面拍摄点 -->
<div class="grid-cell grid-header">点位行 1</div>
<div v-for="(ci) in missionConfig.cols" :key="'p0_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(0, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(0, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(0, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(0, ci-1)">+</button>
</div>
<!-- 中间循环: 机器行 ri + 点位行 ri (ri from 1 to rows-1) -->
<template v-for="ri in (missionConfig.rows - 1)" :key="'mr'+ri">
<!-- 机器行 ri -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ ri }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+ri+'_'+ci"
class="grid-cell"
:class="{ active: selectedMachine && selectedMachine.row === ri-1 && selectedMachine.col === ci-1 }"
@click="onCellClick(ri-1, ci-1)">
<label class="machine-toggle" @click.stop>
<input type="checkbox"
:checked="getMachineAt(ri-1, ci-1) !== null"
@change="toggleMachine(ri-1, ci-1, $event)">
<span class="machine-status" :class="getMachineAt(ri-1, ci-1) ? 'on' : 'off'">
{% raw %}{{ getMachineAt(ri-1, ci-1) ? '有机器' : '无机器' }}{% endraw %}
</span>
</label>
</div>
<!-- 点位行 ri+1 (pointRow=ri): 上面机器的背面 / 下面机器的正面 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ ri+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+(ri)+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(ri, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(ri, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(ri, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(ri, ci-1)">+</button>
</div>
</template>
<!-- 最后一个机器行 (机器行 rows) -->
<div class="grid-cell grid-header">机器行 {% raw %}{{ missionConfig.rows }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'m'+missionConfig.rows+'_'+ci"
class="grid-cell"
:class="{ active: selectedMachine && selectedMachine.row === missionConfig.rows-1 && selectedMachine.col === ci-1 }"
@click="onCellClick(missionConfig.rows-1, ci-1)">
<label class="machine-toggle" @click.stop>
<input type="checkbox"
:checked="getMachineAt(missionConfig.rows-1, ci-1) !== null"
@change="toggleMachine(missionConfig.rows-1, ci-1, $event)">
<span class="machine-status" :class="getMachineAt(missionConfig.rows-1, ci-1) ? 'on' : 'off'">
{% raw %}{{ getMachineAt(missionConfig.rows-1, ci-1) ? '有机器' : '无机器' }}{% endraw %}
</span>
</label>
</div>
<!-- 最后一个点位行 (pointRow=rows): 所有机器的背面拍摄点 -->
<div class="grid-cell grid-header">点位行 {% raw %}{{ missionConfig.rows+1 }}{% endraw %}</div>
<div v-for="(ci) in missionConfig.cols" :key="'p'+missionConfig.rows+'_'+ci"
class="grid-cell point-cell"
@click="openPointEdit(missionConfig.rows, ci-1)">
<span class="point-coords">{% raw %}{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[0]?.toFixed(1) || '-' }},{{ getPointAt(missionConfig.rows, ci-1)?.coords?.[1]?.toFixed(1) || '-' }}{% endraw %}</span>
<button class="btn-icon-small" title="配置坐标" @click.stop="openPointEdit(missionConfig.rows, ci-1)">+</button>
</div>
</div>
<p class="hint" style="margin-top:8px">点击「点位行」配置拍摄坐标;点击「机器行」切换有无机器<br>中间点位同时服务于上下两台机器(上机器背面 / 下机器正面),删除机器不影响点位配置</p>
</div>
</section>
<!-- 机械臂初始姿态 -->
<section class="card" style="margin-top:16px">
<h2>② 🦾 机械臂初始姿态</h2>
<p class="hint" style="margin-bottom:12px">每个机器执行前恢复的初始姿态(6个关节角度,单位:度)</p>
<div class="form-row" style="flex-wrap:wrap;gap:12px">
<div v-for="j in 6" :key="'armInit'+j" class="form-group" style="min-width:100px">
<label>J{% raw %}{{ j }}{% endraw %}</label>
<input type="number" step="0.5"
v-model.number="armInitialPose[j-1]"
style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div>
<div class="form-group" style="align-self:end">
<button class="btn btn-primary" @click="saveArmInitialPose">💾 保存初始姿态</button>
<button class="btn btn-secondary" @click="loadArmCurrentAngles" :disabled="!armConnected" style="margin-left:6px">📋 读取当前角度</button>
<button class="btn btn-primary" @click="applyArmInitialPose" :disabled="!armConnected" style="margin-left:6px">🎯 应用当前姿态</button>
</div>
</div>
</section>
<!-- 下:序列预览 -->
<section class="card" v-if="sequence && sequence.length > 0" style="margin-top:16px">
<h2>③ 🐍 蛇形拍摄序列预览</h2>
<div class="sequence-preview">
<div v-for="(step, idx) in sequence" :key="idx" class="sequence-step">
<span class="step-index">{% raw %}{{ idx+1 }}{% endraw %}</span>
<span class="step-info">
第{% raw %}{{ step.row+1 }}{% endraw %}行 第{% raw %}{{ step.col+1 }}{% endraw %}列
<span class="step-side" :class="step.side">{% raw %}{{ step.side === 'front' ? '正面' : '背面' }}{% endraw %}</span>
</span>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-secondary" @click="refreshSequence">🔄 刷新序列</button>
</div>
</section>
</div>
<!-- 点位编辑弹窗(基于独立点位行模型) -->
<div v-if="editingPoint" class="modal-overlay" @click.self="closePointEdit">
<div class="modal-box" style="min-width:460px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
<h3 style="margin:0">📍 点位配置 — {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col) }}{% endraw %}</h3>
<button class="btn-icon" @click="closePointEdit"></button>
</div>
<div style="margin-bottom:14px">
<div class="form-row">
<div class="form-group">
<label>X</label>
<input type="number" step="0.01" v-model.number="pointEditor.x" placeholder="0.00">
</div>
<div class="form-group">
<label>Y</label>
<input type="number" step="0.01" v-model.number="pointEditor.y" placeholder="0.00">
</div>
<div class="form-group">
<label>Yaw (rad)</label>
<input type="number" step="0.01" v-model.number="pointEditor.yaw" placeholder="0.00">
</div>
</div>
<div class="hint" style="margin-top:4px">
当前: ({% raw %}{{ pointEditor.x.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.y.toFixed(2) }}{% endraw %}, {% raw %}{{ pointEditor.yaw.toFixed(2) }}{% endraw %})
</div>
<div class="hint" style="margin-top:6px;font-size:12px;color:#9aa0a6">
💡 此点位服务于: {% raw %}{{ getPointOwnerLabel(editingPoint.pointRow, editingPoint.col).split('·')[1] || '无' }}{% endraw %}
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="loadPointFromAgv" :disabled="!agvConnected">📍 从AGV读取</button>
<button class="btn btn-success" @click="savePoint">💾 保存</button>
<button class="btn btn-secondary" @click="navigateToPoint" :disabled="!agvConnected || !nav2Available">🚗 导航到该点位</button>
<button class="btn btn-warning" @click="clearPoint" :disabled="canClearPoint(editingPoint.pointRow, editingPoint.col)">🗑️ 清空</button>
<button class="btn btn-secondary" @click="closePointEdit">取消</button>
</div>
</div>
</div>
<!-- ========== 二维码配置 Tab ========== -->
<div v-if="tab === 'qr'">
<section class="card">
<h2>📷 二维码配置</h2>
<p style="color:#9aa0a6;font-size:13px;margin-bottom:16px">配置机械臂姿态(6个关节角度),通过机械臂摄像头识别二维码并匹配机型。</p>
<!-- 机械臂摄像头画面 -->
<div style="margin-bottom:16px">
<div class="camera-preview" style="max-width:640px">
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
<input type="text" v-model="newQrName" placeholder="输入名称..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
<button class="btn btn-primary" @click="addQrConfig()"> 添加</button>
</div>
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
<p>暂无二维码配置,请点击上方按钮添加</p>
</div>
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
<thead>
<tr style="background:#1a2332;text-align:left">
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">名称</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J4</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J5</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J6</th>
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">二维码值</th>
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">匹配机型</th>
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px;text-align:center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="q in qrConfigs" :key="q.id" style="border-bottom:1px solid #1a2332">
<td style="padding:10px 8px">
<input type="text" v-model="q.name" @change="saveQrConfig(q.id)"
style="background:transparent;border:1px solid transparent;color:#fff;padding:4px 6px;width:100px;font-size:13px"
@focus="$event.target.style.borderColor='#388e3c'" @blur="$event.target.style.borderColor='transparent'">
</td>
<td style="padding:10px 4px" v-for="ji in 6" :key="ji">
<input type="number" step="0.1" :value="getQrAngle(q, ji - 1)" @input="updateQrAngle(q.id, ji - 1, $event.target.value)" style="width:62px;padding:3px 4px;border:1px solid #2a3441;border-radius:4px;background:#0f1923;color:#fff;font-size:12px;text-align:center">
</td>
<td style="padding:10px 8px;color:#4fc3f7;font-size:12px;max-width:120px;overflow:hidden;text-overflow:ellipsis">{% raw %}{{ q.qr_value || '—' }}{% endraw %}</td>
<td style="padding:10px 8px;color:#9aa0a6;font-size:12px">{% raw %}{{ getQrModelName(q.model_id) }}{% endraw %}</td>
<td style="padding:10px 8px;text-align:center;white-space:nowrap">
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
<button class="btn btn-danger btn-small" @click="deleteQrConfig(q.id)" style="margin-left:3px" title="删除">🗑️</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
<!-- 机械臂控制 (保持不变) -->
<div v-if="tab === 'arm'">
<section class="card">
<h2>🤖 机械臂控制</h2>
<div v-if="!armConnected" class="alert alert-error">
⚠️ 机械臂未连接,请先在首页连接设备
</div>
<div v-else>
<div class="camera-preview">
<img :src="armCameraUrl" @error="onArmPreviewError">
</div>
<div class="joints-panel">
<h3>关节角度控制</h3>
<div class="joint-grid">
<div v-for="j in 6" :key="j" class="joint-control">
<label>J{% raw %}{{ j }}{% endraw %}</label>
<div class="joint-value">{% raw %}{{ currentAngles[j-1] ? currentAngles[j-1].toFixed(1) : '—' }}{% endraw %}°</div>
<div class="joint-buttons">
<button @mousedown="jogStart(j-1, -1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
<input type="number" v-model.number="angleInputs[j-1]" step="0.5" @change="setAngle(j-1, angleInputs[j-1])">
<button @mousedown="jogStart(j-1, 1)" @mouseup="jogStop(j-1)" @mouseleave="jogStop(j-1)"></button>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="refreshAngles">🔄 刷新角度</button>
<button class="btn btn-secondary" @click="applyAngles">✅ 应用角度</button>
</div>
</div>
</div>
</section>
</div>
<!-- AGV 移动控制 (保持不变) -->
<div v-if="tab === 'agv'">
<section class="card">
<h2>🚗 AGV 移动控制</h2>
<div v-if="!agvConnected" class="alert alert-error">
⚠️ AGV 未连接,请先在首页连接设备
</div>
<div v-else>
<div v-show="cameraOpened" class="camera-preview" style="margin-bottom:16px">
<img :src="agvCameraUrl" style="width:100%;max-width:480px;aspect-ratio:16/9;object-fit:cover;border-radius:8px" @error="agvCameraUrl=''">
</div>
<div class="agv-status-bar">
<span>🔋 电压: <strong>{% raw %}{{ agvBattery !== null ? agvBattery + 'V' : '—' }}{% endraw %}</strong></span>
<span v-if="agvPosition">📍 位置: <strong>X={% raw %}{{ agvPosition[0] !== undefined ? agvPosition[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ agvPosition[1] !== undefined ? agvPosition[1].toFixed(2) : '?' }}{% endraw %} yaw={% raw %}{{ agvPosition[2] !== undefined ? (agvPosition[2] * 180 / Math.PI).toFixed(1) : '?' }}{% endraw }}°</strong></span>
<button class="btn btn-small" @click="refreshAgvPosition">🔄 刷新</button>
<button class="btn btn-small" :class="{'btn-primary': !initPoseLoading}" :disabled="initPoseLoading" @click="initAmclPose">
{% raw %}{{ initPoseLoading ? '初始化中...' : '🎯 初始化定位' }}{% endraw %}
</button>
<span v-if="initPoseMsg" style="margin-left:8px;color:#4caf50;font-size:13px">{% raw %}{{ initPoseMsg }}{% endraw %}</span>
</div>
<!-- Nav2 导航状态 -->
<div v-if="nav2Available" class="nav2-status-bar" style="margin-top:8px;padding:8px 12px;background:#1a2332;border-radius:6px;font-size:13px">
<span>🧭 Nav2: <strong :style="navStatus === 'succeeded' ? 'color:#4caf50' : navStatus === 'navigating' ? 'color:#ff9800' : 'color:#9aa0a6'">{% raw %}{{ navStatus }}{% endraw %}</strong></span>
<span v-if="navCurrentPos" style="margin-left:12px">📍 当前位置: <strong>X={% raw %}{{ navCurrentPos[0] !== undefined ? navCurrentPos[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ navCurrentPos[1] !== undefined ? navCurrentPos[1].toFixed(2) : '?' }}{% endraw %}</strong></span>
<button class="btn btn-small" style="margin-left:8px" @click="refreshNavStatus">🔄 刷新</button>
<button v-if="navStatus === 'navigating'" class="btn btn-small btn-secondary" @click="cancelNav" style="margin-left:4px">取消导航</button>
</div>
<div class="agv-control-panel">
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-up" @mousedown="agvMoveStart('forward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬆️ 前进</button>
<div class="agv-dir-placeholder"></div>
</div>
<div class="agv-dir-row">
<button class="agv-btn agv-btn-left" @mousedown="agvMoveStart('left')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↺ 左转</button>
<button class="agv-btn agv-btn-stop" @click="agvStop">🛑</button>
<button class="agv-btn agv-btn-right" @mousedown="agvMoveStart('right')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">↻ 右转</button>
</div>
<div class="agv-dir-row">
<div class="agv-dir-placeholder"></div>
<button class="agv-btn agv-btn-down" @mousedown="agvMoveStart('backward')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬇️ 后退</button>
<div class="agv-dir-placeholder"></div>
</div>
</div>
<div class="agv-lateral-row">
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('left_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">⬅️ 向左平移</button>
<button class="agv-btn agv-btn-lateral" @mousedown="agvMoveStart('right_lateral')" @mouseup="agvMoveStop" @mouseleave="agvMoveStop">向右平移 ➡️</button>
</div>
<div class="form-row" style="margin-top:16px; max-width:400px">
<div class="form-group">
<label>移动速度</label>
<div class="speed-control">
<input type="range" v-model.number="agvSpeed" min="0.1" max="1.0" step="0.1" style="flex:1">
<span class="speed-value">{% raw %}{{ (agvSpeed * 100).toFixed(0) }}{% endraw %}%</span>
</div>
</div>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-danger" @click="agvResetCollision">🔄 撞物体后复位</button>
<button class="btn btn-secondary" @click="agvStop">🛑 立即停止</button>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/static/js/vue3.global.prod.js?v=20260527a"></script>
<script src="/static/js/setting.js?v=20260527a"></script>
</body>
</html>
-1123
View File
File diff suppressed because it is too large Load Diff
-177
View File
@@ -1,177 +0,0 @@
#!/bin/bash
# ============================================================
# Robot AGV 全量启动脚本 v2.6
# 修复:
# - 清理 scan_fixer lock 文件防残留
# - Nav2 节点检测 grep -c 改为单行输出
# - nohup 启动 Nav2 用 bash -c 包裹(确保 source 环境)
# ============================================================
set -e
AGV_APP_DIR="/home/elephant/work/agv_app"
AGV_ROS2_DIR="/home/elephant/agv_pro_ros2"
ROS_DOMAIN_ID_VAL=1
echo "=========================================="
echo " Robot AGV 全量启动 v2.6"
echo "=========================================="
echo ""
# ---------- 1. 清理旧进程(不杀 ros2-daemon ----------
echo "[1/7] 清理旧进程..."
pkill -f "ros2 launch agv_pro_bringup" 2>/dev/null || true
pkill -f "ros2 launch agv_pro_navigation2" 2>/dev/null || true
pkill -f "agv_pro_node" 2>/dev/null || true
pkill -f "lslidar_driver_node" 2>/dev/null || true
pkill -f "fix_scan_timestamp" 2>/dev/null || true
pkill -f "python.*app.py" 2>/dev/null || true
sleep 4
# 清理 scan_fixer 锁文件(防残留 PID 导致启动失败)
rm -f /tmp/scan_fixer.lock
echo " 清理完成"
# ---------- 2. 重启 ros2 daemon ----------
echo "[2/7] 重启 ros2 daemon..."
pkill -f "ros2-daemon" 2>/dev/null || true
sleep 2
nohup bash -c "source /opt/ros/humble/setup.bash && ros2 daemon start" >/dev/null 2>&1 &
sleep 5
echo " ros2 daemon 已就绪"
# ---------- 3. 启动 bringup (含激光雷达) ----------
echo "[3/7] 启动 AGV Bringup..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py \
port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
BRINGUP_PID=$!
echo " bringup PID: $BRINGUP_PID"
echo " 等待 bringup 就绪..."
for i in $(seq 1 20); do
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/odom'; then
echo " ✅ bringup 已就绪"
break
fi
sleep 2
done
# ---------- 4. 启动激光时间戳修正节点 ----------
echo "[4/7] 启动激光时间戳修正节点..."
pkill -f "fix_scan_timestamp" 2>/dev/null || true
sleep 2
# 确保 /scan 存在
for i in $(seq 1 10); do
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan'; then
echo " /scan 话题已上线"
break
fi
sleep 2
done
nohup bash -c "source /opt/ros/humble/setup.bash && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py" \
> /tmp/scan_fixer.log 2>&1 &
FIXER_PID=$!
echo " fix_scan_timestamp PID: $FIXER_PID"
sleep 5
# 验证 fixer 进程和 scan_corrected
FIXER_COUNT=$(ps aux | grep -c "[f]ix_scan_timestamp" 2>/dev/null || echo 0)
if [ "$FIXER_COUNT" -gt 1 ]; then
echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..."
pkill -f "fix_scan_timestamp" 2>/dev/null || true
sleep 2
rm -f /tmp/scan_fixer.lock
nohup bash -c "source /opt/ros/humble/setup.bash && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py" \
> /tmp/scan_fixer.log 2>&1 &
FIXER_PID=$!
sleep 3
fi
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan_corrected'; then
echo " ✅ /scan_corrected 已上线"
else
echo " ⚠️ /scan_corrected 未上线,检查日志:"
tail -5 /tmp/scan_fixer.log
fi
# ---------- 5. 启动 Nav2 ----------
echo "[5/7] 启动 Nav2 导航..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
# 使用 bash -c 确保 source 环境变量传递到 nohup
nohup bash -c "source /opt/ros/humble/setup.bash && \
source /home/elephant/agv_pro_ros2/install/setup.bash && \
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && \
ros2 launch agv_pro_navigation2 navigation2_active.launch.py \
autostart:=True" > /tmp/ros2_nav2.log 2>&1 &
NAV2_PID=$!
echo " Nav2 PID: $NAV2_PID"
sleep 12
echo " 等待 Nav2 节点就绪..."
for i in $(seq 1 20); do
NODES=$(ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 node list 2>/dev/null | \
grep -cE 'lifecycle_manager_navigation|bt_navigator|controller_server' 2>/dev/null || echo 0)
# 去除可能的换行符,确保是单个数字
NODES=$(echo "$NODES" | tr -d '\n' | awk '{print $1}')
if [ "$NODES" -ge 3 ] 2>/dev/null; then
echo " ✅ Nav2 节点已就绪 ($NODES 个)"
break
fi
sleep 3
done
# ---------- 6. 设置精度参数 ----------
echo "[6/7] 设置导航精度参数 (xy_goal_tolerance=0.05m)..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
for NODE in /controller_server /bt_navigator /planner_server; do
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.yaw_goal_tolerance 0.05 2>/dev/null || true
done
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server FollowPath.xy_goal_tolerance 0.05 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server general_goal_checker.stateful True 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set /controller_server FollowPath.stateful True 2>/dev/null || true
echo " 精度参数已设置"
# ---------- 7. 启动 Flask ----------
echo "[7/7] 启动 Flask API..."
cd "$AGV_APP_DIR"
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
FLASK_PID=$!
echo " Flask PID: $FLASK_PID"
sleep 4
# ---------- 完成 ----------
echo ""
echo "=========================================="
echo " ✅ 启动完成"
echo "=========================================="
echo ""
echo " 进程状态:"
for PROC in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$FLASK_PID"; do
NAME="${PROC%%:*}"
PID="${PROC##*:}"
echo " $NAME : $(ps aux | grep -w "$PID" | grep -v grep | awk '{print $2}' || echo '已退出')"
done
echo ""
echo " 日志文件:"
echo " bringup : /tmp/ros2_bringup.log"
echo " Nav2 : /tmp/ros2_nav2.log"
echo " fixer : /tmp/scan_fixer.log"
echo " Flask : /tmp/agv_flask.log"
echo ""
echo " 关键验证命令:"
echo " curl http://localhost:5000/api/navigate/status"
echo " ROS_DOMAIN_ID=1 ros2 topic echo /scan_corrected --once"
echo " ROS_DOMAIN_ID=1 ros2 topic echo /amcl_pose --once"
-165
View File
@@ -1,165 +0,0 @@
#!/bin/bash
# ============================================================
# Robot AGV 全量启动脚本 v2.2
# 完整流程:
# 清理旧进程(不杀 daemon -> 启动 bringup ->
# 启动激光时间戳修正节点 -> 启动 Nav2 ->
# 设置导航精度参数 -> 启动 Flask
# ============================================================
set -e
AGV_APP_DIR="/home/elephant/work/agv_app"
AGV_ROS2_DIR="/home/elephant/agv_pro_ros2"
ROS_DOMAIN_ID_VAL=1
echo "=========================================="
echo " Robot AGV 全量启动 v2.2"
echo "=========================================="
echo ""
# ---------- 1. 清理旧进程(不杀 ros2-daemon ----------
echo "[1/7] 清理旧进程..."
pkill -f "ros2 launch agv_pro_bringup" 2>/dev/null || true
pkill -f "ros2 launch agv_pro_navigation2" 2>/dev/null || true
pkill -f "agv_pro_node" 2>/dev/null || true
pkill -f "lslidar_driver_node" 2>/dev/null || true
pkill -f "scan_timestamp_fixer" 2>/dev/null || true
pkill -f "python.*app.py" 2>/dev/null || true
sleep 4
echo " 清理完成"
# ---------- 2. 重启 ros2 daemon(仅杀 daemon进程本身,不杀整个环境) ----------
echo "[2/7] 重启 ros2 daemon..."
pkill -f "ros2-daemon" 2>/dev/null || true
sleep 2
nohup bash -c "source /opt/ros/humble/setup.bash && ros2 daemon start" >/dev/null 2>&1 &
sleep 5
echo " ros2 daemon 已就绪"
# ---------- 3. 启动 bringup (含激光雷达) ----------
echo "[3/7] 启动 AGV Bringup..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py \
port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
BRINGUP_PID=$!
echo " bringup PID: $BRINGUP_PID"
echo " 等待 bringup 就绪..."
for i in $(seq 1 20); do
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/odom'; then
echo " ✅ bringup 已就绪"
break
fi
sleep 2
done
# ---------- 4. 启动激光时间戳修正节点(单例,不重复启动) ----------
echo "[4/7] 启动激光时间戳修正节点..."
# 确保只有1个 fixer 进程在运行
pkill -f "scan_timestamp_fixer" 2>/dev/null || true
sleep 2
for i in $(seq 1 10); do
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan'; then
echo " /scan 话题已上线"
break
fi
sleep 2
done
nohup bash -c "source /opt/ros/humble/setup.bash && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py" \
> /tmp/scan_fixer.log 2>&1 &
FIXER_PID=$!
echo " scan_timestamp_fixer PID: $FIXER_PID"
sleep 5
# 验证只有1个 fixer 进程
FIXER_COUNT=$(ps aux | grep -c "[f]ix_scan_timestamp" 2>/dev/null || echo 0)
if [ "$FIXER_COUNT" -gt 1 ]; then
echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..."
pkill -f "scan_timestamp_fixer" 2>/dev/null || true
sleep 2
nohup bash -c "source /opt/ros/humble/setup.bash && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py" \
> /tmp/scan_fixer.log 2>&1 &
sleep 3
fi
if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan_corrected'; then
echo " ✅ /scan_corrected 已上线"
else
echo " ⚠️ /scan_corrected 未上线,检查日志:"
cat /tmp/scan_fixer.log
fi
# ---------- 5. 启动 Nav2 ----------
echo "[5/7] 启动 Nav2 导航..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
nohup ros2 launch agv_pro_navigation2 navigation2_active.launch.py \
autostart:=True > /tmp/ros2_nav2.log 2>&1 &
NAV2_PID=$!
echo " Nav2 PID: $NAV2_PID"
sleep 12
echo " 等待 Nav2 节点就绪..."
for i in $(seq 1 15); do
NODES=$(ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 node list 2>/dev/null | \
grep -c "lifecycle_manager_navigation\|bt_navigator\|controller_server" 2>/dev/null || echo 0)
if [ "$NODES" -ge 3 ]; then
echo " ✅ Nav2 节点已就绪 ($NODES 个)"
break
fi
sleep 3
done
# ---------- 6. 设置精度参数 ----------
echo "[6/7] 设置导航精度参数 (xy_goal_tolerance=0.05m)..."
source /opt/ros/humble/setup.bash
cd "$AGV_ROS2_DIR"
source install/setup.bash
for NODE in /controller_server /bt_navigator /planner_server; do
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 param set $NODE general_goal_checker.yaw_goal_tolerance 0.05 2>/dev/null || true
done
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 param set /controller_server FollowPath.xy_goal_tolerance 0.05 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 param set /controller_server general_goal_checker.stateful True 2>/dev/null || true
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 param set /controller_server FollowPath.stateful True 2>/dev/null || true
echo " 精度参数已设置"
# ---------- 7. 启动 Flask ----------
echo "[7/7] 启动 Flask API..."
cd "$AGV_APP_DIR"
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
FLASK_PID=$!
echo " Flask PID: $FLASK_PID"
sleep 4
# ---------- 完成 ----------
echo ""
echo "=========================================="
echo " ✅ 启动完成"
echo "=========================================="
echo ""
echo " 进程状态:"
for PROC in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$FLASK_PID"; do
NAME="${PROC%%:*}"
PID="${PROC##*:}"
echo " $NAME : $(ps aux | grep -w "$PID" | grep -v grep | awk '{print $2}' || echo '已退出')"
done
echo ""
echo " 日志文件:"
echo " bringup : /tmp/ros2_bringup.log"
echo " Nav2 : /tmp/ros2_nav2.log"
echo " fixer : /tmp/scan_fixer.log"
echo " Flask : /tmp/agv_flask.log"
echo ""
echo " 关键验证命令:"
echo " curl http://localhost:5000/api/navigate/status"
echo " ROS_DOMAIN_ID=1 ros2 topic echo /scan_corrected --once"
echo " ROS_DOMAIN_ID=1 ros2 topic echo /amcl_pose --once"
-2
View File
@@ -3,8 +3,6 @@ const { createApp } = Vue
const API = ''
createApp({
delimiters: ['[[', ']]'],
data() {
return {
connecting: false,
+13 -13
View File
@@ -18,7 +18,7 @@
</nav>
<div class="status-bar">
<span class="status-item" :class="statusClass">
[[ statusText ]]
{% raw %}{{ statusText }}{% endraw %}
</span>
</div>
</header>
@@ -33,54 +33,54 @@
@click="connectDevice('agv')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='agv'"></span>
<span v-else>[[ agvConnected ? '✅' : '❌' ]]</span>
<span v-else>{% raw %}{{ agvConnected ? '✅' : '❌' }}{% endraw %}</span>
</div>
<div class="status-label">AGV</div>
<div class="status-value">
<span v-if="reconnectingDevice==='agv'">重连中...</span>
<span v-else>[[ agvConnected ? '已连接' : '未连接' ]](点击重连)</span>
<span v-else>{% raw %}{{ agvConnected ? '已连接' : '未连接' }}{% endraw %}(点击重连)</span>
</div>
</div>
<div class="status-card" :class="armConnected ? 'ok' : 'error'"
@click="connectDevice('arm')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='arm'"></span>
<span v-else>[[ armConnected ? '✅' : '❌' ]]</span>
<span v-else>{% raw %}{{ armConnected ? '✅' : '❌' }}{% endraw %}</span>
</div>
<div class="status-label">机械臂</div>
<div class="status-value">
<span v-if="reconnectingDevice==='arm'">重连中...</span>
<span v-else>[[ armConnected ? '已连接' : '未连接' ]](点击重连)</span>
<span v-else>{% raw %}{{ armConnected ? '已连接' : '未连接' }}{% endraw %}(点击重连)</span>
</div>
</div>
<div class="status-card" :class="cameraOpened ? 'ok' : 'error'"
@click="connectDevice('camera')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='camera'"></span>
<span v-else>[[ cameraOpened ? '✅' : '❌' ]]</span>
<span v-else>{% raw %}{{ cameraOpened ? '✅' : '❌' }}{% endraw %}</span>
</div>
<div class="status-label">AGV摄像头</div>
<div class="status-value">
<span v-if="reconnectingDevice==='camera'">重连中...</span>
<span v-else>[[ cameraOpened ? '已打开' : '未打开' ]](点击重连)</span>
<span v-else>{% raw %}{{ cameraOpened ? '已打开' : '未打开' }}{% endraw %}(点击重连)</span>
</div>
</div>
<div class="status-card" :class="armCameraOpened ? 'ok' : 'error'"
@click="connectDevice('arm_camera')" style="cursor:pointer">
<div class="status-icon">
<span v-if="reconnectingDevice==='arm_camera'"></span>
<span v-else>[[ armCameraOpened ? '✅' : '❌' ]]</span>
<span v-else>{% raw %}{{ armCameraOpened ? '✅' : '❌' }}{% endraw %}</span>
</div>
<div class="status-label">机械臂摄像头</div>
<div class="status-value">
<span v-if="reconnectingDevice==='arm_camera'">重连中...</span>
<span v-else>[[ armCameraOpened ? '已打开' : '未打开' ]](点击重连)</span>
<span v-else>{% raw %}{{ armCameraOpened ? '已打开' : '未打开' }}{% endraw %}(点击重连)</span>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="connectAll" :disabled="connecting">
[[ connecting ? '连接中...' : '🔗 连接全部设备' ]]
{% raw %}{{ connecting ? '连接中...' : '🔗 连接全部设备' }}{% endraw %}
</button>
<button class="btn btn-secondary" @click="disconnectAll" :disabled="!agvConnected && !armConnected && !cameraOpened">
断开连接
@@ -111,8 +111,8 @@
<section class="card">
<h2>🗺️ 地图信息</h2>
<div v-if="mapLoaded">
<p>地图目录: <code>[[ mapConfig.map_dir ]]</code></p>
<p>地图文件: <code>[[ mapConfig.map_file ]]</code></p>
<p>地图目录: <code>{% raw %}{{ mapConfig.map_dir }}{% endraw %}</code></p>
<p>地图文件: <code>{% raw %}{{ mapConfig.map_file }}{% endraw %}</code></p>
</div>
<div v-else>
<p class="hint">尚未加载地图,请前往 <a href="/setting">设置页面</a> 配置地图</p>
@@ -122,7 +122,7 @@
<!-- 点位概览 -->
<section class="card">
<h2>📍 点位概览</h2>
<p>已配置 <strong>[[ pointsCount ]]</strong> 个拍摄点位</p>
<p>已配置 <strong>{% raw %}{{ pointsCount }}{% endraw %}</strong> 个拍摄点位</p>
<div class="btn-row">
<a href="/setting" class="btn btn-primary">前往设置点位 →</a>
</div>
-638
View File
@@ -1,638 +0,0 @@
const { createApp } = Vue
const API = ''
createApp({
data() {
return {
tab: 'map',
// 任务配置
missionConfig: { rows: 3, cols: 3, grid: [], machines: [] },
selectedMachine: null,
sequence: [],
poseForm: { name: '', photo_type: 'front', description: '' },
// 地图
mapForm: { map_dir: '/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/', map_file: 'map.yaml' },
mapMsg: '',
mapLoaded: false,
mapImageUrl: '',
mapMeta: null,
mapRotation: 0,
mapVersion: 0,
navCurrentPos: null,
nav2Available: false,
// 点位
points: [],
newPointName: '',
newPointMode: 'front',
newPointSequence: ['front', 'back'],
// 机型(姿态组)
models: [],
selectedModelId: null,
newModelName: '',
newModelSerial: '',
// 机械臂
armConnected: false,
currentAngles: [],
angleInputs: [],
previewUrl: API + '/api/camera/preview',
jogIntervals: {},
// AGV
cameraOpened: false,
agvConnected: false,
agvBattery: null,
agvPosition: null,
agvSpeed: 0.5,
agvMoveInterval: null,
agvCameraUrl: API + '/api/camera/refresh',
agvCameraTimer: null,
}
},
mounted() {
this.refresh()
this.refreshAngles()
this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
},
watch: {
tab(val) {
if (val === 'agv') {
this.agvCameraTimer = setInterval(() => {
this.agvCameraUrl = API + '/api/camera/refresh?t=' + Date.now()
}, 1000)
} else {
if (this.agvCameraTimer) {
clearInterval(this.agvCameraTimer)
this.agvCameraTimer = null
}
}
}
},
beforeUnmount() {
Object.values(this.jogIntervals).forEach(i => clearInterval(i))
if (this.agvCameraTimer) clearInterval(this.agvCameraTimer)
if (this.nav2Timer) clearInterval(this.nav2Timer)
},
methods: {
async refresh() {
try {
const res = await fetch(API + '/api/status')
const data = await res.json()
this.agvConnected = data.agv_connected
this.armConnected = data.arm_connected
this.cameraOpened = data.camera_opened
this.mapLoaded = data.map_loaded
if (data.map_loaded) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
} catch (e) {}
await this.loadAllPoints()
await this.loadAllModels()
await this.loadAllMachines()
await this.loadMissionConfig()
},
// === 地图 ===
async loadMap() {
const res = await fetch(API + '/api/map/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
const data = await res.json()
this.mapMsg = data.ok ? '✅ 地图加载成功' : '❌ ' + (data.error || '加载失败')
this.mapLoaded = data.ok
if (data.ok) {
this.mapImageUrl = API + '/api/map/image?t=' + Date.now()
try {
const metaRes = await fetch(API + '/api/map/meta')
const meta = await metaRes.json()
if (meta.ok) this.mapMeta = meta
} catch (e) {}
}
},
onMapError() {
this.mapMsg = '❌ 地图图像加载失败'
},
rotateMap(deg) {
this.mapRotation = (this.mapRotation + deg) % 360
},
resetMapView() {
this.mapRotation = 0
this.mapVersion++
},
async refreshNavStatus() {
try {
const res = await fetch(API + '/api/navigate/status')
if (res.ok) {
const data = await res.json()
this.nav2Available = data.nav2_available
if (data.current_pos) {
this.navCurrentPos = data.current_pos
}
}
} catch (e) {}
},
async onMapClick(e) {
if (!this.mapMeta || !this.agvConnected) return
const rect = e.target.getBoundingClientRect()
const px = (e.clientX - rect.left) / rect.width
const py = (e.clientY - rect.top) / rect.height
const { resolution, origin } = this.mapMeta
const wx = origin[0] + px * resolution * this.mapMeta.width
const wy = origin[1] + (1 - py) * resolution * this.mapMeta.height
try {
const res = await fetch(API + '/api/navigate/to', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: wx, y: wy })
})
const data = await res.json()
if (data.ok) {
this.mapMsg = '✅ 导航目标已发送'
this.mapVersion++
} else {
this.mapMsg = '❌ ' + (data.error || '导航失败')
}
} catch (e) {
this.mapMsg = '❌ 导航请求失败'
}
setTimeout(() => { this.mapMsg = '' }, 3000)
},
getMapX(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, width } = this.mapMeta
const px = (x - origin[0]) / (resolution * width) * 100
return Math.max(0, Math.min(100, px))
},
getMapY(coords) {
if (!coords || !this.mapMeta) return 50
const [x, y, yaw] = coords
const { resolution, origin, height } = this.mapMeta
const py = (y - origin[1]) / (resolution * height) * 100
return Math.max(0, Math.min(100, 100 - py))
},
async saveMap() {
await fetch(API + '/api/map/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.mapForm)
})
this.mapMsg = '✅ 地图配置已保存'
},
// === 点位 ===
async loadAllPoints() {
const res = await fetch(API + '/api/points/list')
const data = await res.json()
this.points = data.points || []
},
async addPoint() {
const res = await fetch(API + '/api/points/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newPointName || 'point_' + (this.points.length + 1),
photo_mode: this.newPointMode,
sequence: this.newPointSequence
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllPoints()
this.newPointName = ''
}
},
async deletePoint(id) {
if (!confirm('确定删除该点位?')) return
await fetch(API + '/api/points/delete/' + id, { method: 'DELETE' })
await this.loadAllPoints()
},
async saveAllPoints() {
await fetch(API + '/api/points/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: this.points })
})
alert('点位已保存')
},
getPoint(id) {
return this.points.find(p => p.id === id)
},
formatAngles(angles) {
if (!angles) return '—'
return angles.map(a => (a || 0).toFixed(1) + '°').join(' / ')
},
// === 机型管理 ===
async loadAllModels() {
const res = await fetch(API + '/api/models/list')
const data = await res.json()
this.models = data.models || []
this.models.forEach(m => {
if (!this.poseForm[m.id]) {
this.poseForm[m.id] = { name: '', photo_type: 'front', description: '' }
}
})
},
async addModel() {
const res = await fetch(API + '/api/models/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.newModelName || 'model_' + (this.models.length + 1),
serial_prefix: this.newModelSerial,
description: ''
})
})
const data = await res.json()
if (data.ok) {
await this.loadAllModels()
this.newModelName = ''
this.newModelSerial = ''
}
},
async deleteModel(modelId) {
if (!confirm('确定删除该机型?其下所有姿态将被删除!')) return
await fetch(API + '/api/models/delete/' + modelId, { method: 'DELETE' })
await this.loadAllModels()
},
// === 姿态管理(属于机型)===
async addPose(modelId) {
const form = this.poseForm[modelId]
if (!form) return
await fetch(API + '/api/models/poses/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: modelId,
name: form.name || '姿态' + ((this.getModel(modelId)?.poses?.length || 0) + 1),
photo_type: form.photo_type,
arm_angles: this.currentAngles,
speed: 500,
description: form.description || ''
})
})
await this.loadAllModels()
form.name = ''
form.description = ''
},
async deletePose(modelId, poseId) {
if (!confirm('确定删除该姿态?')) return
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, { method: 'DELETE' })
await this.loadAllModels()
},
getModel(id) {
return this.models.find(m => m.id === id)
},
// === 任务配置 ===
async loadMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config')
const data = await res.json()
if (data.ok && data.config) {
this.missionConfig.rows = data.config.rows || 3
this.missionConfig.cols = data.config.cols || 3
this.missionConfig.grid = data.config.grid || []
}
} catch (e) { console.error('加载任务配置失败', e) }
},
async generateGrid() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: []
})
})
const data = await res.json()
if (data.ok) {
this.missionConfig.grid = data.config.grid || []
alert('✅ 网格已生成 (' + this.missionConfig.rows + '×' + this.missionConfig.cols + ')')
} else {
alert('❌ 网格生成失败')
}
} catch (e) { alert('请求失败: ' + e.message) }
},
async saveMissionConfig() {
try {
const res = await fetch(API + '/api/mission/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: this.missionConfig.rows,
cols: this.missionConfig.cols,
grid: this.missionConfig.grid
})
})
const data = await res.json()
if (data.ok) {
alert('✅ 网格配置已保存')
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async loadAllMachines() {
try {
const res = await fetch(API + '/api/mission/machines')
const data = await res.json()
this.missionConfig.machines = data.machines || []
} catch (e) { console.error('加载机器列表失败', e) }
},
getMachineAt(ri, ci) {
if (!this.missionConfig.machines) return null
return this.missionConfig.machines.find(m => m.row === ri && m.col === ci) || null
},
getPositionAt(ri, ci) {
if (!this.missionConfig.machines) return null
const machine = this.getMachineAt(ri, ci)
if (!machine) return null
if (ri === 0) return machine.front
const prevMachine = this.getMachineAt(ri - 1, ci)
return prevMachine ? prevMachine.back : machine.front
},
onCellClick(ri, ci) {
const m = this.getMachineAt(ri, ci)
if (!m) {
// 无机器 → 创建机器记录并选中
this.createMachine(ri, ci).then(ok => {
if (ok) {
const created = this.getMachineAt(ri, ci)
if (created) this.selectMachine(created)
}
})
} else {
// 有机器 → 选中
this.selectMachine(m)
}
},
async createMachine(ri, ci) {
try {
const machineId = 'm_' + ri + '_' + ci
const res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
const data = await res.json()
if (!data.ok && data.error !== '该位置已有机器') {
alert('创建机器失败: ' + (data.error || '未知错误'))
return false
}
await this.loadAllMachines()
return true
} catch (e) { alert('创建机器失败: ' + e.message); return false }
},
selectMachine(machine) {
if (!machine.front) machine.front = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.front.coords)) machine.front.coords = [0, 0, 0]
if (!machine.back) machine.back = { coords: [0, 0, 0], poses: [] }
else if (!Array.isArray(machine.back.coords)) machine.back.coords = [0, 0, 0]
this.selectedMachine = machine
},
clearSelection() {
this.selectedMachine = null
},
async deleteMachine(machineId) {
if (!confirm('确定删除此机器?')) return
try {
await fetch(API + '/api/mission/machines/' + machineId, { method: 'DELETE' })
this.selectedMachine = null
await this.loadAllMachines()
} catch (e) { alert('删除失败: ' + e.message) }
},
async saveMachineCoords() {
if (!this.selectedMachine) return
try {
const res = await fetch(API + '/api/mission/machines/' + this.selectedMachine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
front: this.selectedMachine.front,
back: this.selectedMachine.back
})
})
if (res.ok) {
this.mapMsg = '✅ 机器坐标已保存'
setTimeout(() => this.mapMsg = '', 2000)
} else {
alert('保存失败: ' + res.status)
}
} catch (e) { alert('保存失败: ' + e.message) }
},
async readPosition(side) {
if (!this.agvConnected) { alert('AGV 未连接'); return }
try {
const res = await fetch(API + '/api/agv/position')
const data = await res.json()
if (data.ok && data.position) {
const [x, y, theta] = data.position
if (side === 'front') {
this.selectedMachine.front.coords = [x, y, theta]
} else {
this.selectedMachine.back.coords = [x, y, theta]
}
} else {
alert('读取位置失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async addPoseToMachine(machineId, side) {
const name = this.poseForm.name || '姿态' + (((this.selectedMachine && this.selectedMachine[side] && this.selectedMachine[side].poses) || []).length + 1)
try {
const res = await fetch(API + '/api/mission/poses/' + machineId + '/' + side, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
arm_angles: this.currentAngles.length === 6 ? this.currentAngles : [0, 0, 0, 0, 0, 0],
speed: 500,
description: ''
})
})
const data = await res.json()
if (data.ok) {
this.poseForm.name = ''
await this.loadAllMachines()
// 重新选中当前机器以刷新姿态列表
const updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
} else {
alert('添加姿态失败: ' + (data.error || '未知错误'))
}
} catch (e) { alert('添加姿态失败: ' + e.message) }
},
async deletePose(machineId, side, poseId) {
if (!confirm('确定删除此姿态?')) return
try {
await fetch(API + '/api/mission/poses/' + machineId + '/' + side + '/' + poseId, { method: 'DELETE' })
await this.loadAllMachines()
if (this.selectedMachine) {
const updated = this.getMachineAt(this.selectedMachine.row, this.selectedMachine.col)
if (updated) this.selectMachine(updated)
}
} catch (e) { alert('删除姿态失败: ' + e.message) }
},
async capturePosition(ri, ci, side) {
if (!this.agvConnected) { alert('请先连接AGV'); return }
let machine = this.getMachineAt(ri, ci)
if (!machine) {
try {
const machineId = 'm_' + ri + '_' + ci
const res = await fetch(API + '/api/mission/machines/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: machineId,
row: ri,
col: ci,
front: { coords: [0, 0, 0], poses: [] },
back: { coords: [0, 0, 0], poses: [] }
})
})
if (!res.ok) throw new Error('创建失败')
await this.loadAllMachines()
machine = this.getMachineAt(ri, ci)
} catch (e) { alert('创建机器失败: ' + e.message); return }
}
try {
const res = await fetch(API + '/api/agv/position')
const pos = await res.json()
let x = 0, y = 0, theta = 0
if (pos.ok && pos.position && Array.isArray(pos.position)) {
x = pos.position[0] || 0
y = pos.position[1] || 0
theta = pos.position[2] || 0
} else {
alert('读取位置失败: ' + (pos.error || '未知错误'))
return
}
if (!machine) { machine = this.getMachineAt(ri, ci) }
if (!machine) { alert('机器记录不存在'); return }
if (side === 'front') { machine.front.coords = [x, y, theta] } else { machine.back.coords = [x, y, theta] }
await fetch(API + '/api/mission/machines/' + machine.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(machine)
})
alert((side === 'front' ? '正面' : '背面') + '点位已更新: (' + x.toFixed(2) + ',' + y.toFixed(2) + ',' + theta.toFixed(2) + ')')
} catch (e) { alert('读取位置失败: ' + e.message) }
},
async refreshSequence() {
try {
const res = await fetch(API + '/api/mission/generate_sequence')
const data = await res.json()
if (data.ok) {
this.sequence = data.sequence || []
}
} catch (e) { console.error('刷新序列失败', e) }
},
// === 机械臂 ===
async refreshAngles() {
if (!this.armConnected) return
try {
const res = await fetch(API + '/api/arm/get_angles')
const data = await res.json()
if (data.ok && data.angles) {
this.currentAngles = data.angles
this.angleInputs = [...data.angles]
}
} catch (e) {}
},
async setAngle(idx, val) {
await fetch(API + '/api/arm/set_angle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint: 'J' + (idx + 1), angle: val })
})
},
async applyAngles() {
await fetch(API + '/api/arm/set_angles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ angles: this.angleInputs, speed: 500 })
})
},
jogStart(idx, dir) {
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: dir })
})
this.jogIntervals[idx] = setInterval(() => this.refreshAngles(), 200)
},
jogStop(idx) {
clearInterval(this.jogIntervals[idx])
const joint = 'J' + (idx + 1)
fetch(API + '/api/arm/jog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ joint, direction: 0 })
})
setTimeout(() => this.refreshAngles(), 300)
},
onPreviewError(e) {
e.target.style.display = 'none'
},
// === AGV 控制 ===
async refreshAgvPosition() {
if (!this.agvConnected) return
try {
const res = await fetch(API + '/api/agv/position')
const data = await res.json()
if (data.ok) {
this.agvPosition = data.position
this.agvBattery = data.battery
}
} catch (e) {}
},
agvMoveStart(dir) {
if (!this.agvConnected) return
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: dir, speed: this.agvSpeed })
})
},
agvMoveStop() {
fetch(API + '/api/agv/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: 'stop' })
})
},
async agvStop() {
await fetch(API + '/api/agv/stop', { method: 'POST' })
},
async agvResetCollision() {
if (!this.agvConnected) {
alert('AGV 未连接')
return
}
if (!confirm('确定执行撞物体后复位?')) return
try {
const res = await fetch(API + '/api/agv/reset', { method: 'POST' })
const data = await res.json()
if (data.ok) {
alert('✅ ' + data.message)
await this.refresh()
await this.refreshAgvPosition()
} else {
alert('❌ 复位失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('❌ 复位请求失败: ' + e.message)
}
},
}
}).mount('#app')
+3 -10
View File
@@ -84,14 +84,6 @@ class MissionExecutorV3:
self._step_choice = None # "confirm", "retry", "abort"
self._error_mode = False # True when waiting for error resolution
# 错误弹窗
self._error_choice = None # "skip" or "abort"
# 单步执行
self._single_step_mode = False
self._step_choice = None # "confirm", "retry", "abort"
self._error_mode = False # True when waiting for error resolution
# 设备
from .arm_client import ArmClient
self.arm_client = ArmClient(
@@ -553,14 +545,15 @@ class MissionExecutorV3:
return grid
@staticmethod
def pre_generate_tasks(mission_config: dict) -> list:
def pre_generate_tasks(mission_config: dict, machines: list = None) -> list:
"""从网格配置预生成任务列表(用于 UI 展示,无需启动执行器)"""
rows = int(mission_config.get("rows", 1))
cols = int(mission_config.get("cols", 1))
grid = mission_config.get("grid", [])
machines = machines or []
# 如果 grid 为空但从 machines 重建
if not grid and machines:
if (not grid or all(not any(row) if isinstance(row, list) else True for row in grid)) and machines:
grid = MissionExecutorV3._build_grid_from_machines(rows, cols, machines)
if grid:
rows = len(grid)