codex整理结果
This commit is contained in:
Binary file not shown.
+28
-48
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"
|
||||
@@ -3,8 +3,6 @@ const { createApp } = Vue
|
||||
const API = ''
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
connecting: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user