init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .app import app
|
||||
|
||||
__all__ = ["app"]
|
||||
+1205
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
配置文件 - 所有可配置参数集中管理
|
||||
"""
|
||||
import os
|
||||
|
||||
# 基础路径(部署后对应 ~/work/agv_app)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ========== AGV 参数 ==========
|
||||
AGV_CONFIG = {
|
||||
"device": "/dev/agvpro_controller",
|
||||
"baudrate": 10000000,
|
||||
"move_speed": 0.5,
|
||||
"turn_speed": 0.5,
|
||||
}
|
||||
|
||||
# ========== 机械臂 TCP 客户端 ==========
|
||||
ARM_CONFIG = {
|
||||
"host": "192.168.110.164",
|
||||
"port": 5002,
|
||||
"timeout": 8,
|
||||
"retry_times": 3,
|
||||
"retry_interval": 1,
|
||||
}
|
||||
|
||||
# ========== 地图 ==========
|
||||
MAP_CONFIG = {
|
||||
"map_dir": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/",
|
||||
"map_file": "map.yaml",
|
||||
}
|
||||
|
||||
# ========== 摄像头 ==========
|
||||
CAMERA_CONFIG = {
|
||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||
"qr_detect_interval": 0.5,
|
||||
"capture_delay": 0.5,
|
||||
}
|
||||
|
||||
# ========== 机械臂摄像头流 ==========
|
||||
ARM_CAMERA_CONFIG = {
|
||||
"url": "http://192.168.110.164:5003/api/camera/preview",
|
||||
}
|
||||
|
||||
# ========== HTTP 上传 ==========
|
||||
UPLOAD_CONFIG = {
|
||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
}
|
||||
|
||||
# ========== Flask 服务器 ==========
|
||||
SERVER_CONFIG = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5000,
|
||||
"secret_key": "agv630_secret_key_2024",
|
||||
"debug": False,
|
||||
}
|
||||
|
||||
# ========== 任务配置存储路径 ==========
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# ========== 关节角度范围限制 ==========
|
||||
JOINT_LIMITS = {
|
||||
"J1": (-180.0, 180.0),
|
||||
"J2": (-270.0, 90.0),
|
||||
"J3": (-150.0, 150.0),
|
||||
"J4": (-260.0, 80.0),
|
||||
"J5": (-168.0, 168.0),
|
||||
"J6": (-174.0, 174.0),
|
||||
}
|
||||
|
||||
# ========== 机械臂默认速度 ==========
|
||||
DEFAULT_ARM_SPEED = 500
|
||||
|
||||
# ========== 状态定义 ==========
|
||||
class State:
|
||||
SETTING = "setting"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
IDLE = "idle"
|
||||
|
||||
class PhotoType:
|
||||
FRONT = "front"
|
||||
BACK = "back"
|
||||
NAMEPLATE = "nameplate"
|
||||
@@ -0,0 +1,296 @@
|
||||
[
|
||||
{
|
||||
"id": "m_0_2",
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"front": {
|
||||
"coords": [
|
||||
1.2421705407118802,
|
||||
0.0025490140048510445,
|
||||
0.00150923641
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_1_2",
|
||||
"row": 1,
|
||||
"col": 2,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_2_0",
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_2_2",
|
||||
"row": 2,
|
||||
"col": 2,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_3_0",
|
||||
"row": 3,
|
||||
"col": 0,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_3_2",
|
||||
"row": 3,
|
||||
"col": 2,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_3_1",
|
||||
"row": 3,
|
||||
"col": 1,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_4_4",
|
||||
"row": 4,
|
||||
"col": 4,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_4_1",
|
||||
"row": 4,
|
||||
"col": 1,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_4_0",
|
||||
"row": 4,
|
||||
"col": 0,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_3_4",
|
||||
"row": 3,
|
||||
"col": 4,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_3_3",
|
||||
"row": 3,
|
||||
"col": 3,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_0_1",
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m_0_0",
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"front": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
"back": {
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"map_dir": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/",
|
||||
"map_file": "map.yaml",
|
||||
"map_yaml": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/map.yaml"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"rows": 2,
|
||||
"cols": 2,
|
||||
"grid": [],
|
||||
"positions": [
|
||||
{
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"side": "front",
|
||||
"coords": [
|
||||
0.616485726055098,
|
||||
-0.002587517923224651,
|
||||
-0.003483980050000001
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 3,
|
||||
"col": 1,
|
||||
"side": "back",
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"side": "front",
|
||||
"coords": [
|
||||
-0.27906358987415997,
|
||||
0.00411087876725537,
|
||||
-0.00749475593
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"side": "front",
|
||||
"coords": [
|
||||
0.616485726055098,
|
||||
-0.002587517923224651,
|
||||
-0.003483980050000001
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"col": 2,
|
||||
"side": "front",
|
||||
"coords": [
|
||||
-0.27906358987415997,
|
||||
0.00411087876725537,
|
||||
-0.00749475593
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 2,
|
||||
"col": 1,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
-1.898244121263206,
|
||||
-0.014324627152337432,
|
||||
0.004533442980000002
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 2,
|
||||
"col": 0,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
-0.9528404539697249,
|
||||
-0.01004755255507813,
|
||||
0.005515614170000002
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"col": 1,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
-0.9528404539697249,
|
||||
-0.01004755255507813,
|
||||
0.005515614170000002
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 1,
|
||||
"col": 1,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
-0.9528404539697249,
|
||||
-0.01004755255507813,
|
||||
0.005515614170000002
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 0,
|
||||
"col": 0,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
},
|
||||
{
|
||||
"row": 1,
|
||||
"col": 0,
|
||||
"side": "shoot",
|
||||
"coords": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"poses": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"coords": [
|
||||
-0.2489133152442747,
|
||||
-0.9566827357283122,
|
||||
1.3165501267
|
||||
],
|
||||
"id": "p_1778482526",
|
||||
"name": "point_1",
|
||||
"photo_mode": "front",
|
||||
"poses": [
|
||||
{
|
||||
"arm_angles": [],
|
||||
"id": "pose_1778483465",
|
||||
"name": "姿态1",
|
||||
"photo_type": "front",
|
||||
"speed": 500
|
||||
}
|
||||
],
|
||||
"sequence": [
|
||||
"front",
|
||||
"back"
|
||||
]
|
||||
},
|
||||
{
|
||||
"coords": [
|
||||
-0.13938025759948866,
|
||||
-0.5310313938681763,
|
||||
1.3225773811300001
|
||||
],
|
||||
"id": "p_1778482605",
|
||||
"name": "point_2",
|
||||
"photo_mode": "front",
|
||||
"poses": [],
|
||||
"sequence": [
|
||||
"front",
|
||||
"back"
|
||||
]
|
||||
},
|
||||
{
|
||||
"coords": [
|
||||
-0.5498454634407133,
|
||||
0.4294772846745445,
|
||||
2.083953415929999
|
||||
],
|
||||
"id": "p_1778483433",
|
||||
"name": "point_3",
|
||||
"photo_mode": "front",
|
||||
"poses": [],
|
||||
"sequence": [
|
||||
"front",
|
||||
"back"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
flask>=2.0
|
||||
flask-cors>=3.0
|
||||
pymycobot>=4.0.0
|
||||
opencv-python>=4.5
|
||||
pyzbar>=0.1.8
|
||||
requests>=2.25
|
||||
numpy>=1.20
|
||||
@@ -0,0 +1,385 @@
|
||||
<!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=20260514a">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="logo">⚙️ 系统设置</div>
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-link">🏠 首页</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 === '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">
|
||||
<h2>地图可视化</h2>
|
||||
<div class="map-container" style="position:relative;background:#111;border-radius:8px;overflow:hidden">
|
||||
<img :src="mapImageUrl" @error="onMapError" style="width:100%;display:block">
|
||||
<!-- 地图覆盖层:显示点位坐标 -->
|
||||
<div class="map-overlay">
|
||||
<!-- 点位坐标点 -->
|
||||
<div v-for="(p, pi) in missionConfig.positions" :key="'pdot-'+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>
|
||||
</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">
|
||||
<button class="btn btn-primary" @click="generateGrid">🔲 生成网格</button>
|
||||
<button class="btn btn-secondary" @click="saveMissionConfig" style="margin-left:6px">💾 保存网格</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: getMachineAt(ri-1, ci-1) }"
|
||||
@click="onCellClick(ri-1, ci-1)">
|
||||
<template v-if="getMachineAt(ri-1, ci-1)">
|
||||
<div class="cell-machine">✅</div>
|
||||
</template>
|
||||
<span v-else class="empty-cell">⬜</span>
|
||||
</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: getMachineAt(missionConfig.rows-1, ci-1) }"
|
||||
@click="onCellClick(missionConfig.rows-1, ci-1)">
|
||||
<template v-if="getMachineAt(missionConfig.rows-1, ci-1)">
|
||||
<div class="cell-machine">✅</div>
|
||||
</template>
|
||||
<span v-else class="empty-cell">⬜</span>
|
||||
</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" v-if="selectedMachine && selectedMachine.front && selectedMachine.back" style="margin-top:16px">
|
||||
<h2>② 点位配置 — 第{% raw %}{{ selectedMachine.row+1 }}{% endraw %}行 第{% raw %}{{ selectedMachine.col+1 }}{% endraw %}列 <button class="btn btn-small" @click="clearSelection()">← 返回</button></h2>
|
||||
|
||||
<!-- 正面点位 -->
|
||||
<div class="machine-form">
|
||||
<h3>📷 正面点位</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>X 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[0]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Y 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[1]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Yaw (弧度)</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[2]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group" style="align-self:end">
|
||||
<button class="btn btn-small btn-primary" @click="readPosition('front')" :disabled="!agvConnected">📍 读取当前位置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 正面姿态列表 -->
|
||||
<div v-if="selectedMachine.front.poses && selectedMachine.front.poses.length > 0" class="pose-list">
|
||||
<h4>正面姿态 ({% raw %}{{ selectedMachine.front.poses.length }}{% endraw %} 个)</h4>
|
||||
<div v-for="pose in selectedMachine.front.poses" :key="pose.id" class="pose-item">
|
||||
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
|
||||
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
|
||||
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'front', pose.id)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pose-add">
|
||||
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:正面全景)">
|
||||
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'front')">➕ 添加姿态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背面点位 -->
|
||||
<div class="machine-form" style="margin-top:16px">
|
||||
<h3>📷 背面点位</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>X 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[0]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Y 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[1]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Yaw (弧度)</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[2]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group" style="align-self:end">
|
||||
<button class="btn btn-small btn-primary" @click="readPosition('back')" :disabled="!agvConnected">📍 读取当前位置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 背面姿态列表 -->
|
||||
<div v-if="selectedMachine.back.poses && selectedMachine.back.poses.length > 0" class="pose-list">
|
||||
<h4>背面姿态 ({% raw %}{{ selectedMachine.back.poses.length }}{% endraw %} 个)</h4>
|
||||
<div v-for="pose in selectedMachine.back.poses" :key="pose.id" class="pose-item">
|
||||
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
|
||||
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
|
||||
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'back', pose.id)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pose-add">
|
||||
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:背面细节)">
|
||||
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'back')">➕ 添加姿态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top:16px">
|
||||
<button class="btn btn-danger" @click="deleteMachine(selectedMachine.id)">🗑️ 删除此机器</button>
|
||||
<button class="btn btn-secondary" @click="saveMachineCoords">💾 保存此机器配置</button>
|
||||
</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:#888">
|
||||
💡 此点位服务于: {% 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-warning" @click="clearPoint" :disabled="canClearPoint(editingPoint.pointRow, editingPoint.col)">🗑️ 清空</button>
|
||||
<button class="btn btn-secondary" @click="closePointEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</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="previewUrl" @error="onPreviewError">
|
||||
</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>
|
||||
<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] ? agvPosition[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ agvPosition[1] ? agvPosition[1].toFixed(2) : '?' }}{% endraw %}</strong></span>
|
||||
<button class="btn btn-small" @click="refreshAgvPosition">🔄 刷新</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=20260513b"></script>
|
||||
<script src="/static/js/setting.js?v=20260514g"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,587 @@
|
||||
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,
|
||||
// 点位
|
||||
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()
|
||||
},
|
||||
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)
|
||||
},
|
||||
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 = '❌ 地图图像加载失败'
|
||||
},
|
||||
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')
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 启动 AGV 拍摄系统
|
||||
|
||||
cd ~/work/agv_app
|
||||
python3 app.py
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# AGV 拍摄系统完整启动脚本 - ROS2 + Flask
|
||||
# 使用方法: ./start_all.sh
|
||||
|
||||
echo "=== 停止旧进程 ==="
|
||||
pkill -f "ros2 launch agv_pro_bringup" 2>/dev/null
|
||||
pkill -f "python.*app.py" 2>/dev/null
|
||||
sleep 2
|
||||
|
||||
echo "=== 启动 ROS2 Bringup ==="
|
||||
# Source ROS2 环境
|
||||
source /opt/ros/humble/setup.bash
|
||||
cd /home/elephant/agv_pro_ros2
|
||||
source install/setup.bash
|
||||
|
||||
# 启动 ROS2 bringup (后台运行)
|
||||
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
|
||||
ROS2_PID=$!
|
||||
echo "ROS2 bringup started, PID: $ROS2_PID"
|
||||
|
||||
# 等待 ROS2 初始化 (AGV节点需要连接串口)
|
||||
echo "等待 ROS2 初始化..."
|
||||
sleep 5
|
||||
|
||||
# 检查 ROS2 节点是否启动
|
||||
if source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash && ros2 node list 2>/dev/null | grep -q agv_pro_node; then
|
||||
echo "✅ ROS2 AGV 节点已启动"
|
||||
else
|
||||
echo "⚠️ ROS2 节点启动可能失败,请检查日志: /tmp/ros2_bringup.log"
|
||||
fi
|
||||
|
||||
echo "=== 启动 Flask ==="
|
||||
cd /home/elephant/work/agv_app
|
||||
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
||||
FLASK_PID=$!
|
||||
echo "Flask started, PID: $FLASK_PID"
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
echo "=== 启动完成 ==="
|
||||
echo "ROS2 log: /tmp/ros2_bringup.log"
|
||||
echo "Flask log: /tmp/agv_flask.log"
|
||||
echo ""
|
||||
echo "检查状态:"
|
||||
echo " ros2 node list"
|
||||
echo " curl http://localhost:5000/api/status"
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Flask 启动脚本 - 杀掉旧进程并重启
|
||||
|
||||
pkill -f "python.*app.py" 2>/dev/null
|
||||
sleep 1
|
||||
|
||||
cd /home/elephant/work/agv_app
|
||||
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
||||
echo "Flask started, PID: $!"
|
||||
@@ -0,0 +1,722 @@
|
||||
/* ========== 全局样式 ========== */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #0f1923;
|
||||
color: #e8eaed;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: #4fc3f7; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ========== 顶部栏 ========== */
|
||||
.topbar {
|
||||
background: #1a2332;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 32px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo { font-size: 18px; font-weight: bold; color: #4fc3f7; }
|
||||
|
||||
.nav { display: flex; gap: 4px; }
|
||||
.nav-link {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
color: #9aa0a6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-link:hover { background: #2a3441; color: #e8eaed; text-decoration: none; }
|
||||
.nav-link.active { background: #263238; color: #4fc3f7; }
|
||||
|
||||
.status-bar { margin-left: auto; display: flex; gap: 12px; }
|
||||
.status-item {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-item.setting { background: #1b3a2f; color: #4caf50; }
|
||||
.status-item.running { background: #2a2a1b; color: #ffeb3b; }
|
||||
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
||||
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
||||
|
||||
/* ========== 卡片 ========== */
|
||||
.card {
|
||||
background: #1a2332;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.card h2 { font-size: 16px; margin-bottom: 16px; color: #4fc3f7; }
|
||||
|
||||
/* ========== 状态卡片 ========== */
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.status-card {
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.status-card.ok { border-color: #2e7d32; background: #0d1f14; }
|
||||
.status-card.error { border-color: #c62828; background: #1f0d0d; }
|
||||
.status-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.status-label { font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
|
||||
.status-value { font-size: 14px; font-weight: bold; }
|
||||
|
||||
/* ========== 按钮 ========== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #37474f; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: #0277bd; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #0288d1; }
|
||||
.btn-secondary { background: #37474f; }
|
||||
.btn-danger { background: #d32f2f; color: #fff; }
|
||||
.btn-danger:hover:not(:disabled) { background: #f44336; }
|
||||
.btn-success { background: #2e7d32; color: #fff; }
|
||||
.btn-success:hover:not(:disabled) { background: #388e3c; }
|
||||
.btn-warning { background: #e65100; color: #fff; }
|
||||
.btn-error { background: #c62828; color: #fff; }
|
||||
.btn-large { padding: 12px 24px; font-size: 16px; }
|
||||
.btn-small { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-icon { background: none; border: none; cursor: pointer; font-size: 14px; padding: 4px; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* ========== 表单 ========== */
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { display: block; font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 6px;
|
||||
color: #e8eaed;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus { outline: none; border-color: #4fc3f7; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* ========== Tabs ========== */
|
||||
.tabs {
|
||||
background: #1a2332;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9aa0a6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab.active { color: #4fc3f7; border-bottom-color: #4fc3f7; }
|
||||
.tab:hover { color: #e8eaed; }
|
||||
|
||||
/* ========== 摄像头预览 ========== */
|
||||
.camera-preview {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 16px;
|
||||
background: #000;
|
||||
}
|
||||
.camera-preview img,
|
||||
.camera-full img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-full {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* ========== 关节控制 ========== */
|
||||
.joints-panel { margin-top: 16px; }
|
||||
.joints-panel h3 { margin-bottom: 12px; font-size: 14px; }
|
||||
.joint-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.joint-control {
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.joint-control label { font-size: 12px; color: #4fc3f7; font-weight: bold; }
|
||||
.joint-value { font-size: 18px; font-weight: bold; color: #fff; margin: 4px 0; }
|
||||
.joint-buttons { display: flex; align-items: center; gap: 4px; justify-content: center; }
|
||||
.joint-buttons button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2a3441;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.joint-buttons input {
|
||||
width: 60px;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 4px;
|
||||
color: #e8eaed;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== 点位列表 ========== */
|
||||
.point-item {
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.point-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.point-name { font-weight: bold; font-size: 15px; }
|
||||
.point-coords { font-size: 12px; color: #9aa0a6; margin-bottom: 8px; }
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
background: #263238;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
.pose-list { margin-top: 8px; }
|
||||
.pose-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.angles { color: #9aa0a6; font-size: 11px; font-family: monospace; }
|
||||
.pose-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-add input { flex: 1; padding: 6px 10px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; font-size: 13px; }
|
||||
.pose-add select { padding: 6px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; }
|
||||
|
||||
.empty-hint { color: #9aa0a6; text-align: center; padding: 20px; }
|
||||
.hint { font-size: 12px; color: #9aa0a6; margin-top: 8px; }
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; }
|
||||
.alert-error { background: #1f0d0d; border: 1px solid #c62828; color: #ef5350; }
|
||||
.checkbox-group { display: flex; gap: 16px; }
|
||||
.checkbox-group label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #e8eaed; }
|
||||
|
||||
/* ========== 运行页面 ========== */
|
||||
.running-header { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
|
||||
.running-status {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.running-status.idle { color: #9aa0a6; }
|
||||
.running-status.running { color: #4caf50; }
|
||||
.running-status.paused { color: #ff9800; }
|
||||
.pulse {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
.running-progress { flex: 1; display: flex; align-items: center; gap: 12px; }
|
||||
.progress-bar { flex: 1; height: 8px; background: #2a3441; border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #4fc3f7; border-radius: 4px; transition: width 0.3s; }
|
||||
|
||||
/* ========== 报告 ========== */
|
||||
.report-summary { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.stat { padding: 8px 16px; border-radius: 8px; background: #0f1923; border: 1px solid #2a3441; }
|
||||
.stat.ok { border-color: #2e7d32; color: #4caf50; }
|
||||
.stat.error { border-color: #c62828; color: #ef5350; }
|
||||
.report-item { padding: 8px 12px; background: #0f1923; border-radius: 6px; margin-bottom: 8px; border: 1px solid #2a3441; }
|
||||
.report-point { display: flex; align-items: center; gap: 8px; font-weight: bold; }
|
||||
.report-status { font-size: 16px; }
|
||||
.report-pose { font-size: 12px; color: #9aa0a6; padding-left: 24px; margin-top: 4px; }
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.container { grid-template-columns: 1fr; }
|
||||
.grid-3 { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.joint-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* AGV 移动控制面板 */
|
||||
.agv-status-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #9aa0a6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.agv-status-bar strong { color: #e8eaed; }
|
||||
|
||||
.agv-control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.agv-dir-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 80px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.agv-dir-placeholder { width: 80px; height: 44px; }
|
||||
.agv-btn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3441;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.agv-btn:active, .agv-btn:focus { outline: none; }
|
||||
.agv-btn-up { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.agv-btn-down { background: #3a1b1b; border-color: #7d2e2e; color: #f44336; }
|
||||
.agv-btn-left { background: #1b2d3a; border-color: #1565c0; color: #42a5f5; }
|
||||
.agv-btn-right { background: #2d2a1b; border-color: #7d6e2e; color: #ffc107; }
|
||||
.agv-btn-stop { background: #37474f; border-color: #546e7a; }
|
||||
.agv-btn-up:active { background: #1e4d38; }
|
||||
.agv-btn-down:active { background: #4d2020; }
|
||||
.agv-btn-left:active { background: #1e3a4d; }
|
||||
.agv-btn-right:active { background: #3d3820; }
|
||||
.agv-btn-stop:active { background: #455a64; }
|
||||
.agv-btn-lateral {
|
||||
background: #2d1b4a;
|
||||
border-color: #7c4dff;
|
||||
color: #b388ff;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.agv-btn-lateral:active { background: #3d2560; }
|
||||
.agv-lateral-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.speed-value {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* 双摄像头预览布局 */
|
||||
.camera-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.camera-box {
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.camera-label {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.camera-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
/* ========== 地图标记 ========== */
|
||||
.map-marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||
z-index: 10;
|
||||
}
|
||||
.map-marker:hover {
|
||||
transform: translate(-50%, -100%) scale(1.2);
|
||||
}
|
||||
|
||||
/* ========== 任务配置 M×N 网格 ========== */
|
||||
.mission-grid-wrap {
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mission-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 80px repeat(var(--cols,4), 90px);
|
||||
}
|
||||
.grid-cell {
|
||||
min-width: 80px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #2a3441;
|
||||
background: #0f1923;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.grid-cell.active {
|
||||
background: #1b3a2f;
|
||||
border-color: #2e7d32;
|
||||
color: #4caf50;
|
||||
}
|
||||
.grid-cell.active:hover {
|
||||
background: #234;
|
||||
}
|
||||
.grid-cell.selected {
|
||||
border-color: #4fc3f7 !important;
|
||||
box-shadow: 0 0 0 2px #4fc3f7;
|
||||
}
|
||||
.grid-header {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
color: #9aa0a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 机器配置表单 */
|
||||
.machine-form {
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.machine-form h3 {
|
||||
font-size: 14px;
|
||||
color: #4fc3f7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.machine-form h4 {
|
||||
font-size: 13px;
|
||||
color: #9aa0a6;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
/* 姿态列表 */
|
||||
.pose-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pose-name {
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
.pose-angles {
|
||||
color: #9aa0a6;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
.pose-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-add input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 4px;
|
||||
color: #e8eaed;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 蛇形序列预览 */
|
||||
.sequence-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sequence-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: #0f1923;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.step-index {
|
||||
background: #263238;
|
||||
color: #4fc3f7;
|
||||
border-radius: 10px;
|
||||
min-width: 28px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-info {
|
||||
flex: 1;
|
||||
}
|
||||
.step-side {
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-side:contains('正面') {
|
||||
background: #1b3a2f;
|
||||
color: #4caf50;
|
||||
}
|
||||
.step-side:contains('背面') {
|
||||
background: #3a1b2f;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
/* 网格单元格点位配置 */
|
||||
.cell-machine {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.cell-points {
|
||||
margin-top: 2px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 2px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.point-row:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.point-label {
|
||||
color: #666;
|
||||
min-width: 24px;
|
||||
}
|
||||
.point-coords {
|
||||
color: #0366d6;
|
||||
font-family: monospace;
|
||||
font-size: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.btn-icon-small {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-icon-small:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-box {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2a3a50;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
min-width: 380px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
|
||||
|
||||
/* 点位行单元格 */
|
||||
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
|
||||
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
|
||||
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
|
||||
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
|
||||
.point-empty { font-size: 10px; color: #455a64; }
|
||||
|
||||
/* 机器行单元格 */
|
||||
.machine-cell { cursor: pointer; }
|
||||
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
|
||||
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.machine-icon { font-size: 18px; }
|
||||
.machine-empty { font-size: 16px; color: #455a64; }
|
||||
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-box {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2a3a50;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
min-width: 380px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
|
||||
|
||||
/* 点位行单元格 */
|
||||
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
|
||||
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
|
||||
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
|
||||
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
|
||||
.point-empty { font-size: 10px; color: #455a64; }
|
||||
|
||||
/* 机器行单元格 */
|
||||
.machine-cell { cursor: pointer; }
|
||||
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
|
||||
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.machine-icon { font-size: 18px; }
|
||||
.machine-empty { font-size: 16px; color: #455a64; }
|
||||
/* 点位编辑弹窗 */
|
||||
.modal-overlay .modal-box { min-width: 420px; }
|
||||
.modal-overlay .form-row { gap: 8px; }
|
||||
.modal-overlay .btn-row { gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 地图坐标点覆盖层 */
|
||||
.map-container { position: relative; }
|
||||
.map-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none; z-index: 10;
|
||||
}
|
||||
.map-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.point-dot {
|
||||
width: 10px; height: 10px;
|
||||
background: #f39c12;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 6px rgba(243,156,18,0.9);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
const { createApp } = Vue
|
||||
|
||||
const API = ''
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
connecting: false,
|
||||
agvConnected: false,
|
||||
armConnected: false,
|
||||
cameraOpened: false,
|
||||
armCameraOpened: false,
|
||||
mapLoaded: false,
|
||||
mapConfig: {},
|
||||
pointsCount: 0,
|
||||
currentState: 'idle',
|
||||
// 摄像头轮询
|
||||
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
||||
armCameraSrc: '/api/camera/arm_refresh?t=' + Date.now(),
|
||||
agvCameraError: false,
|
||||
armCameraError: false,
|
||||
reconnectingDevice: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allReady() {
|
||||
return this.agvConnected && this.armConnected && this.cameraOpened && this.mapLoaded
|
||||
},
|
||||
statusClass() {
|
||||
return this.currentState
|
||||
},
|
||||
statusText() {
|
||||
const map = { idle: '空闲', setting: '设置模式', running: '运行中', paused: '已暂停' }
|
||||
return map[this.currentState] || '未知'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refresh()
|
||||
setInterval(this.refreshStatus, 3000)
|
||||
this.refreshCams()
|
||||
setInterval(() => this.refreshCams(), 2000)
|
||||
},
|
||||
methods: {
|
||||
refreshCams() {
|
||||
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now()
|
||||
this.armCameraSrc = '/api/camera/arm_refresh?t=' + Date.now()
|
||||
},
|
||||
async refresh() {
|
||||
await this.refreshStatus()
|
||||
await this.loadPoints()
|
||||
},
|
||||
async refreshStatus() {
|
||||
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.armCameraOpened = data.arm_camera_opened
|
||||
this.mapLoaded = data.map_loaded
|
||||
this.currentState = data.state || 'idle'
|
||||
if (data.map_loaded && data.map) {
|
||||
this.mapConfig = data.map
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
async loadPoints() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/points/list')
|
||||
const data = await res.json()
|
||||
this.pointsCount = data.points ? data.points.length : 0
|
||||
} catch (e) {}
|
||||
},
|
||||
async connectAll() {
|
||||
this.connecting = true
|
||||
try {
|
||||
const res = await fetch(API + '/api/system/connect', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.errors && data.errors.length) {
|
||||
alert('部分连接失败:\n' + data.errors.join('\n'))
|
||||
}
|
||||
await this.refreshStatus()
|
||||
} finally {
|
||||
this.connecting = false
|
||||
}
|
||||
},
|
||||
async disconnectAll() {
|
||||
await fetch(API + '/api/system/disconnect', { method: 'POST' })
|
||||
await this.refreshStatus()
|
||||
},
|
||||
async connectDevice(device) {
|
||||
if (this.connecting) return
|
||||
this.reconnectingDevice = device
|
||||
try {
|
||||
const res = await fetch(API + '/api/device/connect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({device})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok && data.error) {
|
||||
alert(data.device + ' 重连失败: ' + data.error)
|
||||
}
|
||||
await this.refreshStatus()
|
||||
} finally {
|
||||
this.reconnectingDevice = null
|
||||
}
|
||||
},
|
||||
async goRunning() {
|
||||
if (!this.allReady) {
|
||||
alert('请先连接所有设备并加载地图')
|
||||
} else {
|
||||
window.location.href = '/running'
|
||||
}
|
||||
}
|
||||
}
|
||||
}).mount('#app')
|
||||
@@ -0,0 +1,80 @@
|
||||
const { createApp } = Vue
|
||||
const API = ''
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
missionState: 'idle',
|
||||
currentPoint: 0,
|
||||
totalPoints: 0,
|
||||
report: null,
|
||||
previewUrl: API + '/api/camera/preview',
|
||||
polling: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
missionStateText() {
|
||||
const map = { idle: '空闲', running: '任务运行中', paused: '已暂停', completed: '已完成' }
|
||||
return map[this.missionState] || '未知'
|
||||
},
|
||||
progressPercent() {
|
||||
if (!this.totalPoints) return 0
|
||||
return Math.round((this.currentPoint / this.totalPoints) * 100)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.poll()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.polling) clearInterval(this.polling)
|
||||
},
|
||||
methods: {
|
||||
poll() {
|
||||
this.refresh()
|
||||
this.polling = setInterval(this.refresh, 2000)
|
||||
},
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/mission/state')
|
||||
const data = await res.json()
|
||||
this.missionState = data.state || 'idle'
|
||||
|
||||
if (this.missionState === 'running') {
|
||||
const reportRes = await fetch(API + '/api/mission/report')
|
||||
const reportData = await reportRes.json()
|
||||
if (reportData.report) {
|
||||
this.totalPoints = reportData.report.total_points || 0
|
||||
this.currentPoint = reportData.report.details?.length || 0
|
||||
this.report = reportData.report
|
||||
}
|
||||
} else if (this.missionState === 'idle') {
|
||||
const reportRes = await fetch(API + '/api/mission/report')
|
||||
const reportData = await reportRes.json()
|
||||
if (reportData.report) {
|
||||
this.report = reportData.report
|
||||
this.totalPoints = reportData.report.total_points || 0
|
||||
this.currentPoint = reportData.report.details?.length || 0
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
async startMission() {
|
||||
if (this.missionState !== 'idle') return
|
||||
await fetch(API + '/api/mission/start', { method: 'POST' })
|
||||
this.missionState = 'running'
|
||||
},
|
||||
async pauseMission() {
|
||||
await fetch(API + '/api/mission/pause', { method: 'POST' })
|
||||
this.missionState = 'paused'
|
||||
},
|
||||
async stopMission() {
|
||||
await fetch(API + '/api/mission/stop', { method: 'POST' })
|
||||
this.missionState = 'idle'
|
||||
},
|
||||
onPreviewError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}).mount('#app')
|
||||
@@ -0,0 +1,887 @@
|
||||
const { createApp } = Vue
|
||||
const API = ''
|
||||
|
||||
const app = createApp({
|
||||
data() {
|
||||
return {
|
||||
tab: 'map',
|
||||
// 任务配置
|
||||
missionConfig: { rows: 3, cols: 3, grid: [], machines: [], positions: [] },
|
||||
// 点位编辑弹窗
|
||||
editingPoint: null, // 当前编辑的点位 {pointRow, col} — pointRow是点位行号(0~rows)
|
||||
pointEditor: { x: 0, y: 0, yaw: 0 },
|
||||
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,
|
||||
mapVersion: 0, // 地图点位版本号,用于强制重新渲染
|
||||
// 点位
|
||||
points: [],
|
||||
newPointName: '',
|
||||
newPointMode: 'front',
|
||||
newPointSequence: ['front', 'back'],
|
||||
// 机型(姿态组)
|
||||
models: [],
|
||||
selectedModelId: null,
|
||||
newModelName: '',
|
||||
newModelDesc: '',
|
||||
newModelNotes: '',
|
||||
newPoseForm: {}, // 机型配置:新建姿态的表单
|
||||
// 机械臂
|
||||
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()
|
||||
},
|
||||
watch: {
|
||||
// 监听点位数据变化,自动刷新地图
|
||||
'missionConfig.positions'() {
|
||||
this.mapVersion++
|
||||
},
|
||||
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)
|
||||
},
|
||||
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 = '❌ 地图图像加载失败'
|
||||
},
|
||||
getMapX(coords) {
|
||||
if (!coords || !this.mapMeta) {
|
||||
console.log('[getMapX] mapMeta not loaded, returning default 50');
|
||||
return 50
|
||||
}
|
||||
const [x, y, yaw] = coords
|
||||
const { resolution, origin, width } = this.mapMeta
|
||||
const px = (x - origin[0]) / (resolution * width) * 100
|
||||
const result = Math.max(0, Math.min(100, px));
|
||||
console.log('[getMapX]', coords, '→ px%:', result);
|
||||
return result
|
||||
},
|
||||
getMapY(coords) {
|
||||
if (!coords || !this.mapMeta) {
|
||||
console.log('[getMapY] mapMeta not loaded, returning default 50');
|
||||
return 50
|
||||
}
|
||||
const [x, y, yaw] = coords
|
||||
const { resolution, origin, height } = this.mapMeta
|
||||
const py = (y - origin[1]) / (resolution * height) * 100
|
||||
const result = Math.max(0, Math.min(100, 100 - py));
|
||||
console.log('[getMapY]', coords, '→ py%:', result);
|
||||
return result
|
||||
},
|
||||
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 || '型号_' + (this.models.length + 1),
|
||||
description: this.newModelDesc || '',
|
||||
notes: this.newModelNotes || '',
|
||||
serial_prefix: this.newModelSerial || ''
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
await this.loadAllModels()
|
||||
this.newModelName = ''
|
||||
this.newModelDesc = ''
|
||||
this.newModelNotes = ''
|
||||
this.newModelSerial = ''
|
||||
}
|
||||
},
|
||||
async deleteModel(modelId) {
|
||||
if (!confirm('确定删除该机型?其下所有姿态将被删除!')) return
|
||||
await fetch(API + '/api/models/delete/' + modelId, { method: 'DELETE' })
|
||||
await this.loadAllModels()
|
||||
},
|
||||
// === 姿态管理(属于机型)===
|
||||
async addPose(modelId, photoType, customName) {
|
||||
const form = this.poseForm[modelId]
|
||||
const type = photoType || form?.photo_type || 'front'
|
||||
const name = customName || form?.name || type + '_' + ((this.getModel(modelId)?.poses?.filter(p => p.photo_type === type).length || 0) + 1)
|
||||
await fetch(API + '/api/models/poses/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_id: modelId,
|
||||
name: name,
|
||||
photo_type: type,
|
||||
arm_angles: this.currentAngles && this.currentAngles.length === 6 ? this.currentAngles : [0, 0, 0, 0, 0, 0],
|
||||
speed: 500,
|
||||
description: form?.description || ''
|
||||
})
|
||||
})
|
||||
await this.loadAllModels()
|
||||
if (form) {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
}
|
||||
},
|
||||
async deletePose(modelId, poseId) {
|
||||
if (!confirm('确定删除该姿态?')) return
|
||||
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, { method: 'DELETE' })
|
||||
await this.loadAllModels()
|
||||
},
|
||||
async updatePoseAngle(modelId, poseId, jointIndex, event) {
|
||||
const value = parseFloat(event.target.value)
|
||||
if (isNaN(value)) return
|
||||
// Find the pose and update its angle
|
||||
const model = this.getModel(modelId)
|
||||
if (!model) return
|
||||
const pose = model.poses.find(p => p.id === poseId)
|
||||
if (!pose) return
|
||||
if (!pose.arm_angles) pose.arm_angles = [0, 0, 0, 0, 0, 0]
|
||||
pose.arm_angles[jointIndex] = value
|
||||
// Save to backend
|
||||
await fetch(API + '/api/models/' + modelId + '/poses/' + poseId, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ arm_angles: pose.arm_angles })
|
||||
})
|
||||
},
|
||||
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 || []
|
||||
this.missionConfig.machines = data.machines || []
|
||||
this.missionConfig.positions = data.config.positions || []
|
||||
this.mapVersion++
|
||||
}
|
||||
} 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) }
|
||||
},
|
||||
|
||||
// ========== 点位行模型(独立于机器) ==========
|
||||
|
||||
/**
|
||||
* 获取指定点位行的数据
|
||||
* @param {number} pointRow - 点位行号,范围 0 ~ missionConfig.rows
|
||||
* - pointRow=0 → 第1台机器的正面拍摄点
|
||||
* - pointRow=rows → 最后1台机器的背面拍摄点
|
||||
* - 中间 pointRow=i → 上面机器(i)的背面 + 下面机器(i+1)的正面
|
||||
* @param {number} col - 列号(0-based)
|
||||
* @returns {object|null} 点位对象 { coords: [x,y,z], ... } 或 null
|
||||
*/
|
||||
getPointAt(pointRow, col) {
|
||||
var positions = this.missionConfig.positions || []
|
||||
// 在独立 positions 数组中查找 (pointRow, col)
|
||||
for (var i = 0; i < positions.length; i++) {
|
||||
var p = positions[i]
|
||||
if (parseInt(p.row) === pointRow && parseInt(p.col) === col) {
|
||||
// 优先找 shoot,其次找 front/back
|
||||
if (p.side === 'shoot') return p
|
||||
// 再看有没有同一(row,col)的shoot
|
||||
}
|
||||
}
|
||||
// 再找同(row,col)的shoot类型
|
||||
for (var i = 0; i < positions.length; i++) {
|
||||
var p = positions[i]
|
||||
if (parseInt(p.row) === pointRow && parseInt(p.col) === col && p.side === 'shoot') {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// 兼容旧数据:尝试从机器对象的 front/back 获取
|
||||
var machineAbove = this.getMachineAt(pointRow - 1, col) // 上面的机器
|
||||
var machineBelow = this.getMachineAt(pointRow, col) // 下面的机器
|
||||
if (pointRow === 0 && machineBelow) {
|
||||
// 第一个点位行 → 下面机器的正面
|
||||
return machineBelow.front || { coords: [0, 0, 0], poses: [] }
|
||||
}
|
||||
if (pointRow === this.missionConfig.rows && machineAbove) {
|
||||
// 最后一个点位行 → 上面机器的背面
|
||||
return machineAbove.back || { coords: [0, 0, 0], poses: [] }
|
||||
}
|
||||
// 中间点位行:优先返回上面机器的背面
|
||||
if (machineAbove && machineAbove.back) {
|
||||
return machineAbove.back
|
||||
}
|
||||
if (machineBelow && machineBelow.front) {
|
||||
return machineBelow.front
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取点位的归属描述(用于弹窗标题)
|
||||
* @param {number} pointRow - 点位行号
|
||||
* @param {number} col - 列号
|
||||
* @returns {string} 如 "第2列 · 机器2背面/机器3正面"
|
||||
*/
|
||||
getPointOwnerLabel(pointRow, col) {
|
||||
var rows = this.missionConfig.rows
|
||||
var labels = []
|
||||
if (pointRow === 0) {
|
||||
// 第一行点位:下面机器的正面
|
||||
var m = this.getMachineAt(0, col)
|
||||
if (m) labels.push('机器' + (m.row + 1) + '正面')
|
||||
} else if (pointRow === rows) {
|
||||
// 最后行点位:上面机器的背面
|
||||
var m2 = this.getMachineAt(rows - 1, col)
|
||||
if (m2) labels.push('机器' + (m2.row + 1) + '背面')
|
||||
} else {
|
||||
// 中间点位行:上面机器的背面 + 下面机器的正面
|
||||
var mAbove = this.getMachineAt(pointRow - 1, col)
|
||||
var mBelow = this.getMachineAt(pointRow, col)
|
||||
if (mAbove) labels.push('机器' + (mAbove.row + 1) + '背面')
|
||||
if (mBelow) labels.push('机器' + (mBelow.row + 1) + '正面')
|
||||
}
|
||||
if (labels.length === 0) return '第' + (col + 1) + '列 · 无归属'
|
||||
return '第' + (col + 1) + '列 · ' + labels.join('/')
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查点位是否可以清空
|
||||
* 只有当上下两台机器都不需要这个点位时才能清空
|
||||
*/
|
||||
canClearPoint(pointRow, col) {
|
||||
var rows = this.missionConfig.rows
|
||||
// 如果上面有机器且机器存在 → 不能清空(上面机器需要此点位拍背面)
|
||||
if (pointRow > 0 && pointRow <= rows) {
|
||||
var mAbove = this.getMachineAt(pointRow - 1, col)
|
||||
if (mAbove) return false
|
||||
}
|
||||
// 如果下面有机器且机器存在 → 不能清空(下面机器需要此点位拍正面)
|
||||
if (pointRow >= 0 && pointRow < rows) {
|
||||
var mBelow = this.getMachineAt(pointRow, col)
|
||||
if (mBelow) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
getMachineAt(ri, ci) {
|
||||
if (!this.missionConfig.machines) return null
|
||||
return this.missionConfig.machines.find(function(m) { return m.row === ri && m.col === ci }) || null
|
||||
},
|
||||
|
||||
// 打开点位编辑弹窗(基于点位行号)
|
||||
openPointEdit(pointRow, col) {
|
||||
var existing = this.getPointAt(pointRow, col)
|
||||
if (existing && existing.coords) {
|
||||
this.pointEditor.x = existing.coords[0] !== undefined ? existing.coords[0] : 0
|
||||
this.pointEditor.y = existing.coords[1] !== undefined ? existing.coords[1] : 0
|
||||
this.pointEditor.yaw = existing.coords[2] !== undefined ? existing.coords[2] : 0
|
||||
} else {
|
||||
this.pointEditor.x = 0
|
||||
this.pointEditor.y = 0
|
||||
this.pointEditor.yaw = 0
|
||||
}
|
||||
this.editingPoint = { pointRow: pointRow, col: col }
|
||||
},
|
||||
|
||||
// 关闭点位编辑弹窗
|
||||
closePointEdit() {
|
||||
this.editingPoint = null
|
||||
},
|
||||
|
||||
// 从AGV读取当前坐标到点位编辑器
|
||||
async loadPointFromAgv() {
|
||||
if (!this.agvConnected) { alert('请先连接AGV'); return }
|
||||
try {
|
||||
const res = await fetch(API + '/api/agv/position')
|
||||
const pos = await res.json()
|
||||
if (pos.ok && pos.position && Array.isArray(pos.position)) {
|
||||
this.pointEditor.x = pos.position[0] ?? 0
|
||||
this.pointEditor.y = pos.position[1] ?? 0
|
||||
this.pointEditor.yaw = pos.position[2] ?? 0
|
||||
alert('✅ 已读取AGV位置: (' + this.pointEditor.x.toFixed(2) + ', ' + this.pointEditor.y.toFixed(2) + ', ' + this.pointEditor.yaw.toFixed(2) + ')')
|
||||
} else if (pos.ok && (!pos.position || !Array.isArray(pos.position))) {
|
||||
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
|
||||
} else {
|
||||
alert('读取AGV位置失败: ' + (pos.error || '未知错误'))
|
||||
}
|
||||
} catch (e) { alert('读取AGV位置失败: ' + e.message) }
|
||||
},
|
||||
|
||||
// 保存点位配置到独立 positions 数组
|
||||
async savePoint() {
|
||||
if (!this.editingPoint) return
|
||||
var pointRow = this.editingPoint.pointRow
|
||||
var col = this.editingPoint.col
|
||||
var coords = [this.pointEditor.x, this.pointEditor.y, this.pointEditor.yaw]
|
||||
try {
|
||||
// 保存到后端独立点位表(使用 point_row 标识点位行)
|
||||
var saveRes = await fetch(API + '/api/mission/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
row: pointRow,
|
||||
col: col,
|
||||
side: 'shoot', // 统一用 'shoot' 表示拍摄点位
|
||||
coords: coords,
|
||||
poses: []
|
||||
})
|
||||
})
|
||||
var saveData = await saveRes.json()
|
||||
if (!saveData.ok) {
|
||||
alert('保存点位失败: ' + (saveData.error || '未知错误'))
|
||||
return
|
||||
}
|
||||
|
||||
// 同步更新关联机器的坐标(如果有的话)
|
||||
this.syncPointToMachines(pointRow, col, coords)
|
||||
|
||||
await this.loadMissionConfig()
|
||||
alert('✅ 点位坐标已保存: (' + coords[0].toFixed(2) + ', ' + coords[1].toFixed(2) + ', ' + coords[2].toFixed(2) + ')')
|
||||
this.closePointEdit()
|
||||
} catch (e) { alert('保存点位失败: ' + e.message) }
|
||||
},
|
||||
|
||||
// 将点位坐标同步到关联的机器对象
|
||||
syncPointToMachines(pointRow, col, coords) {
|
||||
var rows = this.missionConfig.rows
|
||||
// pointRow=0 → 同步到下面机器(0,col)的front
|
||||
if (pointRow === 0) {
|
||||
var m0 = this.getMachineAt(0, col)
|
||||
if (m0) {
|
||||
this.updateMachineSide(m0, 'front', coords)
|
||||
}
|
||||
return
|
||||
}
|
||||
// pointRow=rows → 同步到上面机器(rows-1,col)的back
|
||||
if (pointRow === rows) {
|
||||
var mLast = this.getMachineAt(rows - 1, col)
|
||||
if (mLast) {
|
||||
this.updateMachineSide(mLast, 'back', coords)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 中间点位行 → 同步到上面机器的back + 下面机器的front
|
||||
var mAbove = this.getMachineAt(pointRow - 1, col)
|
||||
var mBelow = this.getMachineAt(pointRow, col)
|
||||
if (mAbove) {
|
||||
this.updateMachineSide(mAbove, 'back', coords)
|
||||
}
|
||||
if (mBelow) {
|
||||
this.updateMachineSide(mBelow, 'front', coords)
|
||||
}
|
||||
},
|
||||
|
||||
// 更新机器某侧的坐标
|
||||
async updateMachineSide(machine, side, coords) {
|
||||
try {
|
||||
var update = {}
|
||||
update[side] = {
|
||||
coords: coords,
|
||||
poses: (machine[side] && machine[side].poses) ? machine[side].poses : []
|
||||
}
|
||||
await fetch(API + '/api/mission/machines/' + machine.id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update)
|
||||
})
|
||||
} catch (e) { console.error('同步机器坐标失败', e) }
|
||||
},
|
||||
|
||||
// 清空点位(带保护检查)
|
||||
async clearPoint() {
|
||||
if (!this.editingPoint) return
|
||||
var pointRow = this.editingPoint.pointRow
|
||||
var col = this.editingPoint.col
|
||||
|
||||
// 检查是否可以清空
|
||||
if (!this.canClearPoint(pointRow, col)) {
|
||||
var ownerLabel = this.getPointOwnerLabel(pointRow, col)
|
||||
alert('⚠️ 无法清空!此点位服务于: ' + ownerLabel + '\n必须先移除相关机器才能清空此点位。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm('确定清空此点位坐标?')) return
|
||||
|
||||
try {
|
||||
// 发送清空请求(坐标归零或删除记录)
|
||||
await fetch(API + '/api/mission/positions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
row: pointRow,
|
||||
col: col,
|
||||
side: 'shoot',
|
||||
coords: [0, 0, 0],
|
||||
poses: []
|
||||
})
|
||||
})
|
||||
this.pointEditor.x = 0
|
||||
this.pointEditor.y = 0
|
||||
this.pointEditor.yaw = 0
|
||||
await this.loadMissionConfig()
|
||||
} catch (e) { alert('清空点位失败: ' + e.message) }
|
||||
},
|
||||
|
||||
onCellClick(ri, ci) {
|
||||
var m = this.getMachineAt(ri, ci)
|
||||
if (!m) {
|
||||
// 无机器 → 创建机器记录
|
||||
this.createMachine(ri, ci).then(function(ok) {
|
||||
if (ok) {
|
||||
var created = this.getMachineAt(ri, ci)
|
||||
if (created) this.selectMachine(created)
|
||||
}
|
||||
}.bind(this))
|
||||
} else {
|
||||
// 有机器 → 切换为无机器(删除)
|
||||
this.deleteMachine(m.id)
|
||||
}
|
||||
},
|
||||
async createMachine(ri, ci) {
|
||||
try {
|
||||
var machineId = 'm_' + ri + '_' + ci
|
||||
var 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: [] }
|
||||
})
|
||||
})
|
||||
var 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('确定删除此机器?\n\n注意:删除后其上方/下方的点位仍可继续使用。')) return
|
||||
try {
|
||||
await fetch(API + '/api/mission/machines/' + machineId, { method: 'DELETE' })
|
||||
this.selectedMachine = null
|
||||
await this.loadAllMachines()
|
||||
await this.loadMissionConfig() // 重新加载点位,确保数据同步
|
||||
} catch (e) { alert('删除失败: ' + e.message) }
|
||||
},
|
||||
async saveMachineCoords() {
|
||||
if (!this.selectedMachine) return
|
||||
try {
|
||||
var 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(function() { this.mapMsg = '' }.bind(this), 2000)
|
||||
} else {
|
||||
alert('保存失败: ' + res.status)
|
||||
}
|
||||
} catch (e) { alert('保存失败: ' + e.message) }
|
||||
},
|
||||
async readPosition(side) {
|
||||
if (!this.agvConnected) { alert('AGV 未连接'); return }
|
||||
try {
|
||||
var res = await fetch(API + '/api/agv/position')
|
||||
var data = await res.json()
|
||||
if (data.ok && data.position && Array.isArray(data.position)) {
|
||||
var x = data.position[0]
|
||||
var y = data.position[1]
|
||||
var theta = data.position[2]
|
||||
if (side === 'front') {
|
||||
this.selectedMachine.front.coords = [x, y, theta]
|
||||
} else {
|
||||
this.selectedMachine.back.coords = [x, y, theta]
|
||||
}
|
||||
} else if (data.ok && (!data.position || !Array.isArray(data.position))) {
|
||||
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
|
||||
} else {
|
||||
alert('读取位置失败: ' + (data.error || '未知错误'))
|
||||
}
|
||||
} catch (e) { alert('读取位置失败: ' + e.message) }
|
||||
},
|
||||
async addPoseToMachine(machineId, side) {
|
||||
var name = this.poseForm.name || '姿态' + (((this.selectedMachine && this.selectedMachine[side] && this.selectedMachine[side].poses) || []).length + 1)
|
||||
try {
|
||||
var 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: ''
|
||||
})
|
||||
})
|
||||
var data = await res.json()
|
||||
if (data.ok) {
|
||||
this.poseForm.name = ''
|
||||
await this.loadAllMachines()
|
||||
var 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) {
|
||||
var 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 }
|
||||
var machine = this.getMachineAt(ri, ci)
|
||||
if (!machine) {
|
||||
try {
|
||||
var machineId = 'm_' + ri + '_' + ci
|
||||
var 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 {
|
||||
var res = await fetch(API + '/api/agv/position')
|
||||
var pos = await res.json()
|
||||
var 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 if (pos.ok && (!pos.position || !Array.isArray(pos.position))) {
|
||||
alert('⚠️ AGV 未发布位置数据,请检查 AGV 传感器是否正常')
|
||||
return
|
||||
} 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 {
|
||||
var res = await fetch(API + '/api/mission/generate_sequence')
|
||||
var data = await res.json()
|
||||
if (data.ok) {
|
||||
this.sequence = data.sequence || []
|
||||
}
|
||||
} catch (e) { console.error('刷新序列失败', e) }
|
||||
},
|
||||
// === 机械臂 ===
|
||||
async refreshAngles() {
|
||||
if (!this.armConnected) return
|
||||
try {
|
||||
var res = await fetch(API + '/api/arm/get_angles')
|
||||
var 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) {
|
||||
var 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(function() { this.refreshAngles() }.bind(this), 200)
|
||||
},
|
||||
jogStop(idx) {
|
||||
clearInterval(this.jogIntervals[idx])
|
||||
var joint = 'J' + (idx + 1)
|
||||
fetch(API + '/api/arm/jog', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ joint, direction: 0 })
|
||||
})
|
||||
setTimeout(function() { this.refreshAngles() }.bind(this), 300)
|
||||
},
|
||||
onPreviewError(e) {
|
||||
e.target.style.display = 'none'
|
||||
},
|
||||
// === AGV 控制 ===
|
||||
async refreshAgvPosition() {
|
||||
if (!this.agvConnected) return
|
||||
try {
|
||||
var res = await fetch(API + '/api/agv/position')
|
||||
var 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 {
|
||||
var res = await fetch(API + '/api/agv/reset', { method: 'POST' })
|
||||
var 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
const vm = app.mount('#app')
|
||||
window.vm = vm // 暴露组件实例
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,722 @@
|
||||
/* ========== 全局样式 ========== */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #0f1923;
|
||||
color: #e8eaed;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: #4fc3f7; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ========== 顶部栏 ========== */
|
||||
.topbar {
|
||||
background: #1a2332;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 32px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo { font-size: 18px; font-weight: bold; color: #4fc3f7; }
|
||||
|
||||
.nav { display: flex; gap: 4px; }
|
||||
.nav-link {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
color: #9aa0a6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-link:hover { background: #2a3441; color: #e8eaed; text-decoration: none; }
|
||||
.nav-link.active { background: #263238; color: #4fc3f7; }
|
||||
|
||||
.status-bar { margin-left: auto; display: flex; gap: 12px; }
|
||||
.status-item {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status-item.setting { background: #1b3a2f; color: #4caf50; }
|
||||
.status-item.running { background: #2a2a1b; color: #ffeb3b; }
|
||||
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
||||
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
||||
|
||||
/* ========== 卡片 ========== */
|
||||
.card {
|
||||
background: #1a2332;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.card h2 { font-size: 16px; margin-bottom: 16px; color: #4fc3f7; }
|
||||
|
||||
/* ========== 状态卡片 ========== */
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||
.status-card {
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.status-card.ok { border-color: #2e7d32; background: #0d1f14; }
|
||||
.status-card.error { border-color: #c62828; background: #1f0d0d; }
|
||||
.status-icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.status-label { font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
|
||||
.status-value { font-size: 14px; font-weight: bold; }
|
||||
|
||||
/* ========== 按钮 ========== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #37474f; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: #0277bd; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #0288d1; }
|
||||
.btn-secondary { background: #37474f; }
|
||||
.btn-danger { background: #d32f2f; color: #fff; }
|
||||
.btn-danger:hover:not(:disabled) { background: #f44336; }
|
||||
.btn-success { background: #2e7d32; color: #fff; }
|
||||
.btn-success:hover:not(:disabled) { background: #388e3c; }
|
||||
.btn-warning { background: #e65100; color: #fff; }
|
||||
.btn-error { background: #c62828; color: #fff; }
|
||||
.btn-large { padding: 12px 24px; font-size: 16px; }
|
||||
.btn-small { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-icon { background: none; border: none; cursor: pointer; font-size: 14px; padding: 4px; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
|
||||
/* ========== 表单 ========== */
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { display: block; font-size: 12px; color: #9aa0a6; margin-bottom: 4px; }
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 6px;
|
||||
color: #e8eaed;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus { outline: none; border-color: #4fc3f7; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
|
||||
/* ========== Tabs ========== */
|
||||
.tabs {
|
||||
background: #1a2332;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9aa0a6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab.active { color: #4fc3f7; border-bottom-color: #4fc3f7; }
|
||||
.tab:hover { color: #e8eaed; }
|
||||
|
||||
/* ========== 摄像头预览 ========== */
|
||||
.camera-preview {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 16px;
|
||||
background: #000;
|
||||
}
|
||||
.camera-preview img,
|
||||
.camera-full img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-full {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* ========== 关节控制 ========== */
|
||||
.joints-panel { margin-top: 16px; }
|
||||
.joints-panel h3 { margin-bottom: 12px; font-size: 14px; }
|
||||
.joint-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.joint-control {
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #2a3441;
|
||||
}
|
||||
.joint-control label { font-size: 12px; color: #4fc3f7; font-weight: bold; }
|
||||
.joint-value { font-size: 18px; font-weight: bold; color: #fff; margin: 4px 0; }
|
||||
.joint-buttons { display: flex; align-items: center; gap: 4px; justify-content: center; }
|
||||
.joint-buttons button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2a3441;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.joint-buttons input {
|
||||
width: 60px;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 4px;
|
||||
color: #e8eaed;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== 点位列表 ========== */
|
||||
.point-item {
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.point-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.point-name { font-weight: bold; font-size: 15px; }
|
||||
.point-coords { font-size: 12px; color: #9aa0a6; margin-bottom: 8px; }
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
background: #263238;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
.pose-list { margin-top: 8px; }
|
||||
.pose-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.angles { color: #9aa0a6; font-size: 11px; font-family: monospace; }
|
||||
.pose-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-add input { flex: 1; padding: 6px 10px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; font-size: 13px; }
|
||||
.pose-add select { padding: 6px; background: #1a2332; border: 1px solid #2a3441; border-radius: 4px; color: #e8eaed; }
|
||||
|
||||
.empty-hint { color: #9aa0a6; text-align: center; padding: 20px; }
|
||||
.hint { font-size: 12px; color: #9aa0a6; margin-top: 8px; }
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; }
|
||||
.alert-error { background: #1f0d0d; border: 1px solid #c62828; color: #ef5350; }
|
||||
.checkbox-group { display: flex; gap: 16px; }
|
||||
.checkbox-group label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: #e8eaed; }
|
||||
|
||||
/* ========== 运行页面 ========== */
|
||||
.running-header { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
|
||||
.running-status {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.running-status.idle { color: #9aa0a6; }
|
||||
.running-status.running { color: #4caf50; }
|
||||
.running-status.paused { color: #ff9800; }
|
||||
.pulse {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
.running-progress { flex: 1; display: flex; align-items: center; gap: 12px; }
|
||||
.progress-bar { flex: 1; height: 8px; background: #2a3441; border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #4fc3f7; border-radius: 4px; transition: width 0.3s; }
|
||||
|
||||
/* ========== 报告 ========== */
|
||||
.report-summary { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.stat { padding: 8px 16px; border-radius: 8px; background: #0f1923; border: 1px solid #2a3441; }
|
||||
.stat.ok { border-color: #2e7d32; color: #4caf50; }
|
||||
.stat.error { border-color: #c62828; color: #ef5350; }
|
||||
.report-item { padding: 8px 12px; background: #0f1923; border-radius: 6px; margin-bottom: 8px; border: 1px solid #2a3441; }
|
||||
.report-point { display: flex; align-items: center; gap: 8px; font-weight: bold; }
|
||||
.report-status { font-size: 16px; }
|
||||
.report-pose { font-size: 12px; color: #9aa0a6; padding-left: 24px; margin-top: 4px; }
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.container { grid-template-columns: 1fr; }
|
||||
.grid-3 { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
.joint-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* AGV 移动控制面板 */
|
||||
.agv-status-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: #0f1923;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #9aa0a6;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.agv-status-bar strong { color: #e8eaed; }
|
||||
|
||||
.agv-control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.agv-dir-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 80px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.agv-dir-placeholder { width: 80px; height: 44px; }
|
||||
.agv-btn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3441;
|
||||
background: #263238;
|
||||
color: #e8eaed;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.agv-btn:active, .agv-btn:focus { outline: none; }
|
||||
.agv-btn-up { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.agv-btn-down { background: #3a1b1b; border-color: #7d2e2e; color: #f44336; }
|
||||
.agv-btn-left { background: #1b2d3a; border-color: #1565c0; color: #42a5f5; }
|
||||
.agv-btn-right { background: #2d2a1b; border-color: #7d6e2e; color: #ffc107; }
|
||||
.agv-btn-stop { background: #37474f; border-color: #546e7a; }
|
||||
.agv-btn-up:active { background: #1e4d38; }
|
||||
.agv-btn-down:active { background: #4d2020; }
|
||||
.agv-btn-left:active { background: #1e3a4d; }
|
||||
.agv-btn-right:active { background: #3d3820; }
|
||||
.agv-btn-stop:active { background: #455a64; }
|
||||
.agv-btn-lateral {
|
||||
background: #2d1b4a;
|
||||
border-color: #7c4dff;
|
||||
color: #b388ff;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.agv-btn-lateral:active { background: #3d2560; }
|
||||
.agv-lateral-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.speed-value {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
/* 双摄像头预览布局 */
|
||||
.camera-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.camera-box {
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.camera-label {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.camera-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
/* ========== 地图标记 ========== */
|
||||
.map-marker {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -100%);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||
z-index: 10;
|
||||
}
|
||||
.map-marker:hover {
|
||||
transform: translate(-50%, -100%) scale(1.2);
|
||||
}
|
||||
|
||||
/* ========== 任务配置 M×N 网格 ========== */
|
||||
.mission-grid-wrap {
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mission-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 80px repeat(var(--cols,4), 90px);
|
||||
}
|
||||
.grid-cell {
|
||||
min-width: 80px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #2a3441;
|
||||
background: #0f1923;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.grid-cell.active {
|
||||
background: #1b3a2f;
|
||||
border-color: #2e7d32;
|
||||
color: #4caf50;
|
||||
}
|
||||
.grid-cell.active:hover {
|
||||
background: #234;
|
||||
}
|
||||
.grid-cell.selected {
|
||||
border-color: #4fc3f7 !important;
|
||||
box-shadow: 0 0 0 2px #4fc3f7;
|
||||
}
|
||||
.grid-header {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
color: #9aa0a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 机器配置表单 */
|
||||
.machine-form {
|
||||
background: #0f1923;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.machine-form h3 {
|
||||
font-size: 14px;
|
||||
color: #4fc3f7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.machine-form h4 {
|
||||
font-size: 13px;
|
||||
color: #9aa0a6;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
/* 姿态列表 */
|
||||
.pose-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pose-name {
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
.pose-angles {
|
||||
color: #9aa0a6;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
.pose-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.pose-add input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #1a2332;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 4px;
|
||||
color: #e8eaed;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 蛇形序列预览 */
|
||||
.sequence-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sequence-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: #0f1923;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a3441;
|
||||
font-size: 13px;
|
||||
}
|
||||
.step-index {
|
||||
background: #263238;
|
||||
color: #4fc3f7;
|
||||
border-radius: 10px;
|
||||
min-width: 28px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-info {
|
||||
flex: 1;
|
||||
}
|
||||
.step-side {
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.step-side:contains('正面') {
|
||||
background: #1b3a2f;
|
||||
color: #4caf50;
|
||||
}
|
||||
.step-side:contains('背面') {
|
||||
background: #3a1b2f;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
/* 网格单元格点位配置 */
|
||||
.cell-machine {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.cell-points {
|
||||
margin-top: 2px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 2px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin: 1px 0;
|
||||
}
|
||||
.point-row:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.point-label {
|
||||
color: #666;
|
||||
min-width: 24px;
|
||||
}
|
||||
.point-coords {
|
||||
color: #0366d6;
|
||||
font-family: monospace;
|
||||
font-size: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.btn-icon-small {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.btn-icon-small:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-box {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2a3a50;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
min-width: 380px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
|
||||
|
||||
/* 点位行单元格 */
|
||||
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
|
||||
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
|
||||
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
|
||||
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
|
||||
.point-empty { font-size: 10px; color: #455a64; }
|
||||
|
||||
/* 机器行单元格 */
|
||||
.machine-cell { cursor: pointer; }
|
||||
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
|
||||
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.machine-icon { font-size: 18px; }
|
||||
.machine-empty { font-size: 16px; color: #455a64; }
|
||||
/* ========== 任务配置 弹窗 + 网格增强样式 ========== */
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-box {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2a3a50;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
min-width: 380px;
|
||||
max-width: 500px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-box h3 { margin: 0 0 8px; color: #e0e6f0; font-size: 16px; }
|
||||
|
||||
/* 点位行单元格 */
|
||||
.point-cell { cursor: pointer; flex-direction: column; gap: 2px; }
|
||||
.point-cell:hover { border-color: #4fc3f7; background: #162030; }
|
||||
.point-cell.point-filled { background: #0d2535; border-color: #1565c0; }
|
||||
.point-coords { font-size: 10px; color: #64b5f6; font-family: monospace; }
|
||||
.point-empty { font-size: 10px; color: #455a64; }
|
||||
|
||||
/* 机器行单元格 */
|
||||
.machine-cell { cursor: pointer; }
|
||||
.machine-cell:hover { border-color: #4caf50; background: #1b3a2f; }
|
||||
.machine-cell.active { background: #1b3a2f; border-color: #2e7d32; color: #4caf50; }
|
||||
.machine-icon { font-size: 18px; }
|
||||
.machine-empty { font-size: 16px; color: #455a64; }
|
||||
/* 点位编辑弹窗 */
|
||||
.modal-overlay .modal-box { min-width: 420px; }
|
||||
.modal-overlay .form-row { gap: 8px; }
|
||||
.modal-overlay .btn-row { gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 地图坐标点覆盖层 */
|
||||
.map-container { position: relative; }
|
||||
.map-overlay {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.point-dot {
|
||||
width: 10px; height: 10px;
|
||||
background: #f39c12;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 6px rgba(243,156,18,0.9);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<!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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="topbar">
|
||||
<div class="logo">🤖 AGV 拍摄系统</div>
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-link active">🏠 首页</a>
|
||||
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
||||
<a href="/running" class="nav-link">▶️ 运行</a>
|
||||
</nav>
|
||||
<div class="status-bar">
|
||||
<span class="status-item" :class="statusClass">
|
||||
[[ statusText ]]
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="container">
|
||||
<!-- 系统状态卡片 -->
|
||||
<section class="card">
|
||||
<h2>📡 系统连接状态</h2>
|
||||
<div class="grid-3">
|
||||
<div class="status-card" :class="agvConnected ? 'ok' : 'error'"
|
||||
@click="connectDevice('agv')" style="cursor:pointer">
|
||||
<div class="status-icon">
|
||||
<span v-if="reconnectingDevice==='agv'">⏳</span>
|
||||
<span v-else>[[ agvConnected ? '✅' : '❌' ]]</span>
|
||||
</div>
|
||||
<div class="status-label">AGV</div>
|
||||
<div class="status-value">
|
||||
<span v-if="reconnectingDevice==='agv'">重连中...</span>
|
||||
<span v-else>[[ agvConnected ? '已连接' : '未连接' ]](点击重连)</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>
|
||||
</div>
|
||||
<div class="status-label">机械臂</div>
|
||||
<div class="status-value">
|
||||
<span v-if="reconnectingDevice==='arm'">重连中...</span>
|
||||
<span v-else>[[ armConnected ? '已连接' : '未连接' ]](点击重连)</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>
|
||||
</div>
|
||||
<div class="status-label">AGV摄像头</div>
|
||||
<div class="status-value">
|
||||
<span v-if="reconnectingDevice==='camera'">重连中...</span>
|
||||
<span v-else>[[ cameraOpened ? '已打开' : '未打开' ]](点击重连)</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>
|
||||
</div>
|
||||
<div class="status-label">机械臂摄像头</div>
|
||||
<div class="status-value">
|
||||
<span v-if="reconnectingDevice==='arm_camera'">重连中...</span>
|
||||
<span v-else>[[ armCameraOpened ? '已打开' : '未打开' ]](点击重连)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" @click="connectAll" :disabled="connecting">
|
||||
[[ connecting ? '连接中...' : '🔗 连接全部设备' ]]
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="disconnectAll" :disabled="!agvConnected && !armConnected && !cameraOpened">
|
||||
断开连接
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 双摄像头预览 -->
|
||||
<section class="card">
|
||||
<h2>📷 摄像头预览</h2>
|
||||
<div class="camera-row">
|
||||
<div class="camera-box">
|
||||
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div>
|
||||
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
|
||||
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
||||
</div>
|
||||
<div class="camera-box">
|
||||
<div class="camera-label">机械臂摄像头 <button class="btn btn-small" @click="armCameraSrc='/api/camera/arm_refresh?t='+Date.now(); armCameraError=false">刷新</button></div>
|
||||
<img v-if="armConnected && !armCameraError" :src="armCameraSrc" class="camera-img" @error="armCameraError=true">
|
||||
<div v-if="armConnected && armCameraError" class="camera-placeholder">机械臂摄像头异常</div>
|
||||
<div v-else-if="!armConnected" class="camera-placeholder">未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 地图信息 -->
|
||||
<section class="card">
|
||||
<h2>🗺️ 地图信息</h2>
|
||||
<div v-if="mapLoaded">
|
||||
<p>地图目录: <code>[[ mapConfig.map_dir ]]</code></p>
|
||||
<p>地图文件: <code>[[ mapConfig.map_file ]]</code></p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="hint">尚未加载地图,请前往 <a href="/setting">设置页面</a> 配置地图</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 点位概览 -->
|
||||
<section class="card">
|
||||
<h2>📍 点位概览</h2>
|
||||
<p>已配置 <strong>[[ pointsCount ]]</strong> 个拍摄点位</p>
|
||||
<div class="btn-row">
|
||||
<a href="/setting" class="btn btn-primary">前往设置点位 →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<section class="card">
|
||||
<h2>🚀 快捷入口</h2>
|
||||
<div class="btn-row">
|
||||
<a href="/setting" class="btn btn-large btn-primary">进入设置模式 ⚙️</a>
|
||||
<a href="/running" class="btn btn-large btn-success" :class="{disabled: !allReady}" @click.prevent="checkReady">
|
||||
开始运行 ▶️
|
||||
</a>
|
||||
</div>
|
||||
<p v-if="!allReady" class="hint">请先连接所有设备并加载地图</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/vue3.global.prod.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!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">
|
||||
</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'">
|
||||
<span>点位 [[ currentPoint + 1 ]] / [[ totalPoints ]]</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{width: progressPercent + '%'}"></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-error btn-large" @click="stopMission" :disabled="missionState === 'idle'">
|
||||
⏹️ 停止
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 实时预览 -->
|
||||
<section class="card">
|
||||
<h2>📷 摄像头预览</h2>
|
||||
<div class="camera-full">
|
||||
<img :src="previewUrl" @error="onPreviewError">
|
||||
</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>
|
||||
<div class="report-details">
|
||||
<div v-for="(detail, i) in report.details" :key="i" class="report-item">
|
||||
<div class="report-point">
|
||||
<span class="report-status" :class="detail.status">[[ detail.status === 'completed' ? '✅' : '❌' ]]</span>
|
||||
[[ detail.point_name ]]
|
||||
</div>
|
||||
<div v-for="(pose, pi) in detail.poses" :key="pi" class="report-pose">
|
||||
[[ pose.photo_type ]] - [[ pose.status ]]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/vue3.global.prod.js"></script>
|
||||
<script src="/static/js/running.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,507 @@
|
||||
<!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=20260514e">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="topbar">
|
||||
<div class="logo">⚙️ 系统设置</div>
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-link">🏠 首页</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 === \'model\'}" @click="tab = \'model\'">📦 机型配置</button>
|
||||
<button class="tab" :class="{active: tab === 'mission'}" @click="tab = 'mission'">🎯 任务配置</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">
|
||||
<h2>地图可视化</h2>
|
||||
<div class="map-container" style="position:relative;background:#111;border-radius:8px;overflow:hidden">
|
||||
<img :src="mapImageUrl" @error="onMapError" style="width:100%;display:block">
|
||||
<!-- 地图覆盖层:显示点位坐标 -->
|
||||
<div class="map-overlay">
|
||||
<!-- 点位坐标点 -->
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ========== 机型配置 Tab ========== -->
|
||||
<div v-if="tab === 'model'">
|
||||
<section class="card">
|
||||
<h2>📦 机型配置</h2>
|
||||
|
||||
<!-- 添加新机型 -->
|
||||
<div class="form-section" style="background:#f5f5f5;padding:16px;border-radius:8px;margin-bottom:20px">
|
||||
<h3 style="margin-top:0">添加新机型</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>机型名称</label>
|
||||
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>描述</label>
|
||||
<input type="text" v-model="newModelDesc" placeholder="描述信息" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>备注</label>
|
||||
<input type="text" v-model="newModelNotes" placeholder="备注信息" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="addModel" style="margin-top:8px">➕ 添加机型</button>
|
||||
</div>
|
||||
|
||||
<!-- 机型列表 -->
|
||||
<div v-if="models.length === 0" style="text-align:center;color:#888;padding:40px">
|
||||
<p>暂无机型配置,请添加新机型</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-for="m in models" :key="m.id" style="border:1px solid #ddd;border-radius:8px;margin-bottom:20px;overflow:hidden">
|
||||
<!-- 机型头部 -->
|
||||
<div style="background:#e8f4e8;padding:12px 16px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<strong style="font-size:16px">{{ m.name }}</strong>
|
||||
<span style="margin-left:12px;color:#666;font-size:13px">ID: {{ m.id }}</span>
|
||||
<span v-if="m.description" style="margin-left:12px;color:#888;font-size:13px">{{ m.description }}</span>
|
||||
<span v-if="m.notes" style="margin-left:12px;color:#aaa;font-size:13px">【{{ m.notes }}】</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-danger btn-small" @click="deleteModel(m.id)">🗑️ 删除机型</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 姿态配置 -->
|
||||
<div style="padding:16px">
|
||||
<!-- 正面姿态 -->
|
||||
<div style="margin-bottom:20px">
|
||||
<h4 style="margin:0 0 12px 0;color:#1976d2">🔵 正面姿态</h4>
|
||||
<div v-for="pose in m.poses.filter(p => p.photo_type === 'front')" :key="pose.id" style="background:#f8f8f8;padding:12px;border-radius:6px;margin-bottom:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>{{ pose.name or '正面姿态' }}</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:#666">J{{j}}</span>
|
||||
<input type="number" step="0.1"
|
||||
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
|
||||
@change="updatePoseAngle(m.id, pose.id, j-1, )"
|
||||
style="width:70px;padding:4px;border:1px solid #ddd;border-radius:4px">
|
||||
<span style="font-size:11px;color:#999">°</span>
|
||||
</div>
|
||||
</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 #ddd;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:#888">
|
||||
当前机械臂角度:
|
||||
<span v-if="currentAngles.length">
|
||||
J1={{ currentAngles[0]?.toFixed(1) }}° J2={{ currentAngles[1]?.toFixed(1) }}° J3={{ currentAngles[2]?.toFixed(1) }}° J4={{ currentAngles[3]?.toFixed(1) }}° J5={{ currentAngles[4]?.toFixed(1) }}° J6={{ currentAngles[5]?.toFixed(1) }}°
|
||||
</span>
|
||||
<span v-else>(未连接机械臂)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背面姿态 -->
|
||||
<div>
|
||||
<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:#fff0f0;padding:12px;border-radius:6px;margin-bottom:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>{{ pose.name or '背面姿态' }}</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:#666">J{{j}}</span>
|
||||
<input type="number" step="0.1"
|
||||
:value="pose.arm_angles && pose.arm_angles[j-1] !== undefined ? pose.arm_angles[j-1] : 0"
|
||||
@change="updatePoseAngle(m.id, pose.id, j-1, )"
|
||||
style="width:70px;padding:4px;border:1px solid #ddd;border-radius:4px">
|
||||
<span style="font-size:11px;color:#999">°</span>
|
||||
</div>
|
||||
</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 #ddd;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>
|
||||
</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">
|
||||
<button class="btn btn-primary" @click="generateGrid">🔲 生成网格</button>
|
||||
<button class="btn btn-secondary" @click="saveMissionConfig" style="margin-left:6px">💾 保存网格</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: getMachineAt(ri-1, ci-1) }"
|
||||
@click="onCellClick(ri-1, ci-1)">
|
||||
<template v-if="getMachineAt(ri-1, ci-1)">
|
||||
<div class="cell-machine">✅</div>
|
||||
</template>
|
||||
<span v-else class="empty-cell">⬜</span>
|
||||
</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: getMachineAt(missionConfig.rows-1, ci-1) }"
|
||||
@click="onCellClick(missionConfig.rows-1, ci-1)">
|
||||
<template v-if="getMachineAt(missionConfig.rows-1, ci-1)">
|
||||
<div class="cell-machine">✅</div>
|
||||
</template>
|
||||
<span v-else class="empty-cell">⬜</span>
|
||||
</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" v-if="selectedMachine && selectedMachine.front && selectedMachine.back" style="margin-top:16px">
|
||||
<h2>② 点位配置 — 第{% raw %}{{ selectedMachine.row+1 }}{% endraw %}行 第{% raw %}{{ selectedMachine.col+1 }}{% endraw %}列 <button class="btn btn-small" @click="clearSelection()">← 返回</button></h2>
|
||||
|
||||
<!-- 正面点位 -->
|
||||
<div class="machine-form">
|
||||
<h3>📷 正面点位</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>X 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[0]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Y 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[1]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Yaw (弧度)</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.front.coords[2]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group" style="align-self:end">
|
||||
<button class="btn btn-small btn-primary" @click="readPosition('front')" :disabled="!agvConnected">📍 读取当前位置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 正面姿态列表 -->
|
||||
<div v-if="selectedMachine.front.poses && selectedMachine.front.poses.length > 0" class="pose-list">
|
||||
<h4>正面姿态 ({% raw %}{{ selectedMachine.front.poses.length }}{% endraw %} 个)</h4>
|
||||
<div v-for="pose in selectedMachine.front.poses" :key="pose.id" class="pose-item">
|
||||
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
|
||||
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
|
||||
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'front', pose.id)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pose-add">
|
||||
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:正面全景)">
|
||||
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'front')">➕ 添加姿态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背面点位 -->
|
||||
<div class="machine-form" style="margin-top:16px">
|
||||
<h3>📷 背面点位</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>X 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[0]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Y 坐标</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[1]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Yaw (弧度)</label>
|
||||
<input type="number" step="0.01" v-model.number="selectedMachine.back.coords[2]" placeholder="0.00">
|
||||
</div>
|
||||
<div class="form-group" style="align-self:end">
|
||||
<button class="btn btn-small btn-primary" @click="readPosition('back')" :disabled="!agvConnected">📍 读取当前位置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 背面姿态列表 -->
|
||||
<div v-if="selectedMachine.back.poses && selectedMachine.back.poses.length > 0" class="pose-list">
|
||||
<h4>背面姿态 ({% raw %}{{ selectedMachine.back.poses.length }}{% endraw %} 个)</h4>
|
||||
<div v-for="pose in selectedMachine.back.poses" :key="pose.id" class="pose-item">
|
||||
<span class="pose-name">{% raw %}{{ pose.name }}{% endraw %}</span>
|
||||
<span class="pose-angles" v-if="pose.arm_angles">角度: {% raw %}{{ formatAngles(pose.arm_angles) }}{% endraw %}</span>
|
||||
<button class="btn-icon" @click="deletePose(selectedMachine.id, 'back', pose.id)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pose-add">
|
||||
<input type="text" v-model="poseForm.name" placeholder="姿态名称(如:背面细节)">
|
||||
<button class="btn btn-small btn-success" @click="addPoseToMachine(selectedMachine.id, 'back')">➕ 添加姿态</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top:16px">
|
||||
<button class="btn btn-danger" @click="deleteMachine(selectedMachine.id)">🗑️ 删除此机器</button>
|
||||
<button class="btn btn-secondary" @click="saveMachineCoords">💾 保存此机器配置</button>
|
||||
</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:#888">
|
||||
💡 此点位服务于: {% 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-warning" @click="clearPoint" :disabled="canClearPoint(editingPoint.pointRow, editingPoint.col)">🗑️ 清空</button>
|
||||
<button class="btn btn-secondary" @click="closePointEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</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="previewUrl" @error="onPreviewError">
|
||||
</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>
|
||||
<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] ? agvPosition[0].toFixed(2) : '?' }}{% endraw %} Y={% raw %}{{ agvPosition[1] ? agvPosition[1].toFixed(2) : '?' }}{% endraw %}</strong></span>
|
||||
<button class="btn btn-small" @click="refreshAgvPosition">🔄 刷新</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=20260513b"></script>
|
||||
<script src="/static/js/setting.js?v=20260514k"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,438 @@
|
||||
const { createApp } = Vue
|
||||
const API = ''
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
tab: 'map',
|
||||
// 任务配置
|
||||
missionConfig: { rows: 3, cols: 3, grid: [], machines: [] },
|
||||
selectedMachine: null,
|
||||
// 地图
|
||||
mapForm: { map_dir: '/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/', map_file: 'map.yaml' },
|
||||
mapMsg: '',
|
||||
mapLoaded: false,
|
||||
mapImageUrl: '',
|
||||
mapMeta: null,
|
||||
// 点位
|
||||
points: [],
|
||||
newPointName: '',
|
||||
newPointMode: 'front',
|
||||
newPointSequence: ['front', 'back'],
|
||||
// 机型(姿态组)
|
||||
models: [],
|
||||
selectedModelId: null,
|
||||
newModelName: '',
|
||||
newModelSerial: '',
|
||||
poseForm: {},
|
||||
// 机械臂
|
||||
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()
|
||||
},
|
||||
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)
|
||||
},
|
||||
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()
|
||||
},
|
||||
// === 地图 ===
|
||||
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 = '❌ 地图图像加载失败'
|
||||
},
|
||||
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 || []
|
||||
// 初始化 poseForm
|
||||
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)
|
||||
},
|
||||
// === 机械臂 ===
|
||||
|
||||
clearSelection() { this.selectedMachine = null },
|
||||
async saveMachineCoords() {
|
||||
if (!this.selectedMachine) return
|
||||
try {
|
||||
const res = await fetch(API + '/api/mission/machines/' + this.selectedMachine.id, {
|
||||
method: 'PATCH',
|
||||
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) }
|
||||
},
|
||||
selectMachine(machine) {
|
||||
// 确保 front/back 永远有 coords 数组,避免 v-model 赋值失败
|
||||
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
|
||||
console.log('selectedMachine:', machine)
|
||||
},
|
||||
onCellClick(ri, ci) {
|
||||
let m = this.getMachineAt(ri, ci)
|
||||
if (!m) {
|
||||
// 自动创建机器记录
|
||||
this.createMachine(ri, ci).then(() => {
|
||||
m = this.getMachineAt(ri, ci)
|
||||
if (m) this.selectMachine(m)
|
||||
})
|
||||
} else {
|
||||
this.selectMachine(m)
|
||||
}
|
||||
},
|
||||
async createMachine(ri, ci) {
|
||||
try {
|
||||
const res = await fetch(API + '/api/mission/machines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row: ri, col: ci, front: { coords: [0, 0, 0], poses: [] }, back: { coords: [0, 0, 0], poses: [] } })
|
||||
})
|
||||
await this.loadAllMachines()
|
||||
return res.ok
|
||||
} catch (e) { alert('创建机器失败: ' + e.message); return false }
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
async capturePosition(ri, ci, side) {
|
||||
if (!this.agvConnected) { alert('请先连接AGV'); return }
|
||||
let machine = this.getMachineAt(ri, ci)
|
||||
if (!machine) {
|
||||
try {
|
||||
const res = await fetch(API + '/api/mission/machines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ 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.position && Array.isArray(pos.position)) { x = pos.position[0]||0; y = pos.position[1]||0; theta = pos.position[2]||0 }
|
||||
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) }
|
||||
},
|
||||
}
|
||||
}).mount('#app')
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
AGV 导航控制模块 - 通过 pymycobot 控制 AGV 运动
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入 pymycobot
|
||||
try:
|
||||
from pymycobot import MyAGVPro
|
||||
MYCOBOT_AVAILABLE = True
|
||||
except ImportError:
|
||||
MYCOBOT_AVAILABLE = False
|
||||
logger.warning("pymycobot 未安装,AGV 控制功能不可用")
|
||||
|
||||
|
||||
class AGVController:
|
||||
"""AGV 运动控制"""
|
||||
|
||||
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
|
||||
self.device = device
|
||||
self.baudrate = baudrate
|
||||
self._agv: Optional[MyAGVPro] = None
|
||||
self._connected = False
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""连接 AGV"""
|
||||
if not MYCOBOT_AVAILABLE:
|
||||
logger.error("pymycobot 不可用")
|
||||
return False
|
||||
try:
|
||||
self._agv = MyAGVPro(self.device, self.baudrate, debug=False)
|
||||
# 检查是否上电
|
||||
if self._agv.is_power_on():
|
||||
self._connected = True
|
||||
logger.info("AGV 连接成功")
|
||||
return True
|
||||
else:
|
||||
logger.warning("AGV 未上电,尝试上电...")
|
||||
self._agv.power_on()
|
||||
time.sleep(2)
|
||||
if self._agv.is_power_on():
|
||||
self._connected = True
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"AGV 连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._agv is not None
|
||||
|
||||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
||||
"""前进"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.move_forward(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
||||
"""后退"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.move_backward(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
||||
"""左转"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.turn_left(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
||||
"""右转"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.turn_right(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
||||
"""向左横向移动"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.move_left_lateral(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
||||
"""向右横向移动"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._agv.move_right_lateral(speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
if self.is_connected():
|
||||
self._agv.stop()
|
||||
|
||||
def get_position(self) -> Optional[List[float]]:
|
||||
"""获取 AGV 当前位置 [x, y, rz]"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
try:
|
||||
# 启用自动报告以获取位置
|
||||
self._agv.set_auto_report_state(1)
|
||||
time.sleep(0.5)
|
||||
msg = self._agv.get_auto_report_message()
|
||||
if msg and len(msg) >= 3:
|
||||
return [msg[0], msg[1], msg[2]]
|
||||
except Exception as e:
|
||||
logger.error(f"获取 AGV 位置失败: {e}")
|
||||
return None
|
||||
|
||||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
||||
"""移动到目标点(简单的方向控制实现)"""
|
||||
# 注意:AGV Pro 的 pymycobot 没有直接 goto API
|
||||
# 需要 ROS2 SLAM 导航支持,此处提供基础运动接口
|
||||
# 实际导航需要结合地图和路径规划
|
||||
logger.warning("go_to_point 需要 ROS2 导航支持,当前仅记录目标")
|
||||
return True
|
||||
|
||||
def get_battery(self) -> Optional[float]:
|
||||
"""获取电池电压"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
try:
|
||||
self._agv.set_auto_report_state(1)
|
||||
msg = self._agv.get_auto_report_message()
|
||||
if msg and len(msg) > 5:
|
||||
return msg[5] # 电池电压
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def disconnect(self):
|
||||
if self._agv:
|
||||
self.stop()
|
||||
self._agv = None
|
||||
self._connected = False
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.disconnect()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
AGV 导航控制模块 - 通过 ROS2 控制 AGV 运动
|
||||
使用 ros2 CLI 命令进行通信,避免 rclpy 导入问题
|
||||
"""
|
||||
import time
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ROS2 环境设置
|
||||
ROS2_SETUP_CMD = "export ROS_DOMAIN_ID=1 && source ~/agv_pro_ros2/install/setup.bash"
|
||||
|
||||
|
||||
class AGVController:
|
||||
"""AGV 运动控制 - ROS2 版本"""
|
||||
|
||||
def __init__(self, device: str = "/dev/agvpro_controller", baudrate: int = 1000000):
|
||||
self.device = device
|
||||
self.baudrate = baudrate
|
||||
self._connected = False
|
||||
self._position = [0.0, 0.0, 0.0] # [x, y, yaw]
|
||||
self._voltage = 0.0
|
||||
self._ros2_available = False
|
||||
|
||||
def _run_ros2_cmd(self, cmd: str, timeout: float = 5.0) -> tuple:
|
||||
"""执行 ros2 命令"""
|
||||
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)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""连接 AGV - 检查 ROS2 节点和 topic"""
|
||||
try:
|
||||
# 检查 agv_pro_node 是否运行
|
||||
rc, out, err = self._run_ros2_cmd("ros2 node list")
|
||||
if rc != 0:
|
||||
logger.error(f"ROS2 节点列表获取失败: {err}")
|
||||
return False
|
||||
|
||||
if "/agv_pro_node" not in out:
|
||||
logger.error("agv_pro_node 未运行")
|
||||
return False
|
||||
|
||||
# 检查 /odom topic
|
||||
rc, out, err = self._run_ros2_cmd("ros2 topic list")
|
||||
if "/odom" not in out:
|
||||
logger.error("/odom topic 不存在")
|
||||
return False
|
||||
|
||||
# 尝试获取一次位置数据
|
||||
rc, out, err = self._run_ros2_cmd(
|
||||
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
|
||||
timeout=6
|
||||
)
|
||||
|
||||
if rc == 0 and out:
|
||||
self._connected = True
|
||||
self._ros2_available = True
|
||||
logger.info("AGV ROS2 连接成功")
|
||||
return True
|
||||
else:
|
||||
# /odom 可能暂时没数据,但节点存在也算连接成功
|
||||
self._connected = True
|
||||
self._ros2_available = True
|
||||
logger.info("AGV ROS2 连接成功 (节点存在,等待 odom 数据)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AGV 连接失败: {e}")
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def _publish_cmd_vel(self, linear_x: float = 0.0, linear_y: float = 0.0, angular_z: float = 0.0):
|
||||
"""发布速度命令到 /cmd_vel"""
|
||||
# 直接执行,避免引号嵌套问题
|
||||
msg = f'{{"linear": {{"x": {linear_x}, "y": {linear_y}, "z": 0.0}}, "angular": {{"x": 0.0, "y": 0.0, "z": {angular_z}}}}}'
|
||||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \"{msg}\" --once'"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"发布 cmd_vel 失败: {result.stderr.strip()}")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("发布 cmd_vel 超时")
|
||||
except Exception as e:
|
||||
logger.warning(f"发布 cmd_vel 失败: {e}")
|
||||
|
||||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
||||
"""前进"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(linear_x=speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
||||
"""后退"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(linear_x=-speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
||||
"""左转"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(angular_z=speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
||||
"""右转"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(angular_z=-speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
||||
"""向左横向移动"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(linear_y=speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
||||
"""向右横向移动"""
|
||||
if not self.is_connected():
|
||||
return
|
||||
self._publish_cmd_vel(linear_y=-speed)
|
||||
if duration:
|
||||
time.sleep(duration)
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
"""停止"""
|
||||
if self.is_connected():
|
||||
self._publish_cmd_vel(0, 0, 0)
|
||||
|
||||
def get_position(self) -> Optional[List[float]]:
|
||||
"""获取 AGV 当前位置 [x, y, yaw]"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
try:
|
||||
# 从 /odom topic 获取位置
|
||||
rc, out, err = self._run_ros2_cmd(
|
||||
"timeout 5 ros2 topic echo /odom 2>timeout 10 ros2 topic echo /odom --once 2>/dev/null1 | head -1",
|
||||
timeout=6
|
||||
)
|
||||
if rc == 0 and out:
|
||||
# 解析 odom 消息 (YAML 格式)
|
||||
# ros2 topic echo 输出可能含多个 --- 分隔的文档,只取第一个
|
||||
import yaml
|
||||
yaml_str = out.split('---')[0]
|
||||
data = yaml.safe_load(yaml_str)
|
||||
if data:
|
||||
pos = data.get("pose", {}).get("pose", {}).get("position", {})
|
||||
x = pos.get("x", 0.0)
|
||||
y = pos.get("y", 0.0)
|
||||
# 从四元数计算 yaw
|
||||
orient = data.get("pose", {}).get("pose", {}).get("orientation", {})
|
||||
qz = orient.get("z", 0.0)
|
||||
qw = orient.get("w", 1.0)
|
||||
yaw = math.atan2(2.0 * qw * qz, 1.0 - 2.0 * qz * qz)
|
||||
self._position = [x, y, yaw]
|
||||
return self._position
|
||||
except Exception as e:
|
||||
logger.debug(f"获取位置失败: {e}")
|
||||
return None
|
||||
|
||||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
||||
"""移动到目标点(需要 ROS2 导航栈)"""
|
||||
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
||||
return True
|
||||
|
||||
def get_battery(self) -> Optional[float]:
|
||||
"""获取电池电压"""
|
||||
if not self.is_connected():
|
||||
return None
|
||||
try:
|
||||
# 从 /voltage topic 获取电压
|
||||
rc, out, err = self._run_ros2_cmd(
|
||||
"timeout 5 ros2 topic echo /voltage 2>timeout 10 ros2 topic echo /voltage --once 2>/dev/null1 | head -1",
|
||||
timeout=6
|
||||
)
|
||||
if rc == 0 and out:
|
||||
# 解析电压消息(ros2 topic echo 可能输出多文档 YAML)
|
||||
import yaml
|
||||
yaml_str = out.split('---')[0]
|
||||
data = yaml.safe_load(yaml_str)
|
||||
if data:
|
||||
self._voltage = data.get("data", 0.0)
|
||||
return self._voltage
|
||||
except Exception as e:
|
||||
logger.debug(f"获取电压失败: {e}")
|
||||
return None
|
||||
|
||||
def disconnect(self):
|
||||
self.stop()
|
||||
self._connected = False
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.disconnect()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
机械臂通信客户端 - 通过 TCP 连接机械臂端 TCP 服务器
|
||||
服务器再转发给 RoboFlow (630 Socket API)
|
||||
"""
|
||||
import socket
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArmClient:
|
||||
"""TCP 客户端,连接机械臂端的 arm_server"""
|
||||
|
||||
def __init__(self, host: str, port: int, timeout: float = 10):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._sock: Optional[socket.socket] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""建立 TCP 连接"""
|
||||
try:
|
||||
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._sock.settimeout(self.timeout)
|
||||
self._sock.connect((self.host, self.port))
|
||||
logger.info(f"已连接到机械臂 {self.host}:{self.port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"连接机械臂失败: {e}")
|
||||
return False
|
||||
|
||||
def send_command(self, cmd: str) -> Tuple[bool, str]:
|
||||
"""发送命令并接收响应"""
|
||||
if not self._sock:
|
||||
return False, "未连接"
|
||||
try:
|
||||
# 发送命令(自动加换行)
|
||||
self._sock.sendall((cmd + "\n").encode("utf-8"))
|
||||
# 接收响应
|
||||
resp = self._sock.recv(1024).decode("utf-8").strip()
|
||||
return True, resp
|
||||
except socket.timeout:
|
||||
return False, "命令超时"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def close(self):
|
||||
if self._sock:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
self.close()
|
||||
time.sleep(1)
|
||||
return self.connect()
|
||||
|
||||
# ========== 封装机械臂命令 ==========
|
||||
|
||||
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||
"""获取所有关节角度"""
|
||||
ok, resp = self.send_command("get_angles()")
|
||||
if ok and resp.startswith("get_angles:["):
|
||||
try:
|
||||
# get_angles:[0.174, 0.520, ...] → list
|
||||
nums = resp.split("[")[1].split("]")[0]
|
||||
angles = [float(x) for x in nums.split(",")]
|
||||
return True, angles
|
||||
except:
|
||||
return False, []
|
||||
return False, []
|
||||
|
||||
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
||||
"""设置所有关节角度"""
|
||||
if len(angles) != 6:
|
||||
return False
|
||||
cmd = f"set_angles({angles[0]:.2f},{angles[1]:.2f},{angles[2]:.2f},{angles[3]:.2f},{angles[4]:.2f},{angles[5]:.2f},{speed})"
|
||||
ok, resp = self.send_command(cmd)
|
||||
return ok and "ok" in resp
|
||||
|
||||
def set_angle(self, joint: str, angle: float, speed: int = 500) -> bool:
|
||||
"""设置单个关节角度"""
|
||||
cmd = f"set_angle({joint},{angle:.2f},{speed})"
|
||||
ok, resp = self.send_command(cmd)
|
||||
return ok and "ok" in resp
|
||||
|
||||
def jog_angle(self, joint: str, direction: int, speed: int = 500) -> bool:
|
||||
"""连续调节关节角度(direction: -1负方向/0停止/1正方向)"""
|
||||
cmd = f"jog_angle({joint},{direction},{speed})"
|
||||
ok, resp = self.send_command(cmd)
|
||||
return ok
|
||||
|
||||
def get_coords(self) -> Tuple[bool, List[float]]:
|
||||
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
||||
ok, resp = self.send_command("get_coords()")
|
||||
if ok and "get_coords:" in resp:
|
||||
try:
|
||||
nums = resp.split("[")[1].split("]")[0]
|
||||
coords = [float(x) for x in nums.split(",")]
|
||||
return True, coords
|
||||
except:
|
||||
return False, []
|
||||
return False, []
|
||||
|
||||
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||
"""设置坐标和姿态"""
|
||||
if len(coords) != 6:
|
||||
return False
|
||||
cmd = f"set_coords({coords[0]:.2f},{coords[1]:.2f},{coords[2]:.2f},{coords[3]:.2f},{coords[4]:.2f},{coords[5]:.2f},{speed})"
|
||||
ok, resp = self.send_command(cmd)
|
||||
return ok and "ok" in resp
|
||||
|
||||
def jog_coord(self, axis: str, direction: int, speed: int = 500) -> bool:
|
||||
"""连续调节坐标轴"""
|
||||
cmd = f"jog_coord({axis},{direction},{speed})"
|
||||
ok, resp = self.send_command(cmd)
|
||||
return ok
|
||||
|
||||
def power_on(self) -> bool:
|
||||
ok, _ = self.send_command("power_on()")
|
||||
return ok
|
||||
|
||||
def state_on(self) -> bool:
|
||||
ok, _ = self.send_command("state_on()")
|
||||
return ok
|
||||
|
||||
def state_off(self) -> bool:
|
||||
ok, _ = self.send_command("state_off()")
|
||||
return ok
|
||||
|
||||
def state_check(self) -> bool:
|
||||
"""检查机械臂状态是否正常"""
|
||||
ok, resp = self.send_command("state_check()")
|
||||
return ok and resp == "state_check:1"
|
||||
|
||||
def check_running(self) -> bool:
|
||||
"""检查机械臂是否在运行"""
|
||||
ok, resp = self.send_command("check_running()")
|
||||
return ok and resp == "check_running:1"
|
||||
|
||||
def wait_done(self, timeout: float = 30) -> bool:
|
||||
"""等待上一条命令执行完成"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
ok, resp = self.send_command("check_running()")
|
||||
if ok and resp == "check_running:0":
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
def task_stop(self) -> bool:
|
||||
ok, _ = self.send_command("task_stop()")
|
||||
return ok
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
配置文件 - 所有可配置参数集中管理
|
||||
"""
|
||||
import os
|
||||
|
||||
# 基础路径(部署后对应 ~/work/agv_app)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ========== AGV 参数 ==========
|
||||
AGV_CONFIG = {
|
||||
"device": "/dev/agvpro_controller",
|
||||
"baudrate": 10000000,
|
||||
"move_speed": 0.5,
|
||||
"turn_speed": 0.5,
|
||||
}
|
||||
|
||||
# ========== 机械臂 TCP 客户端 ==========
|
||||
ARM_CONFIG = {
|
||||
"host": "192.168.110.164",
|
||||
"port": 5002,
|
||||
"timeout": 8,
|
||||
"retry_times": 3,
|
||||
"retry_interval": 1,
|
||||
}
|
||||
|
||||
# ========== 地图 ==========
|
||||
MAP_CONFIG = {
|
||||
"map_dir": "/home/elephant/agv_pro_ros2/src/agv_pro_navigation2/map/",
|
||||
"map_file": "map.yaml",
|
||||
}
|
||||
|
||||
# ========== 摄像头 ==========
|
||||
CAMERA_CONFIG = {
|
||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||
"qr_detect_interval": 0.5,
|
||||
"capture_delay": 0.5,
|
||||
}
|
||||
|
||||
# ========== 机械臂摄像头流 ==========
|
||||
ARM_CAMERA_CONFIG = {
|
||||
"url": "http://192.168.110.164:5003/api/camera/preview",
|
||||
}
|
||||
|
||||
# ========== HTTP 上传 ==========
|
||||
UPLOAD_CONFIG = {
|
||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
}
|
||||
|
||||
# ========== Flask 服务器 ==========
|
||||
SERVER_CONFIG = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5000,
|
||||
"secret_key": "agv630_secret_key_2024",
|
||||
"debug": False,
|
||||
}
|
||||
|
||||
# ========== 任务配置存储路径 ==========
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
# ========== 关节角度范围限制 ==========
|
||||
JOINT_LIMITS = {
|
||||
"J1": (-180.0, 180.0),
|
||||
"J2": (-270.0, 90.0),
|
||||
"J3": (-150.0, 150.0),
|
||||
"J4": (-260.0, 80.0),
|
||||
"J5": (-168.0, 168.0),
|
||||
"J6": (-174.0, 174.0),
|
||||
}
|
||||
|
||||
# ========== 机械臂默认速度 ==========
|
||||
DEFAULT_ARM_SPEED = 500
|
||||
|
||||
# ========== 状态定义 ==========
|
||||
class State:
|
||||
SETTING = "setting"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
IDLE = "idle"
|
||||
|
||||
class PhotoType:
|
||||
FRONT = "front"
|
||||
BACK = "back"
|
||||
NAMEPLATE = "nameplate"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
HTTP 上传模块 - 将图片上传到指定服务器
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageUploader:
|
||||
"""图片上传器"""
|
||||
|
||||
def __init__(self, upload_url: str, timeout: int = 30, max_retries: int = 3):
|
||||
self.upload_url = upload_url
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
|
||||
def upload(self, image_path: str, serial_number: str, photo_index: int,
|
||||
photo_type: str = "front") -> Optional[str]:
|
||||
"""
|
||||
上传单张图片
|
||||
返回: 服务器返回的消息(成功时),失败返回 None
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
logger.error(f"图片文件不存在: {image_path}")
|
||||
return None
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
files = {"file": (os.path.basename(image_path), f, "image/jpeg")}
|
||||
data = {
|
||||
"serialNumber": serial_number,
|
||||
"index": photo_index
|
||||
}
|
||||
resp = requests.post(
|
||||
self.upload_url,
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"图片上传成功: {serialNumber} #{photo_index} ({photo_type})")
|
||||
try:
|
||||
return resp.json().get("msg", "success")
|
||||
except:
|
||||
return resp.text
|
||||
else:
|
||||
logger.warning(f"上传失败 [{resp.status_code}]: {resp.text[:100]}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"上传异常 (尝试 {attempt+1}/{self.max_retries}): {e}")
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(2)
|
||||
|
||||
logger.error(f"图片上传最终失败: {image_path}")
|
||||
return None
|
||||
|
||||
def upload_batch(self, image_paths: list, serial_number: str,
|
||||
start_index: int = 0) -> dict:
|
||||
"""批量上传图片"""
|
||||
results = []
|
||||
for i, path in enumerate(image_paths):
|
||||
result = self.upload(path, serial_number, start_index + i)
|
||||
results.append({
|
||||
"index": start_index + i,
|
||||
"path": path,
|
||||
"success": result is not None,
|
||||
"msg": result
|
||||
})
|
||||
return results
|
||||
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
地图导航模块 - A* 路径规划 + Pure Pursuit 路径跟踪
|
||||
在已知地图上规划路径,控制 AGV 自动导航到目标坐标
|
||||
|
||||
依赖:numpy, cv2, Pillow(均已安装在 AGV 上)
|
||||
不依赖:激光雷达、SLAM、Nav2
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import heapq
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import subprocess
|
||||
import numpy as np
|
||||
import cv2
|
||||
import yaml
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ROS2 环境设置(与 agv_controller_ros2.py 保持一致)
|
||||
ROS2_SETUP_CMD = "export ROS_DOMAIN_ID=0 && source ~/agv_pro_ros2/install/setup.bash"
|
||||
|
||||
|
||||
# ========== 坐标转换 ==========
|
||||
|
||||
class CoordTransformer:
|
||||
"""地图世界坐标 ↔ 栅格坐标 双向转换"""
|
||||
|
||||
def __init__(self, resolution: float, origin: List[float], width: int, height: int):
|
||||
"""
|
||||
Args:
|
||||
resolution: 地图分辨率(米/像素)
|
||||
origin: [x, y, yaw] 地图原点在世界坐标系中的位置
|
||||
width: 地图宽度(像素)
|
||||
height: 地图高度(像素)
|
||||
"""
|
||||
self.resolution = resolution
|
||||
self.origin = origin # [ox, oy, oyaw]
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def world_to_grid(self, wx: float, wy: float) -> Tuple[int, int]:
|
||||
"""世界坐标 → 栅格坐标 [col, row]"""
|
||||
col = int((wx - self.origin[0]) / self.resolution)
|
||||
row = int((wy - self.origin[1]) / self.resolution)
|
||||
# ROS 地图 row=0 对应图像最上方(y 最大值),需要翻转
|
||||
row = self.height - 1 - row
|
||||
return (col, row)
|
||||
|
||||
def grid_to_world(self, col: int, row: int) -> Tuple[float, float]:
|
||||
"""栅格坐标 [col, row] → 世界坐标 [x, y]"""
|
||||
# 翻转 row
|
||||
actual_row = self.height - 1 - row
|
||||
wx = col * self.resolution + self.origin[0]
|
||||
wy = actual_row * self.resolution + self.origin[1]
|
||||
return (wx, wy)
|
||||
|
||||
def world_to_grid_center(self, wx: float, wy: float) -> Tuple[float, float]:
|
||||
"""世界坐标 → 栅格中心的世界坐标(对齐到栅格)"""
|
||||
col, row = self.world_to_grid(wx, wy)
|
||||
return self.grid_to_world(col, row)
|
||||
|
||||
|
||||
# ========== A* 路径规划 ==========
|
||||
|
||||
class AStarPlanner:
|
||||
"""A* 路径规划器,在栅格地图上规划最短路径"""
|
||||
|
||||
# 8方向移动:右、左、下、上、右下、右上、左下、左上
|
||||
DIRECTIONS = [
|
||||
(1, 0), (-1, 0), (0, 1), (0, -1),
|
||||
(1, 1), (1, -1), (-1, 1), (-1, -1)
|
||||
]
|
||||
# 对角线移动的代价乘数(sqrt(2))
|
||||
DIR_COSTS = [1.0, 1.0, 1.0, 1.0, 1.414, 1.414, 1.414, 1.414]
|
||||
|
||||
def __init__(self, occupancy_grid: np.ndarray, inflation_radius: int = 3):
|
||||
"""
|
||||
Args:
|
||||
occupancy_grid: 栅格地图,0=空闲,255=障碍物
|
||||
inflation_radius: 障碍物膨胀半径(像素),AGV 有一定体积不能贴墙走
|
||||
"""
|
||||
self.grid = occupancy_grid
|
||||
self.height, self.width = occupancy_grid.shape
|
||||
self.inflated = self._inflate(inflation_radius)
|
||||
|
||||
def _inflate(self, radius: int) -> np.ndarray:
|
||||
"""膨胀障碍物区域"""
|
||||
if radius <= 0:
|
||||
return self.grid.copy()
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
|
||||
inflated = cv2.dilate(self.grid, kernel, iterations=1)
|
||||
# 确保二值化
|
||||
inflated = np.where(inflated > 50, 255, 0).astype(np.uint8)
|
||||
return inflated
|
||||
|
||||
def plan(self, start: Tuple[int, int], goal: Tuple[int, int]) -> Optional[List[Tuple[int, int]]]:
|
||||
"""
|
||||
A* 路径规划
|
||||
|
||||
Args:
|
||||
start: 起点栅格坐标 (col, row)
|
||||
goal: 终点栅格坐标 (col, row)
|
||||
|
||||
Returns:
|
||||
路径点列表 [(col, row), ...],包含起点和终点;无法规划时返回 None
|
||||
"""
|
||||
# 边界检查
|
||||
if not self._is_valid(start) or not self._is_valid(goal):
|
||||
logger.warning(f"起点或终点无效: start={start}, goal={goal}")
|
||||
# 尝试找最近的可行点
|
||||
start = self._find_nearest_free(start)
|
||||
goal = self._find_nearest_free(goal)
|
||||
if start is None or goal is None:
|
||||
logger.error("无法找到有效的起点或终点")
|
||||
return None
|
||||
|
||||
# 检查终点是否被障碍物包围
|
||||
if self.inflated[goal[1], goal[0]] > 50:
|
||||
goal = self._find_nearest_free(goal)
|
||||
|
||||
if goal is None:
|
||||
logger.error("终点周围无可行区域")
|
||||
return None
|
||||
|
||||
# A* 算法
|
||||
open_set = []
|
||||
heapq.heappush(open_set, (0.0, start))
|
||||
came_from = {}
|
||||
g_score = {start: 0.0}
|
||||
closed_set = set()
|
||||
|
||||
while open_set:
|
||||
_, current = heapq.heappop(open_set)
|
||||
|
||||
if current in closed_set:
|
||||
continue
|
||||
closed_set.add(current)
|
||||
|
||||
if current == goal:
|
||||
# 回溯路径
|
||||
path = []
|
||||
while current in came_from:
|
||||
path.append(current)
|
||||
current = came_from[current]
|
||||
path.append(start)
|
||||
path.reverse()
|
||||
return path
|
||||
|
||||
for i, (dx, dy) in enumerate(self.DIRECTIONS):
|
||||
neighbor = (current[0] + dx, current[1] + dy)
|
||||
|
||||
if neighbor in closed_set:
|
||||
continue
|
||||
|
||||
if not self._is_valid(neighbor):
|
||||
continue
|
||||
|
||||
if self.inflated[neighbor[1], neighbor[0]] > 50:
|
||||
continue
|
||||
|
||||
move_cost = self.DIR_COSTS[i]
|
||||
tentative_g = g_score[current] + move_cost
|
||||
|
||||
if tentative_g < g_score.get(neighbor, float('inf')):
|
||||
came_from[neighbor] = current
|
||||
g_score[neighbor] = tentative_g
|
||||
f_score = tentative_g + self._heuristic(neighbor, goal)
|
||||
heapq.heappush(open_set, (f_score, neighbor))
|
||||
|
||||
logger.warning("A* 无法找到路径")
|
||||
return None
|
||||
|
||||
def _heuristic(self, a: Tuple[int, int], b: Tuple[int, int]) -> float:
|
||||
"""对角线距离启发式"""
|
||||
dx = abs(a[0] - b[0])
|
||||
dy = abs(a[1] - b[1])
|
||||
return max(dx, dy) + (1.414 - 1) * min(dx, dy)
|
||||
|
||||
def _is_valid(self, pos: Tuple[int, int]) -> bool:
|
||||
return 0 <= pos[0] < self.width and 0 <= pos[1] < self.height
|
||||
|
||||
def _find_nearest_free(self, pos: Tuple[int, int], max_dist: int = 10) -> Optional[Tuple[int, int]]:
|
||||
"""在 pos 附近找最近的可行点"""
|
||||
for r in range(1, max_dist + 1):
|
||||
for dx in range(-r, r + 1):
|
||||
for dy in range(-r, r + 1):
|
||||
n = (pos[0] + dx, pos[1] + dy)
|
||||
if self._is_valid(n) and self.inflated[n[1], n[0]] == 0:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
# ========== 路径平滑 ==========
|
||||
|
||||
def smooth_path(grid: np.ndarray, path: List[Tuple[int, int]],
|
||||
weight_data: float = 0.3, weight_smooth: float = 0.5,
|
||||
tolerance: float = 1e-5, max_iter: int = 500) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
路径平滑(梯度下降法)
|
||||
在障碍物约束下让路径更平滑,减少不必要的转向
|
||||
"""
|
||||
if len(path) <= 2:
|
||||
return path
|
||||
|
||||
height, width = grid.shape
|
||||
new_path = [list(p) for p in path]
|
||||
|
||||
for iteration in range(max_iter):
|
||||
change = 0.0
|
||||
for i in range(1, len(new_path) - 1):
|
||||
for j in range(2):
|
||||
old_val = new_path[i][j]
|
||||
# 数据项:趋向原始路径点
|
||||
data_gradient = weight_data * (path[i][j] - new_path[i][j])
|
||||
# 平滑项:趋向邻居中点
|
||||
smooth_gradient = weight_smooth * (
|
||||
new_path[i - 1][j] + new_path[i + 1][j] - 2 * new_path[i][j]
|
||||
)
|
||||
new_path[i][j] += data_gradient + smooth_gradient
|
||||
|
||||
# 边界约束
|
||||
new_path[i][0] = max(0, min(width - 1, new_path[i][0]))
|
||||
new_path[i][1] = max(0, min(height - 1, new_path[i][1]))
|
||||
|
||||
# 障碍物约束
|
||||
col, row = int(round(new_path[i][0])), int(round(new_path[i][1]))
|
||||
if 0 <= col < width and 0 <= row < height:
|
||||
if grid[row, col] > 50:
|
||||
new_path[i][j] = old_val # 回退
|
||||
|
||||
change += abs(new_path[i][j] - old_val)
|
||||
|
||||
if change < tolerance:
|
||||
break
|
||||
|
||||
return [(int(round(p[0])), int(round(p[1]))) for p in new_path]
|
||||
|
||||
|
||||
# ========== 路径降采样 ==========
|
||||
|
||||
def downsample_path(path: List[Tuple[int, int]], min_dist: int = 3) -> List[Tuple[int, int]]:
|
||||
"""降采样路径,移除过近的点,减少 cmd_vel 发布频率"""
|
||||
if len(path) <= 2:
|
||||
return path
|
||||
|
||||
result = [path[0]]
|
||||
for p in path[1:]:
|
||||
last = result[-1]
|
||||
dist = math.hypot(p[0] - last[0], p[1] - last[1])
|
||||
if dist >= min_dist:
|
||||
result.append(p)
|
||||
# 确保终点包含在内
|
||||
if result[-1] != path[-1]:
|
||||
result.append(path[-1])
|
||||
return result
|
||||
|
||||
|
||||
# ========== Pure Pursuit 控制器 ==========
|
||||
|
||||
class PurePursuitController:
|
||||
"""Pure Pursuit 路径跟踪控制器"""
|
||||
|
||||
def __init__(self, lookahead_distance: float = 0.3,
|
||||
max_linear_speed: float = 0.4,
|
||||
max_angular_speed: float = 0.8,
|
||||
goal_tolerance: float = 0.15,
|
||||
slow_down_distance: float = 0.5):
|
||||
"""
|
||||
Args:
|
||||
lookahead_distance: 前视距离(米),越大转弯越平缓
|
||||
max_linear_speed: 最大线速度 (m/s)
|
||||
max_angular_speed: 最大角速度 (rad/s)
|
||||
goal_tolerance: 到达目标容差(米)
|
||||
slow_down_distance: 开始减速的距离(米)
|
||||
"""
|
||||
self.lookahead_distance = lookahead_distance
|
||||
self.max_linear_speed = max_linear_speed
|
||||
self.max_angular_speed = max_angular_speed
|
||||
self.goal_tolerance = goal_tolerance
|
||||
self.slow_down_distance = slow_down_distance
|
||||
self.transformer: Optional[CoordTransformer] = None
|
||||
|
||||
def set_transformer(self, transformer: CoordTransformer):
|
||||
self.transformer = transformer
|
||||
|
||||
def compute(self, current_pos: Tuple[float, float, float],
|
||||
path_world: List[Tuple[float, float]]) -> Tuple[float, float, bool]:
|
||||
"""
|
||||
计算控制量
|
||||
|
||||
Args:
|
||||
current_pos: (x, y, yaw) 当前世界坐标
|
||||
path_world: 路径点列表 [(x, y), ...] 世界坐标
|
||||
|
||||
Returns:
|
||||
(linear_x, angular_z, reached) 线速度、角速度、是否到达
|
||||
"""
|
||||
if not path_world:
|
||||
return (0.0, 0.0, True)
|
||||
|
||||
x, y, yaw = current_pos
|
||||
|
||||
# 检查是否到达终点
|
||||
goal = path_world[-1]
|
||||
dist_to_goal = math.hypot(goal[0] - x, goal[1] - y)
|
||||
if dist_to_goal < self.goal_tolerance:
|
||||
return (0.0, 0.0, True)
|
||||
|
||||
# 找前视点(lookahead point)
|
||||
lookahead_point = self._find_lookahead_point(x, y, path_world)
|
||||
|
||||
if lookahead_point is None:
|
||||
# 已经越过最后一个点
|
||||
return (0.0, 0.0, True)
|
||||
|
||||
lx, ly = lookahead_point
|
||||
|
||||
# 转换到机器人坐标系
|
||||
dx = lx - x
|
||||
dy = ly - y
|
||||
|
||||
# 旋转到机器人坐标系(x 轴朝前)
|
||||
local_x = dx * math.cos(yaw) + dy * math.sin(yaw)
|
||||
local_y = -dx * math.sin(yaw) + dy * math.cos(yaw)
|
||||
|
||||
# 弧长 = 角度 * 半径 → curvature = 2 * ly / L^2
|
||||
L = math.hypot(local_x, local_y)
|
||||
if L < 1e-6:
|
||||
return (0.0, 0.0, True)
|
||||
|
||||
curvature = 2.0 * local_y / (L * L)
|
||||
angular_z = curvature * self.max_linear_speed
|
||||
|
||||
# 根据距离调整速度
|
||||
linear_x = self.max_linear_speed
|
||||
if dist_to_goal < self.slow_down_distance:
|
||||
ratio = max(0.15, dist_to_goal / self.slow_down_distance)
|
||||
linear_x *= ratio
|
||||
|
||||
# 限制角速度
|
||||
angular_z = max(-self.max_angular_speed, min(self.max_angular_speed, angular_z))
|
||||
|
||||
# 如果角度偏差太大,先原位转弯
|
||||
angle_to_goal = math.atan2(ly - y, lx - x) - yaw
|
||||
angle_to_goal = math.atan2(math.sin(angle_to_goal), math.cos(angle_to_goal))
|
||||
|
||||
if abs(angle_to_goal) > math.pi / 3:
|
||||
# 角度偏差 > 60°,先原位转弯
|
||||
linear_x = 0.0
|
||||
angular_z = max(-self.max_angular_speed, min(self.max_angular_speed, angle_to_goal * 1.5))
|
||||
|
||||
return (linear_x, angular_z, False)
|
||||
|
||||
def _find_lookahead_point(self, x: float, y: float,
|
||||
path: List[Tuple[float, float]]) -> Optional[Tuple[float, float]]:
|
||||
"""沿路径找到前视距离处的点"""
|
||||
for i in range(len(path) - 1, -1, -1):
|
||||
dist = math.hypot(path[i][0] - x, path[i][1] - y)
|
||||
if dist >= self.lookahead_distance:
|
||||
return path[i]
|
||||
# 如果所有点都在前视距离内,返回终点
|
||||
return path[-1] if path else None
|
||||
|
||||
|
||||
# ========== 导航器(核心模块) ==========
|
||||
|
||||
class NavStatus(Enum):
|
||||
IDLE = "idle"
|
||||
PLANNING = "planning"
|
||||
NAVIGATING = "navigating"
|
||||
REACHED = "reached"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class MapNavigator:
|
||||
"""地图导航器 — 整合路径规划与路径跟踪"""
|
||||
|
||||
def __init__(self, map_yaml_path: str):
|
||||
"""
|
||||
Args:
|
||||
map_yaml_path: map.yaml 文件的绝对路径
|
||||
"""
|
||||
self.map_yaml_path = map_yaml_path
|
||||
self.transformer: Optional[CoordTransformer] = None
|
||||
self.planner: Optional[AStarPlanner] = None
|
||||
self.controller = PurePursuitController()
|
||||
self.controller.set_transformer(self.transformer)
|
||||
|
||||
# 导航状态
|
||||
self.status = NavStatus.IDLE
|
||||
self._nav_thread: Optional[threading.Thread] = None
|
||||
self._cancel_event = threading.Event()
|
||||
|
||||
# 当前路径(世界坐标)
|
||||
self.path_world: List[Tuple[float, float]] = []
|
||||
self.current_position = [0.0, 0.0, 0.0] # [x, y, yaw]
|
||||
|
||||
# 加载地图
|
||||
self._load_map()
|
||||
|
||||
def _load_map(self):
|
||||
"""加载地图 PGM + YAML"""
|
||||
with open(self.map_yaml_path, 'r') as f:
|
||||
meta = yaml.safe_load(f)
|
||||
|
||||
map_dir = os.path.dirname(self.map_yaml_path)
|
||||
pgm_path = os.path.join(map_dir, meta['image'])
|
||||
|
||||
# 读取 PGM 灰度图
|
||||
img = cv2.imread(pgm_path, cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"无法读取地图文件: {pgm_path}")
|
||||
|
||||
# ROS 地图:0=占用(障碍物),254=空闲,205=未知
|
||||
# 转为二值:空闲=0,障碍物=255
|
||||
self.occupancy = np.where(img <= 50, 255, 0).astype(np.uint8)
|
||||
# 未知区域(205 附近)也视为障碍物
|
||||
self.occupancy = np.where((img > 50) & (img < 250), 255, self.occupancy)
|
||||
|
||||
resolution = meta['resolution']
|
||||
origin = meta.get('origin', [0, 0, 0])
|
||||
height, width = img.shape
|
||||
|
||||
self.transformer = CoordTransformer(resolution, origin, width, height)
|
||||
self.planner = AStarPlanner(self.occupancy, inflation_radius=3)
|
||||
self.controller.set_transformer(self.transformer)
|
||||
|
||||
self._map_meta = meta
|
||||
logger.info(f"地图加载完成: {width}x{height}, 分辨率 {resolution}m, 原点 {origin}")
|
||||
|
||||
def get_odom(self) -> List[float]:
|
||||
"""从 /odom 话题获取当前位置 [x, y, yaw]"""
|
||||
try:
|
||||
cmd = f"timeout 5 ros2 topic echo /odom --once 2>/dev/null"
|
||||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && {cmd}'"
|
||||
result = subprocess.run(
|
||||
full_cmd, shell=True, capture_output=True, text=True, timeout=6
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
yaml_str = result.stdout.split('---')[0]
|
||||
data = yaml.safe_load(yaml_str)
|
||||
if data:
|
||||
pos = data.get("pose", {}).get("pose", {}).get("position", {})
|
||||
x, y = pos.get("x", 0.0), pos.get("y", 0.0)
|
||||
orient = data.get("pose", {}).get("pose", {}).get("orientation", {})
|
||||
qz, qw = orient.get("z", 0.0), orient.get("w", 1.0)
|
||||
yaw = math.atan2(2.0 * qw * qz, 1.0 - 2.0 * qz * qz)
|
||||
self.current_position = [x, y, yaw]
|
||||
return self.current_position
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 odom 失败: {e}")
|
||||
return self.current_position
|
||||
|
||||
def _publish_cmd_vel(self, linear_x: float, angular_z: float):
|
||||
"""发布速度命令到 /cmd_vel"""
|
||||
msg = (
|
||||
f'{{"linear": {{"x": {linear_x:.4f}, "y": 0.0, "z": 0.0}}, '
|
||||
f'"angular": {{"x": 0.0, "y": 0.0, "z": {angular_z:.4f}}}}}'
|
||||
)
|
||||
full_cmd = f"bash -c '{ROS2_SETUP_CMD} && ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \"{msg}\" --once'"
|
||||
try:
|
||||
subprocess.run(full_cmd, shell=True, capture_output=True, text=True, timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("发布 cmd_vel 超时")
|
||||
|
||||
def _stop_cmd_vel(self):
|
||||
"""发布停止命令"""
|
||||
self._publish_cmd_vel(0.0, 0.0)
|
||||
|
||||
def plan_path(self, goal_x: float, goal_y: float,
|
||||
start_x: float = None, start_y: float = None) -> bool:
|
||||
"""
|
||||
规划路径(不执行导航)
|
||||
|
||||
Args:
|
||||
goal_x, goal_y: 目标世界坐标(米)
|
||||
start_x, start_y: 起点世界坐标(米),默认使用当前 odom
|
||||
|
||||
Returns:
|
||||
是否规划成功
|
||||
"""
|
||||
if self.transformer is None:
|
||||
logger.error("地图未加载")
|
||||
return False
|
||||
|
||||
# 获取起点
|
||||
if start_x is None or start_y is None:
|
||||
pos = self.get_odom()
|
||||
start_x, start_y = pos[0], pos[1]
|
||||
|
||||
# 坐标转换
|
||||
start_grid = self.transformer.world_to_grid(start_x, start_y)
|
||||
goal_grid = self.transformer.world_to_grid(goal_x, goal_y)
|
||||
|
||||
logger.info(f"规划路径: 起点(世界){start_x:.2f},{start_y:.2f} → (栅格){start_grid}")
|
||||
logger.info(f" 终点(世界){goal_x:.2f},{goal_y:.2f} → (栅格){goal_grid}")
|
||||
|
||||
# A* 规划
|
||||
path_grid = self.planner.plan(start_grid, goal_grid)
|
||||
if path_grid is None:
|
||||
logger.warning("路径规划失败")
|
||||
return False
|
||||
|
||||
# 路径平滑
|
||||
path_grid = smooth_path(self.planner.inflated, path_grid)
|
||||
|
||||
# 降采样
|
||||
path_grid = downsample_path(path_grid, min_dist=2)
|
||||
|
||||
# 转换为世界坐标
|
||||
self.path_world = [self.transformer.grid_to_world(c, r) for c, r in path_grid]
|
||||
|
||||
logger.info(f"路径规划成功: {len(self.path_world)} 个路径点")
|
||||
return True
|
||||
|
||||
def navigate_to(self, goal_x: float, goal_y, blocking: bool = False) -> bool:
|
||||
"""
|
||||
导航到目标点
|
||||
|
||||
Args:
|
||||
goal_x, goal_y: 目标世界坐标(米)
|
||||
blocking: 是否阻塞等待导航完成
|
||||
|
||||
Returns:
|
||||
非阻塞模式下返回 True(表示已启动),阻塞模式下返回是否到达
|
||||
"""
|
||||
if self.status == NavStatus.NAVIGATING:
|
||||
logger.warning("导航正在进行中,请先停止当前导航")
|
||||
return False
|
||||
|
||||
# 规划路径
|
||||
if not self.plan_path(goal_x, goal_y):
|
||||
self.status = NavStatus.FAILED
|
||||
return False
|
||||
|
||||
# 启动导航线程
|
||||
self._cancel_event.clear()
|
||||
self.status = NavStatus.NAVIGATING
|
||||
self._nav_thread = threading.Thread(
|
||||
target=self._navigate_thread,
|
||||
args=(goal_x, goal_y),
|
||||
daemon=True
|
||||
)
|
||||
self._nav_thread.start()
|
||||
|
||||
if blocking:
|
||||
self._nav_thread.join()
|
||||
return self.status == NavStatus.REACHED
|
||||
|
||||
return True
|
||||
|
||||
def _navigate_thread(self, goal_x: float, goal_y: float):
|
||||
"""导航线程"""
|
||||
logger.info(f"开始导航 → 目标 ({goal_x:.2f}, {goal_y:.2f})")
|
||||
|
||||
try:
|
||||
# 转弯朝向第一个路径点
|
||||
self._initial_turn()
|
||||
|
||||
# 跟踪路径
|
||||
last_cmd_time = time.time()
|
||||
cmd_interval = 0.2 # cmd_vel 发布间隔(秒)
|
||||
|
||||
while not self._cancel_event.is_set():
|
||||
pos = self.get_odom()
|
||||
x, y, yaw = pos
|
||||
|
||||
linear_x, angular_z, reached = self.controller.compute(
|
||||
(x, y, yaw), self.path_world
|
||||
)
|
||||
|
||||
if reached:
|
||||
self._stop_cmd_vel()
|
||||
self.status = NavStatus.REACHED
|
||||
logger.info("✅ 已到达目标点")
|
||||
return
|
||||
|
||||
# 控制发布频率
|
||||
now = time.time()
|
||||
if now - last_cmd_time >= cmd_interval:
|
||||
self._publish_cmd_vel(linear_x, angular_z)
|
||||
last_cmd_time = now
|
||||
|
||||
time.sleep(0.05) # 50ms 控制循环
|
||||
|
||||
# 被取消
|
||||
self._stop_cmd_vel()
|
||||
self.status = NavStatus.CANCELLED
|
||||
logger.info("导航已取消")
|
||||
|
||||
except Exception as e:
|
||||
self._stop_cmd_vel()
|
||||
self.status = NavStatus.FAILED
|
||||
logger.error(f"导航异常: {e}")
|
||||
|
||||
def _initial_turn(self):
|
||||
"""导航开始前,先原地转向朝向第一个路径点"""
|
||||
if len(self.path_world) < 2:
|
||||
return
|
||||
|
||||
pos = self.get_odom()
|
||||
x, y, yaw = pos
|
||||
target = self.path_world[1] # 第一个路径点是当前位置,取第二个
|
||||
|
||||
angle_to_target = math.atan2(target[1] - y, target[0] - x) - yaw
|
||||
angle_to_target = math.atan2(math.sin(angle_to_target), math.cos(angle_to_target))
|
||||
|
||||
if abs(angle_to_target) < 0.1: # < 6°,不需要转弯
|
||||
return
|
||||
|
||||
logger.info(f"初始转向: {math.degrees(angle_to_target):.1f}°")
|
||||
|
||||
# 分段旋转(避免一步到位导致超调)
|
||||
steps = max(3, int(abs(angle_to_target) / 0.2))
|
||||
step_angle = angle_to_target / steps
|
||||
step_time = abs(step_angle) / self.controller.max_angular_speed + 0.1
|
||||
|
||||
for _ in range(steps):
|
||||
if self._cancel_event.is_set():
|
||||
return
|
||||
angular = max(-self.controller.max_angular_speed,
|
||||
min(self.controller.max_angular_speed, step_angle * 2))
|
||||
self._publish_cmd_vel(0.0, angular)
|
||||
time.sleep(step_time)
|
||||
|
||||
self._stop_cmd_vel()
|
||||
time.sleep(0.2) # 稳定后继续
|
||||
|
||||
def stop(self):
|
||||
"""停止当前导航"""
|
||||
if self.status == NavStatus.NAVIGATING:
|
||||
self._cancel_event.set()
|
||||
self._stop_cmd_vel()
|
||||
if self._nav_thread and self._nav_thread.is_alive():
|
||||
self._nav_thread.join(timeout=3)
|
||||
self.status = NavStatus.CANCELLED
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""获取导航状态"""
|
||||
pos = self.get_odom()
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"current_position": pos,
|
||||
"path_length": len(self.path_world),
|
||||
"path": self.path_world if self.status in (NavStatus.NAVIGATING, NavStatus.REACHED) else []
|
||||
}
|
||||
|
||||
def get_path_preview(self, goal_x: float, goal_y: float) -> Optional[List[Tuple[float, float]]]:
|
||||
"""
|
||||
预览路径(仅规划不执行),用于前端可视化
|
||||
|
||||
Returns:
|
||||
世界坐标路径列表,或 None(规划失败)
|
||||
"""
|
||||
if self.plan_path(goal_x, goal_y):
|
||||
return self.path_world
|
||||
return None
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
任务调度器 - 管理拍摄任务的执行
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from enum import Enum
|
||||
|
||||
from .arm_client import ArmClient
|
||||
from .agv_controller import AGVController
|
||||
from .qr_scanner import QRScanner
|
||||
from .image_uploader import ImageUploader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class MissionExecutor:
|
||||
"""任务执行器 - 负责按顺序执行点位拍摄任务"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.status = TaskStatus.PENDING
|
||||
self.current_point_index = 0
|
||||
self.current_pose_index = 0
|
||||
self.snapshot_serial_map = {} # {point_id: serial_number} 缓存已扫描的 serialNumber
|
||||
|
||||
# 初始化各模块
|
||||
self.agv = AGVController(
|
||||
device=config.get("device", "/dev/agvpro_controller"),
|
||||
baudrate=config.get("baudrate", 1000000)
|
||||
)
|
||||
self.arm_client: Optional[ArmClient] = None
|
||||
self.uploader = ImageUploader(
|
||||
upload_url=config["upload_url"],
|
||||
timeout=config.get("upload_timeout", 30),
|
||||
max_retries=config.get("upload_retries", 3)
|
||||
)
|
||||
self.qr_scanner = QRScanner(device_index=config.get("camera_index", 0))
|
||||
|
||||
# ========== 连接管理 ==========
|
||||
|
||||
def connect_all(self) -> Dict[str, bool]:
|
||||
"""连接 AGV、机械臂、摄像头"""
|
||||
results = {}
|
||||
|
||||
# 连接 AGV
|
||||
results["agv"] = self.agv.connect()
|
||||
|
||||
# 连接机械臂(通过 TCP)
|
||||
arm_cfg = self.config["arm"]
|
||||
self.arm_client = ArmClient(arm_cfg["host"], arm_cfg["port"])
|
||||
results["arm"] = self.arm_client.connect()
|
||||
|
||||
# 打开摄像头
|
||||
results["camera"] = self.qr_scanner.open()
|
||||
|
||||
return results
|
||||
|
||||
def disconnect_all(self):
|
||||
"""断开所有连接"""
|
||||
if self.arm_client:
|
||||
self.arm_client.close()
|
||||
self.agv.disconnect()
|
||||
self.qr_scanner.close()
|
||||
|
||||
# ========== 任务执行 ==========
|
||||
|
||||
def execute_mission(self, mission_data: dict) -> dict:
|
||||
"""
|
||||
执行一个完整任务(一个地图的所有点位)
|
||||
mission_data: 包含点位列表的完整任务配置
|
||||
返回执行报告
|
||||
"""
|
||||
self.status = TaskStatus.RUNNING
|
||||
report = {
|
||||
"total_points": len(mission_data.get("points", [])),
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"details": []
|
||||
}
|
||||
|
||||
points = mission_data.get("points", [])
|
||||
for i, point in enumerate(points):
|
||||
self.current_point_index = i
|
||||
try:
|
||||
result = self._execute_point(point)
|
||||
report["details"].append(result)
|
||||
if result["status"] == "completed":
|
||||
report["completed"] += 1
|
||||
else:
|
||||
report["failed"] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"点位 {i} 执行异常: {e}")
|
||||
report["failed"] += 1
|
||||
report["details"].append({
|
||||
"point_index": i,
|
||||
"point_name": point.get("name", f"point_{i}"),
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
self.status = TaskStatus.COMPLETED if report["failed"] == 0 else TaskStatus.PAUSED
|
||||
return report
|
||||
|
||||
def _execute_point(self, point: dict) -> dict:
|
||||
"""执行单个点位的拍摄"""
|
||||
point_name = point.get("name", "unknown")
|
||||
logger.info(f"开始执行点位: {point_name}")
|
||||
|
||||
result = {
|
||||
"point_name": point_name,
|
||||
"poses": []
|
||||
}
|
||||
|
||||
# 1. AGV 移动到点位
|
||||
coords = point.get("coords", {})
|
||||
x, y = coords.get("x", 0), coords.get("y", 0)
|
||||
logger.info(f"AGV 移动到 ({x}, {y})")
|
||||
# TODO: 调用导航移动到目标点
|
||||
time.sleep(1) # 模拟移动
|
||||
|
||||
# 2. 执行该点位的所有姿态
|
||||
poses = point.get("poses", [])
|
||||
for j, pose in enumerate(poses):
|
||||
self.current_pose_index = j
|
||||
pose_result = self._execute_pose(point, pose, j)
|
||||
result["poses"].append(pose_result)
|
||||
|
||||
# 如果是"两者都要"类型,需要按顺序执行两台机器
|
||||
if pose.get("type") == "both":
|
||||
# 执行顺序由 pose.sequence 配置
|
||||
sequence = pose.get("sequence", ["front_first"])
|
||||
for step in sequence:
|
||||
if step == "front":
|
||||
self._capture_and_upload(point, pose, "front", j)
|
||||
elif step == "back":
|
||||
self._capture_and_upload(point, pose, "back", j)
|
||||
else:
|
||||
photo_type = pose.get("photo_type", "front")
|
||||
self._capture_and_upload(point, pose, photo_type, j)
|
||||
|
||||
result["status"] = "completed"
|
||||
return result
|
||||
|
||||
def _execute_pose(self, point: dict, pose: dict, pose_idx: int) -> dict:
|
||||
"""执行单个姿态的拍摄"""
|
||||
photo_type = pose.get("photo_type", "front")
|
||||
camera_source = pose.get("camera", "agv") # agv 或 arm
|
||||
|
||||
# 如果需要机械臂运动
|
||||
arm_angles = pose.get("arm_angles", None)
|
||||
if arm_angles and self.arm_client:
|
||||
self.arm_client.set_angles(arm_angles, speed=pose.get("speed", 500))
|
||||
time.sleep(1) # 等待运动到位
|
||||
|
||||
return {
|
||||
"pose_index": pose_idx,
|
||||
"photo_type": photo_type,
|
||||
"arm_angles": arm_angles,
|
||||
"status": "ready"
|
||||
}
|
||||
|
||||
def _capture_and_upload(self, point: dict, pose: dict, photo_type: str, pose_idx: int):
|
||||
"""拍摄并上传"""
|
||||
point_id = point.get("id", str(point))
|
||||
|
||||
# 确定 serialNumber
|
||||
if photo_type == "front":
|
||||
# 正面:从二维码获取 serialNumber
|
||||
serial = self.qr_scanner.scan_with_retry(max_attempts=5, interval=0.5)
|
||||
if not serial:
|
||||
logger.warning(f"点位 {point.get('name')} 正面拍摄未扫描到二维码,跳过")
|
||||
return
|
||||
self.snapshot_serial_map[point_id] = serial
|
||||
else:
|
||||
# 背面:使用缓存的 serialNumber
|
||||
serial = self.snapshot_serial_map.get(point_id)
|
||||
if not serial:
|
||||
logger.warning(f"点位 {point.get('name')} 背面拍摄但无缓存 serialNumber")
|
||||
return
|
||||
|
||||
# 拍摄图片(AGV 端摄像头)
|
||||
frame = self.qr_scanner.read_frame()
|
||||
if frame is None:
|
||||
logger.error("摄像头读取失败")
|
||||
return
|
||||
|
||||
# 保存图片
|
||||
photo_dir = os.path.join(os.path.dirname(__file__), "..", "photos")
|
||||
os.makedirs(photo_dir, exist_ok=True)
|
||||
photo_path = os.path.join(photo_dir, f"{serial}_{photo_type}_{int(time.time())}.jpg")
|
||||
import cv2
|
||||
cv2.imwrite(photo_path, frame)
|
||||
|
||||
# 上传
|
||||
self.uploader.upload(photo_path, serial, pose_idx, photo_type)
|
||||
logger.info(f"上传完成: {serial} {photo_type}")
|
||||
|
||||
# ========== 状态查询 ==========
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return {
|
||||
"task_status": self.status.value,
|
||||
"current_point": self.current_point_index,
|
||||
"current_pose": self.current_pose_index,
|
||||
"agv_connected": self.agv.is_connected(),
|
||||
"arm_connected": self.arm_client is not None,
|
||||
"camera_opened": self.qr_scanner._cap is not None and self.qr_scanner._cap.isOpened()
|
||||
}
|
||||
|
||||
def pause(self):
|
||||
self.status = TaskStatus.PAUSED
|
||||
|
||||
def resume(self):
|
||||
self.status = TaskStatus.RUNNING
|
||||
|
||||
def stop(self):
|
||||
if self.arm_client:
|
||||
self.arm_client.task_stop()
|
||||
self.agv.stop()
|
||||
self.status = TaskStatus.PENDING
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber
|
||||
"""
|
||||
import cv2
|
||||
import time
|
||||
import logging
|
||||
import numpy as np
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入二维码识别库
|
||||
try:
|
||||
from pyzbar.pyzbar import decode as qr_decode
|
||||
PYZBAR_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYZBAR_AVAILABLE = False
|
||||
logger.warning("pyzbar 未安装,尝试用 OpenCV 内置 QRCodeDetector")
|
||||
|
||||
|
||||
class QRScanner:
|
||||
"""二维码扫描器"""
|
||||
|
||||
def __init__(self, device_index: int = 0):
|
||||
self.device_index = device_index
|
||||
self._cap: Optional[cv2.VideoCapture] = None
|
||||
self._qr_detector = cv2.QRCodeDetector() # OpenCV 内置二维码检测器
|
||||
|
||||
def open(self) -> bool:
|
||||
"""打开摄像头"""
|
||||
try:
|
||||
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
|
||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||
if self._cap.isOpened():
|
||||
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
|
||||
return True
|
||||
else:
|
||||
# fallback: 不指定后端
|
||||
self._cap = cv2.VideoCapture(self.device_index)
|
||||
if self._cap.isOpened():
|
||||
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
|
||||
return True
|
||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"摄像头打开失败: {e}")
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self._cap:
|
||||
self._cap.release()
|
||||
self._cap = None
|
||||
|
||||
def read_frame(self) -> Optional[np.ndarray]:
|
||||
"""读取一帧"""
|
||||
if not self._cap or not self._cap.isOpened():
|
||||
return None
|
||||
ret, frame = self._cap.read()
|
||||
if not ret:
|
||||
return None
|
||||
return frame
|
||||
|
||||
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||
"""从图像帧中检测二维码"""
|
||||
if frame is None:
|
||||
return None
|
||||
try:
|
||||
# OpenCV 内置二维码检测
|
||||
data, vertices, _ = self._qr_detector.detectAndDecode(frame)
|
||||
if data and len(data) > 0:
|
||||
return data.strip()
|
||||
except Exception as e:
|
||||
logger.debug(f"二维码检测失败: {e}")
|
||||
return None
|
||||
|
||||
def scan_once(self) -> Optional[str]:
|
||||
"""扫描一次(读取一帧并检测)"""
|
||||
frame = self.read_frame()
|
||||
return self.detect_qr(frame)
|
||||
|
||||
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
|
||||
"""多次扫描直到成功或达到最大次数"""
|
||||
for i in range(max_attempts):
|
||||
result = self.scan_once()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(interval)
|
||||
return None
|
||||
|
||||
def get_preview_frame(self) -> Optional[np.ndarray]:
|
||||
"""获取预览帧(用于界面显示)"""
|
||||
return self.read_frame()
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
Reference in New Issue
Block a user