Compare commits
13 Commits
671351aa89
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f10ef75852 | |||
| 87060e30d4 | |||
| 7083c45feb | |||
| 52f1930f9a | |||
| 3d0bcc8f6f | |||
| fede57e69a | |||
| 916b44bc3c | |||
| 62292edc70 | |||
| cbc88def27 | |||
| 48121b2a05 | |||
| a4f4be4c8e | |||
| 696bf2ef6e | |||
| 4126e01bba |
+208
@@ -0,0 +1,208 @@
|
|||||||
|
# ==============================
|
||||||
|
# Python
|
||||||
|
# ==============================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.virtualenv/
|
||||||
|
.virtenv/
|
||||||
|
|
||||||
|
# uv package manager
|
||||||
|
uv-cache/
|
||||||
|
|
||||||
|
# Python testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Node.js / npm
|
||||||
|
# ==============================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Next.js (Frontend)
|
||||||
|
# ==============================
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.vercel/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# IDEs & Editors
|
||||||
|
# ==============================
|
||||||
|
# VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.)
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# Vim/Neovim
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.netrwhist
|
||||||
|
|
||||||
|
# Emacs
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
*.elc
|
||||||
|
|
||||||
|
# Sublime Text
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# macOS
|
||||||
|
# ==============================
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Windows
|
||||||
|
# ==============================
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
*.stackdump
|
||||||
|
[Dd]esktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
*.lnk
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Linux
|
||||||
|
# ==============================
|
||||||
|
*~
|
||||||
|
.fuse_hidden*
|
||||||
|
.directory
|
||||||
|
.Trash-*
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Local runtime & temporary files
|
||||||
|
# ==============================
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
timeout
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.cache
|
||||||
|
*.bak
|
||||||
|
*.bak.*
|
||||||
|
*.bak2
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Application specific
|
||||||
|
# ==============================
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# ROS2 / Robotics
|
||||||
|
# ==============================
|
||||||
|
install/
|
||||||
|
log/
|
||||||
|
build/
|
||||||
|
*/install/
|
||||||
|
*/log/
|
||||||
|
*/build/
|
||||||
|
*.bag
|
||||||
|
*.bag.active
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# Misc
|
||||||
|
# ==============================
|
||||||
|
# Sensitive data (adjust patterns as needed)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.secret
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# Large binary files (adjust as needed)
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
|
||||||
|
# Generated documentation
|
||||||
|
docs/_build/
|
||||||
|
site/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.10
|
||||||
+538
-62
@@ -8,10 +8,11 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import requests
|
||||||
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
|
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from config import SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State
|
from config import SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State, ZHIJIAN_BASE_URL, ZHIJIAN_AUTH_TOKEN, set_api_mode
|
||||||
from utils.arm_client import ArmClient
|
from utils.arm_client import ArmClient
|
||||||
from utils.agv_controller_ros2 import AGVController
|
from utils.agv_controller_ros2 import AGVController
|
||||||
from utils.qr_scanner import QRScanner
|
from utils.qr_scanner import QRScanner
|
||||||
@@ -53,6 +54,8 @@ class GlobalState:
|
|||||||
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
||||||
self.qr_config = [] # 二维码配置(独立点位列表)
|
self.qr_config = [] # 二维码配置(独立点位列表)
|
||||||
self.navigator = None # Nav2Navigator 实例
|
self.navigator = None # Nav2Navigator 实例
|
||||||
|
self.current_customs = None # 当前设定的报关单信息
|
||||||
|
self.inspection = None # 查验状态 {customs_id, customs_name, items: [{inventoryCode, inventoryName, spec, quantify, inspected}]}
|
||||||
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
@@ -126,10 +129,11 @@ try:
|
|||||||
# Flask 2.3+ 方式
|
# Flask 2.3+ 方式
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
load_persisted_config()
|
load_persisted_config()
|
||||||
# 启动时自动重连 AGV(异步,不阻塞 Flask 启动)
|
# 启动时自动连接所有设备(异步,不阻塞 Flask 启动)
|
||||||
import threading
|
import threading
|
||||||
def _auto_reconnect():
|
def _auto_connect_all():
|
||||||
time.sleep(2) # 等待 Flask 完全就绪
|
time.sleep(2) # 等待 Flask 完全就绪
|
||||||
|
# 连接 AGV
|
||||||
try:
|
try:
|
||||||
from utils.agv_controller_ros2 import AGVController
|
from utils.agv_controller_ros2 import AGVController
|
||||||
gs.agv_controller = AGVController()
|
gs.agv_controller = AGVController()
|
||||||
@@ -139,7 +143,19 @@ try:
|
|||||||
print("[启动] AGV 自动连接失败,请手动连接")
|
print("[启动] AGV 自动连接失败,请手动连接")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[启动] AGV 自动连接异常: {e}")
|
print(f"[启动] AGV 自动连接异常: {e}")
|
||||||
threading.Thread(target=_auto_reconnect, daemon=True).start()
|
# 连接机械臂
|
||||||
|
try:
|
||||||
|
from utils.arm_client import ArmClient
|
||||||
|
gs.arm_client = ArmClient(ARM_CONFIG["host"], ARM_CONFIG["port"])
|
||||||
|
if gs.arm_client.connect():
|
||||||
|
gs.arm_client.power_on()
|
||||||
|
gs.arm_client.state_on()
|
||||||
|
print("[启动] 机械臂自动连接成功")
|
||||||
|
else:
|
||||||
|
print("[启动] 机械臂自动连接失败,请手动连接")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[启动] 机械臂自动连接异常: {e}")
|
||||||
|
threading.Thread(target=_auto_connect_all, daemon=True).start()
|
||||||
except:
|
except:
|
||||||
# 兼容旧版 Flask
|
# 兼容旧版 Flask
|
||||||
@app.before_first_request
|
@app.before_first_request
|
||||||
@@ -175,15 +191,21 @@ def api_status():
|
|||||||
arm_connected = ok
|
arm_connected = ok
|
||||||
except:
|
except:
|
||||||
arm_connected = False
|
arm_connected = False
|
||||||
# 连接已断开,清理 socket
|
|
||||||
if gs.arm_client:
|
|
||||||
gs.arm_client._sock = None
|
|
||||||
|
|
||||||
# 实际验证 AGV 连接
|
# 实际验证 AGV 连接
|
||||||
agv_connected = False
|
agv_connected = False
|
||||||
if gs.agv_controller:
|
if gs.agv_controller:
|
||||||
agv_connected = gs.agv_controller.is_connected()
|
agv_connected = gs.agv_controller.is_connected()
|
||||||
|
|
||||||
|
# 实时检测机械臂摄像头是否可用
|
||||||
|
try:
|
||||||
|
import requests as _armcam_req
|
||||||
|
_r = _armcam_req.get(ARM_CAMERA_CONFIG["url"], stream=True, timeout=3)
|
||||||
|
gs.arm_camera_opened = (_r.status_code == 200)
|
||||||
|
_r.close()
|
||||||
|
except:
|
||||||
|
gs.arm_camera_opened = False
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"state": gs.state,
|
"state": gs.state,
|
||||||
"agv_connected": agv_connected,
|
"agv_connected": agv_connected,
|
||||||
@@ -747,11 +769,7 @@ def api_mission_config_set():
|
|||||||
gs.mission_config["cols"] = cols
|
gs.mission_config["cols"] = cols
|
||||||
gs.mission_config["grid"] = grid
|
gs.mission_config["grid"] = grid
|
||||||
gs.mission_config["arm_initial_pose"] = arm_initial_pose
|
gs.mission_config["arm_initial_pose"] = arm_initial_pose
|
||||||
# 清除超出网格边界的 positions(只保留 front/back 且 row<=rows, col<cols)
|
# 点位数据始终保留,不随网格变更删除
|
||||||
gs.mission_config["positions"] = [
|
|
||||||
p for p in gs.mission_config.get("positions", [])
|
|
||||||
if p.get("row", 0) <= rows and p.get("col", 0) < cols and p.get("side") in ("front", "back")
|
|
||||||
]
|
|
||||||
save_json("mission_config.json", gs.mission_config)
|
save_json("mission_config.json", gs.mission_config)
|
||||||
return jsonify({"ok": True, "config": gs.mission_config})
|
return jsonify({"ok": True, "config": gs.mission_config})
|
||||||
|
|
||||||
@@ -1070,33 +1088,25 @@ def api_camera_preview():
|
|||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
||||||
return "camera not opened", 400
|
return "camera not opened", 400
|
||||||
|
|
||||||
|
import time as _time
|
||||||
def gen():
|
def gen():
|
||||||
|
_last_ok = _time.time()
|
||||||
while True:
|
while True:
|
||||||
frame = gs.qr_scanner.read_frame()
|
frame = gs.qr_scanner.read_frame()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
break
|
if _time.time() - _last_ok > 5:
|
||||||
# 编码为 JPEG
|
break
|
||||||
|
_time.sleep(0.05)
|
||||||
|
continue
|
||||||
import cv2
|
import cv2
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
if ret:
|
if ret:
|
||||||
|
_last_ok = _time.time()
|
||||||
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
||||||
buf.tobytes() + b"\r\n")
|
buf.tobytes() + b"\r\n")
|
||||||
|
|
||||||
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
@app.route("/api/camera/refresh")
|
|
||||||
def api_camera_refresh():
|
|
||||||
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
|
||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
|
||||||
return "camera not opened", 400
|
|
||||||
import cv2
|
|
||||||
frame = gs.qr_scanner.read_frame()
|
|
||||||
if frame is None:
|
|
||||||
return "", 400
|
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
|
||||||
if ret:
|
|
||||||
return Response(buf.tobytes(), mimetype="image/jpeg")
|
|
||||||
return "encode failed", 500
|
|
||||||
|
|
||||||
@app.route("/api/camera/capture")
|
@app.route("/api/camera/capture")
|
||||||
def api_camera_capture():
|
def api_camera_capture():
|
||||||
@@ -1114,27 +1124,98 @@ def api_camera_capture():
|
|||||||
cv2.imwrite(photo_path, frame)
|
cv2.imwrite(photo_path, frame)
|
||||||
return jsonify({"ok": True, "path": photo_path})
|
return jsonify({"ok": True, "path": photo_path})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_corrupted_jpeg(jpeg_bytes: bytes) -> float:
|
||||||
|
"""检测 JPEG 是否为花屏帧。返回 0~1 的置信度 (1=确定花屏)。"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
arr = np.frombuffer(jpeg_bytes, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
return 1.0
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if h < 10 or w < 10:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||||
|
|
||||||
|
# 绿色条纹检测:HSV 中绿色 H=40~80
|
||||||
|
green_mask = cv2.inRange(hsv, (40, 30, 40), (80, 255, 255))
|
||||||
|
green_ratio = cv2.countNonZero(green_mask) / (h * w)
|
||||||
|
|
||||||
|
# 紫色/品红条纹
|
||||||
|
purple_mask = cv2.inRange(hsv, (130, 30, 40), (170, 255, 255))
|
||||||
|
purple_ratio = cv2.countNonZero(purple_mask) / (h * w)
|
||||||
|
|
||||||
|
if green_ratio > 0.80 or purple_ratio > 0.80:
|
||||||
|
return 0.95
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
row_stds = np.std(gray, axis=1)
|
||||||
|
col_stds = np.std(gray, axis=0)
|
||||||
|
|
||||||
|
row_std_of_stds = float(np.std(row_stds))
|
||||||
|
col_std_of_stds = float(np.std(col_stds))
|
||||||
|
|
||||||
|
if row_std_of_stds > 70 and col_std_of_stds > 30:
|
||||||
|
return 0.85
|
||||||
|
|
||||||
|
unique_colors = len(np.unique(img.reshape(-1, 3), axis=0))
|
||||||
|
if unique_colors < 200:
|
||||||
|
return 0.75
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
except ImportError:
|
||||||
|
return 0.0
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_refresh")
|
@app.route("/api/camera/arm_refresh")
|
||||||
def api_arm_camera_refresh():
|
def api_arm_camera_refresh():
|
||||||
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET)"""
|
"""从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
import cv2
|
||||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
import numpy as np
|
||||||
if r.status_code == 200 and r.content:
|
url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
|
||||||
resp = Response(r.content, mimetype="image/jpeg")
|
max_retries = 3
|
||||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
for attempt in range(1, max_retries + 1):
|
||||||
resp.headers["Pragma"] = "no-cache"
|
try:
|
||||||
resp.headers["Expires"] = "0"
|
r = requests.get(url, timeout=8)
|
||||||
return resp
|
if r.status_code == 200 and r.content:
|
||||||
return "", 404
|
corruption = _is_corrupted_jpeg(r.content)
|
||||||
except Exception as ex:
|
if corruption > 0.5:
|
||||||
logger.info(f"arm_refresh 不可用: {ex}")
|
logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
|
||||||
return "", 404
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
# 解码 → 上下翻转 → 编码
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is not None:
|
||||||
|
img = cv2.flip(img, 0) # 0 = 上下翻转
|
||||||
|
ret, jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if ret:
|
||||||
|
resp = Response(jpg.tobytes(), mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
resp = Response(r.content, mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
return "", 404
|
||||||
|
except Exception as ex:
|
||||||
|
logger.info(f"arm_refresh 尝试{attempt}/{max_retries} 失败: {ex}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
logger.warning(f"arm_refresh 在 {max_retries} 次尝试后仍失败")
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_preview")
|
@app.route("/api/camera/arm_preview")
|
||||||
def api_arm_camera_preview():
|
def api_arm_camera_preview():
|
||||||
"""代理机械臂 MJPEG 视频流,供页面连续预览。"""
|
"""代理机械臂 MJPEG 视频流,直透返回(不翻转)。"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
try:
|
||||||
upstream = requests.get(
|
upstream = requests.get(
|
||||||
@@ -1147,10 +1228,23 @@ def api_arm_camera_preview():
|
|||||||
return "", 404
|
return "", 404
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
buf = b""
|
||||||
try:
|
try:
|
||||||
for chunk in upstream.iter_content(chunk_size=8192):
|
for chunk in upstream.iter_content(chunk_size=8192):
|
||||||
if chunk:
|
if not chunk:
|
||||||
yield chunk
|
continue
|
||||||
|
buf += chunk
|
||||||
|
# 查找 MJPEG 帧边界
|
||||||
|
while True:
|
||||||
|
start = buf.find(b"\xff\xd8")
|
||||||
|
end = buf.find(b"\xff\xd9")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
jpg_data = buf[start:end+2]
|
||||||
|
buf = buf[end+2:]
|
||||||
|
# 直透原始帧(不翻转)
|
||||||
|
yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpg_data + b"\r\n"
|
||||||
|
else:
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
upstream.close()
|
upstream.close()
|
||||||
|
|
||||||
@@ -1179,7 +1273,7 @@ def api_agv_move():
|
|||||||
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
|
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
|
||||||
data = request.json
|
data = request.json
|
||||||
direction = data.get("direction", "stop") # forward / backward / left / right / stop
|
direction = data.get("direction", "stop") # forward / backward / left / right / stop
|
||||||
speed = data.get("speed", AGV_CONFIG.get("move_speed", 0.5))
|
speed = data.get("speed", AGV_CONFIG.get("move_speed", 1.0))
|
||||||
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
if not gs.agv_controller or not gs.agv_controller.is_connected():
|
||||||
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
|
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
|
||||||
try:
|
try:
|
||||||
@@ -1242,6 +1336,11 @@ def api_agv_reset():
|
|||||||
def api_mission_start():
|
def api_mission_start():
|
||||||
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
|
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
|
# 必须先设置报关单(开始查验)
|
||||||
|
if not gs.inspection:
|
||||||
|
return jsonify({"ok": False, "error": "请先在「设置→报关单」中选择报关单并点击「开始查验」"}), 400
|
||||||
|
|
||||||
single_step = bool(data.get("single_step", False))
|
single_step = bool(data.get("single_step", False))
|
||||||
# 任务步骤控制开关
|
# 任务步骤控制开关
|
||||||
options = {
|
options = {
|
||||||
@@ -1250,8 +1349,8 @@ def api_mission_start():
|
|||||||
"qr_scan": bool(data.get("qr_scan", True)),
|
"qr_scan": bool(data.get("qr_scan", True)),
|
||||||
"front_photo": bool(data.get("front_photo", True)),
|
"front_photo": bool(data.get("front_photo", True)),
|
||||||
"back_photo": bool(data.get("back_photo", True)),
|
"back_photo": bool(data.get("back_photo", True)),
|
||||||
"agv_speed": float(data.get("agv_speed", 0.5)),
|
"agv_speed": float(data.get("agv_speed", 1.0)),
|
||||||
"arm_speed": int(data.get("arm_speed", 500)),
|
"arm_speed": int(data.get("arm_speed", 1000)),
|
||||||
}
|
}
|
||||||
print(f"[Mission] options: {options}")
|
print(f"[Mission] options: {options}")
|
||||||
|
|
||||||
@@ -1436,6 +1535,18 @@ def api_mission_state():
|
|||||||
result["waiting_step"] = False
|
result["waiting_step"] = False
|
||||||
result["waiting_error"] = False
|
result["waiting_error"] = False
|
||||||
|
|
||||||
|
# 查验状态
|
||||||
|
result["inspection"] = gs.inspection
|
||||||
|
|
||||||
|
# QR 输入弹窗消息
|
||||||
|
if hasattr(MissionExecutorV3, "_instance") and MissionExecutorV3._instance:
|
||||||
|
rpt = MissionExecutorV3._instance.report
|
||||||
|
result["qr_message"] = rpt.get("qr_message", "")
|
||||||
|
result["step_label"] = rpt.get("step_label", "")
|
||||||
|
else:
|
||||||
|
result["qr_message"] = ""
|
||||||
|
result["step_label"] = ""
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@app.route("/api/mission/log", methods=["GET"])
|
@app.route("/api/mission/log", methods=["GET"])
|
||||||
@@ -1574,31 +1685,61 @@ def api_qr_read_angles(qr_id):
|
|||||||
|
|
||||||
@app.route("/api/qr/scan/<qr_id>", methods=["POST"])
|
@app.route("/api/qr/scan/<qr_id>", methods=["POST"])
|
||||||
def api_qr_config_scan(qr_id):
|
def api_qr_config_scan(qr_id):
|
||||||
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项"""
|
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项(pyzbar 优先,OpenCV 兜底)"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
try:
|
||||||
|
jpg_bytes = None
|
||||||
|
# 多次尝试获取清晰帧
|
||||||
|
for _ in range(3):
|
||||||
|
try:
|
||||||
|
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
||||||
|
if r.status_code == 200 and r.content:
|
||||||
|
jpg_bytes = r.content
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
import time; time.sleep(0.3)
|
||||||
|
|
||||||
|
if jpg_bytes is None:
|
||||||
|
return jsonify({"ok": False, "error": "无法连接机械臂摄像头"}), 400
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
# 从机械臂摄像头 snapshot 端点拉取一帧 JPEG
|
|
||||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
|
||||||
if r.status_code != 200 or not r.content:
|
|
||||||
return jsonify({"ok": False, "error": "无法连接机械臂摄像头"}), 400
|
|
||||||
jpg_bytes = r.content
|
|
||||||
# 解码为 numpy 数组并检测二维码
|
|
||||||
nparr = np.frombuffer(jpg_bytes, np.uint8)
|
nparr = np.frombuffer(jpg_bytes, np.uint8)
|
||||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
return jsonify({"ok": False, "error": "图像解码失败"}), 400
|
return jsonify({"ok": False, "error": "图像解码失败"}), 400
|
||||||
# 使用 OpenCV QRCodeDetector 检测
|
|
||||||
detector = cv2.QRCodeDetector()
|
result = None
|
||||||
result, _, _ = detector.detectAndDecode(frame)
|
|
||||||
if result and len(result.strip()) > 0:
|
# 方法1: pyzbar(识别率更高)
|
||||||
result = result.strip()
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
from pyzbar.pyzbar import decode as pyzbar_decode
|
||||||
|
pil_img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
||||||
|
codes = pyzbar_decode(pil_img)
|
||||||
|
if codes and codes[0].data:
|
||||||
|
result = codes[0].data.decode("utf-8").strip()
|
||||||
|
logger.info(f"pyzbar 扫码成功: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"pyzbar 扫码失败: {e}")
|
||||||
|
|
||||||
|
# 方法2: OpenCV QRCodeDetector(兜底)
|
||||||
|
if not result:
|
||||||
|
try:
|
||||||
|
detector = cv2.QRCodeDetector()
|
||||||
|
val, _, _ = detector.detectAndDecode(frame)
|
||||||
|
if val and len(val.strip()) > 0:
|
||||||
|
result = val.strip()
|
||||||
|
logger.info(f"OpenCV 扫码成功: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"OpenCV 扫码失败: {e}")
|
||||||
|
|
||||||
|
if result:
|
||||||
# 保存到配置项
|
# 保存到配置项
|
||||||
for entry in gs.qr_config:
|
for entry in gs.qr_config:
|
||||||
if entry["id"] == qr_id:
|
if entry["id"] == qr_id:
|
||||||
entry["qr_value"] = result
|
entry["qr_value"] = result
|
||||||
# 尝试匹配机型
|
|
||||||
matched_model = None
|
matched_model = None
|
||||||
for model in gs.models_config:
|
for model in gs.models_config:
|
||||||
prefix = model.get("serial_prefix", "")
|
prefix = model.get("serial_prefix", "")
|
||||||
@@ -1615,19 +1756,353 @@ def api_qr_config_scan(qr_id):
|
|||||||
"model_name": matched_model["name"] if matched_model else ""
|
"model_name": matched_model["name"] if matched_model else ""
|
||||||
})
|
})
|
||||||
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
|
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
|
||||||
else:
|
|
||||||
return jsonify({"ok": False, "error": "未检测到二维码"})
|
return jsonify({"ok": False, "error": "未检测到二维码,请调整机械臂姿态或手动输入"})
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"QR 扫描机械臂摄像头失败: {ex}")
|
logger.error(f"QR 扫描失败: {ex}")
|
||||||
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 环境切换 API ==========
|
||||||
|
|
||||||
|
@app.route("/api/config/mode", methods=["GET"])
|
||||||
|
def api_config_mode_get():
|
||||||
|
"""获取当前 API 环境模式"""
|
||||||
|
import config
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"test_mode": config.TEST_MODE,
|
||||||
|
"base_url": config.ZHIJIAN_BASE_URL,
|
||||||
|
"label": "测试环境" if config.TEST_MODE else "正式环境"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/config/mode", methods=["POST"])
|
||||||
|
def api_config_mode_set():
|
||||||
|
"""切换 API 环境"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
test_mode = body.get("test_mode", True)
|
||||||
|
set_api_mode(test_mode)
|
||||||
|
import config
|
||||||
|
logger.info(f"API 环境已切换为: {'测试' if test_mode else '正式'} → {config.ZHIJIAN_BASE_URL}")
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"test_mode": config.TEST_MODE,
|
||||||
|
"base_url": config.ZHIJIAN_BASE_URL,
|
||||||
|
"label": "测试环境" if config.TEST_MODE else "正式环境"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 报关单接口(代理外部 API) ==========
|
||||||
|
|
||||||
|
def _get_zhijian_base():
|
||||||
|
"""动态获取报关单 API base,跟随环境切换"""
|
||||||
|
import config
|
||||||
|
base = f"{config.ZHIJIAN_BASE_URL}{config.API_PREFIX}/zhijian/integration"
|
||||||
|
return base
|
||||||
|
|
||||||
|
_ZHIJIAN_AUTH = ZHIJIAN_AUTH_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/list")
|
||||||
|
def api_customs_list():
|
||||||
|
"""获取报关单列表(代理外部 API)"""
|
||||||
|
import requests
|
||||||
|
page = request.args.get("pageNum", 1)
|
||||||
|
size = request.args.get("pageSize", 50)
|
||||||
|
url = f"{_get_zhijian_base()}/customsListPage?pageNum={page}&pageSize={size}"
|
||||||
|
logger.info(f"[customs/list] 🔍 请求 → {url}")
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[customs/list] 📡 响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[customs/list] ⚠️ 返回非200: {r.status_code}")
|
||||||
|
return jsonify({"ok": False, "error": f"报关单API返回 {r.status_code}"}), 502
|
||||||
|
data = r.json()
|
||||||
|
# 兼容不同返回格式:裸数组 / {data:{rows:[...]}} / {rows:[...]}
|
||||||
|
if isinstance(data, list):
|
||||||
|
rows = data
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
rows = data.get("data", {}).get("rows", []) or data.get("rows", [])
|
||||||
|
else:
|
||||||
|
rows = []
|
||||||
|
logger.info(f"[customs/list] ✅ 获取到 {len(rows)} 条报关单")
|
||||||
|
return jsonify({"ok": True, "data": {"rows": rows}})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[customs/list] ❌ 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/machines")
|
||||||
|
def api_customs_machines():
|
||||||
|
"""根据报关单 ID 获取机器列表(代理外部 API)
|
||||||
|
数据源:cjt_customs_item 表 → Java customsMachines 接口
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
customs_id = request.args.get("customsId", "")
|
||||||
|
logger.info(f"[customs/machines] 📥 customsId={customs_id}")
|
||||||
|
if not customs_id:
|
||||||
|
logger.warning("[customs/machines] ⚠️ 缺少 customsId")
|
||||||
|
return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{_get_zhijian_base()}/customsMachines?customsId={customs_id}"
|
||||||
|
logger.info(f"[customs/machines] 🔍 请求 → {url}")
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[customs/machines] 📡 响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[customs/machines] ⚠️ 返回非200: {r.status_code}, body={r.text[:300]}")
|
||||||
|
return jsonify({"ok": False, "error": "机器列表API返回非200"}), 502
|
||||||
|
result = r.json()
|
||||||
|
machines = result.get("data") or []
|
||||||
|
logger.info(f"[customs/machines] ✅ 获取到 {len(machines)} 条机器记录")
|
||||||
|
return jsonify({"ok": True, "data": result})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[customs/machines] ❌ 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/selected", methods=["POST"])
|
||||||
|
def api_customs_selected():
|
||||||
|
"""任务开始前设定当前报关单(存储当前任务对应的报关单信息)"""
|
||||||
|
data = request.json or {}
|
||||||
|
gs.current_customs = {
|
||||||
|
"id": data.get("id", ""),
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
"machine_ids": data.get("machine_ids", []),
|
||||||
|
}
|
||||||
|
logger.info(f"设定报关单: {gs.current_customs['name']} ({len(gs.current_customs['machine_ids'])} 台机器)")
|
||||||
|
return jsonify({"ok": True, "customs": gs.current_customs})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/selected", methods=["GET"])
|
||||||
|
def api_customs_selected_get():
|
||||||
|
"""获取当前设定的报关单信息"""
|
||||||
|
c = gs.current_customs or {"id": "", "name": "未选择", "machine_ids": []}
|
||||||
|
return jsonify({"ok": True, "customs": c})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 查验 API ==========
|
||||||
|
@app.route("/api/customs/inspection/start", methods=["POST"])
|
||||||
|
def api_customs_inspection_start():
|
||||||
|
"""开始查验:加载报关单机器列表,初始化查验计数"""
|
||||||
|
data = request.json or {}
|
||||||
|
customs_id = data.get("customsId", "")
|
||||||
|
if not customs_id:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 customsId"}), 400
|
||||||
|
|
||||||
|
# 获取报关单机器列表
|
||||||
|
try:
|
||||||
|
machines_url = f"{_get_zhijian_base()}/customsMachines?customsId={customs_id}"
|
||||||
|
logger.info(f"[inspection/start] 🔍 获取机器列表 → {machines_url}")
|
||||||
|
r = requests.get(
|
||||||
|
machines_url,
|
||||||
|
headers={"Authorization": _ZHIJIAN_AUTH},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
logger.info(f"[inspection/start] 📡 机器列表响应 HTTP {r.status_code}, body长度={len(r.text)}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[inspection/start] ⚠️ 返回非200: {r.status_code}, body={r.text[:300]}")
|
||||||
|
return jsonify({"ok": False, "error": f"接口返回 {r.status_code}"}), 502
|
||||||
|
j = r.json()
|
||||||
|
machines = j.get("data") or []
|
||||||
|
logger.info(f"[inspection/start] ✅ 获取到 {len(machines)} 条机器记录")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[inspection/start] ❌ 获取机器列表失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
# 按 inventoryCode 聚合(同一物料可能有多条序列号记录)
|
||||||
|
items_dict = {}
|
||||||
|
for m in machines:
|
||||||
|
code = m.get("inventoryCode") or m.get("machineCode") or "unknown"
|
||||||
|
if code not in items_dict:
|
||||||
|
items_dict[code] = {
|
||||||
|
"inventoryCode": code,
|
||||||
|
"inventoryName": m.get("inventoryName") or m.get("machineName") or "-",
|
||||||
|
"spec": m.get("inventorySpecification") or m.get("spec") or "-",
|
||||||
|
"quantify": 0,
|
||||||
|
"inspected": 0,
|
||||||
|
}
|
||||||
|
# 累计数量(quantify 字段可能来自 customsMachines 的返回值)
|
||||||
|
q = m.get("quantify", 0)
|
||||||
|
if q:
|
||||||
|
items_dict[code]["quantify"] += int(float(q))
|
||||||
|
|
||||||
|
# 如果 quantify 全部为 0,用机器条目数作为数量
|
||||||
|
for item in items_dict.values():
|
||||||
|
if item["quantify"] == 0:
|
||||||
|
item["quantify"] = sum(1 for m in machines if (m.get("inventoryCode") or "") == item["inventoryCode"])
|
||||||
|
|
||||||
|
logger.info(f"[inspection/start] 📊 聚合结果: {len(items_dict)} 种机型, total {sum(i['quantify'] for i in items_dict.values())} 台")
|
||||||
|
|
||||||
|
# 获取报关单名称
|
||||||
|
customs_name = data.get("customsName") or customs_id
|
||||||
|
try:
|
||||||
|
list_url = f"{_get_zhijian_base()}/customsListPage?pageNum=1&pageSize=100"
|
||||||
|
logger.info(f"[inspection/start] 🔍 获取报关单名称 → {list_url}")
|
||||||
|
r2 = requests.get(
|
||||||
|
list_url,
|
||||||
|
headers={"Authorization": _ZHIJIAN_AUTH},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if r2.status_code == 200:
|
||||||
|
j2 = r2.json()
|
||||||
|
for row in j2.get("rows", []):
|
||||||
|
c = row.get("customs", {})
|
||||||
|
if str(c.get("id", "")) == str(customs_id):
|
||||||
|
customs_name = c.get("customsCode") or row.get("orderCode") or customs_name
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
gs.inspection = {
|
||||||
|
"customsId": customs_id,
|
||||||
|
"customsName": customs_name,
|
||||||
|
"items": list(items_dict.values()),
|
||||||
|
"startedAt": time.time(),
|
||||||
|
}
|
||||||
|
logger.info(f"开始查验: {customs_name} ({len(gs.inspection['items'])} 种机型,共 {sum(i['quantify'] for i in gs.inspection['items'])} 台)")
|
||||||
|
return jsonify({"ok": True, "inspection": gs.inspection})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection", methods=["GET"])
|
||||||
|
def api_customs_inspection():
|
||||||
|
"""获取当前查验状态"""
|
||||||
|
return jsonify({"ok": True, "inspection": gs.inspection})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection/end", methods=["POST"])
|
||||||
|
def api_customs_inspection_end():
|
||||||
|
"""结束查验"""
|
||||||
|
gs.inspection = None
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/printer", methods=["GET"])
|
||||||
|
def api_customs_printer():
|
||||||
|
"""代理 /zhijian/profile/printer 查询,同时更新查验计数
|
||||||
|
GET ?serialNumber=xxx
|
||||||
|
"""
|
||||||
|
sn = request.args.get("serialNumber", "").strip()
|
||||||
|
logger.info(f"[printer] 📥 收到查询请求 serialNumber={sn}")
|
||||||
|
if not sn:
|
||||||
|
logger.warning("[printer] ⚠️ 缺少 serialNumber 参数")
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serialNumber"}), 400
|
||||||
|
|
||||||
|
# 调用 Java profile/printer 接口
|
||||||
|
api_base = _get_zhijian_base().rstrip("/")
|
||||||
|
profile_url = f"{api_base[:api_base.rfind('/')]}/profile/printer?serialNumber={sn}"
|
||||||
|
logger.info(f"[printer] 🔍 请求 Java → {profile_url}")
|
||||||
|
try:
|
||||||
|
r = requests.get(profile_url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
logger.info(f"[printer] 📡 Java响应 HTTP {r.status_code}: {r.text[:500]}")
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning(f"[printer] ⚠️ Java返回非200: {r.status_code}")
|
||||||
|
return jsonify({"ok": False, "error": f"profile/printer 返回 {r.status_code}"}), 502
|
||||||
|
j = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[printer] ❌ 查询 Java printer 失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
data = j.get("data", j)
|
||||||
|
printer = data.get("printer")
|
||||||
|
order_item = data.get("orderItem")
|
||||||
|
logger.info(f"[printer] 📊 Java返回解析: printer={'yes' if printer else 'no'}, orderItem={'yes' if order_item else 'no'}")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ok": True,
|
||||||
|
"printer": printer,
|
||||||
|
"orderItem": order_item,
|
||||||
|
"modelName": "机器1", # 默认
|
||||||
|
"inventoryCode": None,
|
||||||
|
"matchedItem": None,
|
||||||
|
"hasInspection": gs.inspection is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取 inventory 信息(优先级: orderItem.inventory > printer.inventory > printer.model/machineModel)
|
||||||
|
if order_item and order_item.get("inventory"):
|
||||||
|
inv = order_item["inventory"]
|
||||||
|
result["modelName"] = inv.get("inventoryName") or inv.get("name") or "机器1"
|
||||||
|
result["inventoryCode"] = inv.get("inventoryCode") or inv.get("code")
|
||||||
|
logger.info(f"[printer] 🏷️ 从 orderItem.inventory 提取: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}")
|
||||||
|
elif printer and printer.get("inventory"):
|
||||||
|
inv = printer["inventory"]
|
||||||
|
result["modelName"] = inv.get("inventoryName") or inv.get("name") or "机器1"
|
||||||
|
result["inventoryCode"] = inv.get("inventoryCode") or inv.get("code")
|
||||||
|
logger.info(f"[printer] 🏷️ 从 printer.inventory 提取: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}")
|
||||||
|
elif printer:
|
||||||
|
result["modelName"] = printer.get("model") or printer.get("machineModel") or "机器1"
|
||||||
|
logger.info(f"[printer] 🏷️ 从 printer.model/machineModel 提取: modelName={result['modelName']}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ printer 和 orderItem 均为空,回退 modelName=机器1")
|
||||||
|
|
||||||
|
# 更新查验计数(先检查是否超量,超量时不增加计数)
|
||||||
|
if gs.inspection and result["inventoryCode"]:
|
||||||
|
for item in gs.inspection["items"]:
|
||||||
|
if item["inventoryCode"] == result["inventoryCode"]:
|
||||||
|
# 先返回当前值(未+1),让调用方判断是否超量
|
||||||
|
result["matchedItem"] = item
|
||||||
|
# 只有未超量时才真正+1
|
||||||
|
if item["inspected"] < item["quantify"]:
|
||||||
|
item["inspected"] += 1
|
||||||
|
logger.info(f"[printer] ✅ 查验计数更新: {item['inventoryName']} → {item['inspected']}/{item['quantify']}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ 超量不计数: {item['inventoryName']} 已达 {item['inspected']}/{item['quantify']}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"[printer] ⚠️ inventoryCode={result['inventoryCode']} 不在查验清单中")
|
||||||
|
logger.info(f"[printer] 📤 返回结果: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}, hasInspection={result['hasInspection']}, matched={'yes' if result['matchedItem'] else 'no'}")
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/inspection/update", methods=["POST"])
|
||||||
|
def api_customs_inspection_update():
|
||||||
|
"""直接更新查验计数(由执行器调用)"""
|
||||||
|
data = request.json or {}
|
||||||
|
code = data.get("inventoryCode", "")
|
||||||
|
if not gs.inspection or not code:
|
||||||
|
return jsonify({"ok": False})
|
||||||
|
for item in gs.inspection["items"]:
|
||||||
|
if item["inventoryCode"] == code:
|
||||||
|
item["inspected"] += 1
|
||||||
|
return jsonify({"ok": True, "item": item})
|
||||||
|
return jsonify({"ok": False, "error": "未匹配"})
|
||||||
|
|
||||||
|
|
||||||
# ========== 静态资源 ==========
|
# ========== 静态资源 ==========
|
||||||
@app.route("/photos/<name>")
|
@app.route("/photos/<name>")
|
||||||
def photos(name):
|
def photos(name):
|
||||||
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/refresh")
|
||||||
|
def api_camera_refresh():
|
||||||
|
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
||||||
|
if not gs.qr_scanner:
|
||||||
|
return jsonify({"error": "scanner not initialized"}), 400
|
||||||
|
if not gs.qr_scanner._cap or not gs.qr_scanner._cap.isOpened():
|
||||||
|
return jsonify({"error": "camera not opened"}), 400
|
||||||
|
import cv2
|
||||||
|
frame = gs.qr_scanner.read_frame()
|
||||||
|
if frame is None:
|
||||||
|
return jsonify({"error": "read frame failed"}), 400
|
||||||
|
# 检查是否为全黑/无内容的帧(Orbbec 深度/IR 帧可能无内容)
|
||||||
|
if frame.mean() < 5:
|
||||||
|
return jsonify({"error": "camera sensor not ready"}), 400
|
||||||
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
|
if ret:
|
||||||
|
return Response(buf.tobytes(), mimetype="image/jpeg")
|
||||||
|
return jsonify({"error": "encode failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/capabilities")
|
||||||
|
def api_camera_capabilities():
|
||||||
|
"""返回摄像头能力信息,前端据此决定如何展示"""
|
||||||
|
return jsonify({
|
||||||
|
"has_agv_camera": False, # Orbbec 深度相机不提供可用的彩色画面
|
||||||
|
"has_arm_camera": True,
|
||||||
|
})
|
||||||
# ========== 启动 ==========
|
# ========== 启动 ==========
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
@@ -1654,3 +2129,4 @@ if __name__ == "__main__":
|
|||||||
debug=SERVER_CONFIG["debug"],
|
debug=SERVER_CONFIG["debug"],
|
||||||
threaded=True
|
threaded=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+28
-7
@@ -7,15 +7,15 @@ import os
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||||
AGV_HOST = "192.168.60.177"
|
AGV_HOST = "192.168.60.80"
|
||||||
ARM_HOST = "192.168.60.88"
|
ARM_HOST = "192.168.60.120"
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
# ========== AGV 参数 ==========
|
||||||
AGV_CONFIG = {
|
AGV_CONFIG = {
|
||||||
"device": "/dev/agvpro_controller",
|
"device": "/dev/agvpro_controller",
|
||||||
"baudrate": 10000000,
|
"baudrate": 10000000,
|
||||||
"move_speed": 0.5,
|
"move_speed": 1.0,
|
||||||
"turn_speed": 0.5,
|
"turn_speed": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== 机械臂 TCP 客户端 ==========
|
# ========== 机械臂 TCP 客户端 ==========
|
||||||
@@ -35,7 +35,7 @@ MAP_CONFIG = {
|
|||||||
|
|
||||||
# ========== 摄像头 ==========
|
# ========== 摄像头 ==========
|
||||||
CAMERA_CONFIG = {
|
CAMERA_CONFIG = {
|
||||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
"device_index": 4, # AGV 摄像头 video4(Orbbec Gemini 彩色流,YUYV 640x480)
|
||||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||||
"qr_detect_interval": 0.5,
|
"qr_detect_interval": 0.5,
|
||||||
"capture_delay": 0.5,
|
"capture_delay": 0.5,
|
||||||
@@ -47,9 +47,30 @@ ARM_CAMERA_CONFIG = {
|
|||||||
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ========== 外部 API 环境 ==========
|
||||||
|
# 切换测试/正式环境只需改 TEST_MODE 一个变量
|
||||||
|
TEST_MODE = False # True=测试环境(192.168.60.159), False=正式环境(ts.zhijian168.com)
|
||||||
|
PROD_BASE_URL = "https://ts.zhijian168.com"
|
||||||
|
TEST_BASE_URL = "http://192.168.60.159:8080"
|
||||||
|
PROD_API_PREFIX = "/prod-api"
|
||||||
|
TEST_API_PREFIX = "" # 测试服务器无 /prod-api 网关前缀
|
||||||
|
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
|
||||||
|
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
|
||||||
|
ZHIJIAN_AUTH_TOKEN = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
||||||
|
|
||||||
|
|
||||||
|
def set_api_mode(test_mode):
|
||||||
|
"""运行时切换 API 环境 — 无需重启 Flask"""
|
||||||
|
global TEST_MODE, ZHIJIAN_BASE_URL, API_PREFIX, UPLOAD_CONFIG
|
||||||
|
TEST_MODE = bool(test_mode)
|
||||||
|
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
|
||||||
|
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
|
||||||
|
UPLOAD_CONFIG["url"] = f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage"
|
||||||
|
|
||||||
|
|
||||||
# ========== HTTP 上传 ==========
|
# ========== HTTP 上传 ==========
|
||||||
UPLOAD_CONFIG = {
|
UPLOAD_CONFIG = {
|
||||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
"url": f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
}
|
}
|
||||||
@@ -77,7 +98,7 @@ JOINT_LIMITS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ========== 机械臂默认速度 ==========
|
# ========== 机械臂默认速度 ==========
|
||||||
DEFAULT_ARM_SPEED = 500
|
DEFAULT_ARM_SPEED = 1000
|
||||||
|
|
||||||
# ========== 状态定义 ==========
|
# ========== 状态定义 ==========
|
||||||
class State:
|
class State:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "qr_1779278140334",
|
||||||
|
"name": "二维码1",
|
||||||
|
"joint_angles": [
|
||||||
|
-89.796645,
|
||||||
|
-2.013175,
|
||||||
|
-87.176721,
|
||||||
|
-82.49663,
|
||||||
|
-93.323403,
|
||||||
|
20.399941
|
||||||
|
],
|
||||||
|
"qr_value": "BG042110276",
|
||||||
|
"model_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qr_1779286233426",
|
||||||
|
"name": "左侧二维码",
|
||||||
|
"joint_angles": [
|
||||||
|
-70.967019,
|
||||||
|
-19.319962,
|
||||||
|
-67.929797,
|
||||||
|
-90.749908,
|
||||||
|
-121.735483,
|
||||||
|
20.399961
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "qr_1779954274845",
|
||||||
|
"name": "右侧二维码",
|
||||||
|
"joint_angles": [
|
||||||
|
-106.216678,
|
||||||
|
35.346758,
|
||||||
|
-134.01322,
|
||||||
|
-79.250251,
|
||||||
|
-84.069984,
|
||||||
|
21.982971
|
||||||
|
],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""修复激光雷达时间戳偏移的修正器 v5"""
|
|
||||||
import os, sys, rclpy
|
|
||||||
from rclpy.node import Node
|
|
||||||
from sensor_msgs.msg import LaserScan
|
|
||||||
from builtin_interfaces.msg import Time
|
|
||||||
|
|
||||||
LOCKFILE = "/tmp/scan_fixer.lock"
|
|
||||||
|
|
||||||
if os.path.exists(LOCKFILE):
|
|
||||||
with open(LOCKFILE) as f:
|
|
||||||
old_pid = int(f.read().strip())
|
|
||||||
try:
|
|
||||||
os.kill(old_pid, 0)
|
|
||||||
print(f"Another fixer running PID {old_pid}, exit.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
except (OSError, ProcessLookupError):
|
|
||||||
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
|
|
||||||
|
|
||||||
with open(LOCKFILE, "w") as f:
|
|
||||||
f.write(str(os.getpid()))
|
|
||||||
|
|
||||||
def main():
|
|
||||||
rclpy.init(args=sys.argv[1:])
|
|
||||||
node = Node('scan_timestamp_fixer')
|
|
||||||
offset = 2.0
|
|
||||||
pub = node.create_publisher(LaserScan, '/scan_corrected', 10)
|
|
||||||
count = [0]
|
|
||||||
|
|
||||||
def cb(msg: LaserScan):
|
|
||||||
count[0] += 1
|
|
||||||
s, ns = msg.header.stamp.sec, msg.header.stamp.nanosec
|
|
||||||
s2 = s - int(offset)
|
|
||||||
ns2 = ns - int((offset % 1) * 1e9)
|
|
||||||
if ns2 < 0:
|
|
||||||
ns2 += 1000000000
|
|
||||||
s2 -= 1
|
|
||||||
out = LaserScan()
|
|
||||||
out.header.frame_id = msg.header.frame_id
|
|
||||||
out.header.stamp = Time(sec=s2, nanosec=ns2)
|
|
||||||
out.angle_min = msg.angle_min
|
|
||||||
out.angle_max = msg.angle_max
|
|
||||||
out.angle_increment = msg.angle_increment
|
|
||||||
out.time_increment = msg.time_increment
|
|
||||||
out.scan_time = msg.scan_time
|
|
||||||
out.range_min = msg.range_min
|
|
||||||
out.range_max = msg.range_max
|
|
||||||
out.ranges = msg.ranges
|
|
||||||
out.intensities = msg.intensities
|
|
||||||
pub.publish(out)
|
|
||||||
if count[0] % 200 == 0:
|
|
||||||
node.get_logger().info(f'#{count[0]} /scan={s} -> /scan_corrected={s2}')
|
|
||||||
|
|
||||||
node.create_subscription(LaserScan, '/scan', cb, 10)
|
|
||||||
node.get_logger().info(f'Fixer PID={os.getpid()}, offset={offset}s')
|
|
||||||
|
|
||||||
try:
|
|
||||||
while rclpy.ok():
|
|
||||||
rclpy.spin_once(node, timeout_sec=0.5)
|
|
||||||
finally:
|
|
||||||
node.destroy_node()
|
|
||||||
rclpy.shutdown()
|
|
||||||
if os.path.exists(LOCKFILE):
|
|
||||||
os.unlink(LOCKFILE)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
source /opt/ros/humble/setup.bash
|
|
||||||
source /home/elephant/agv_pro_ros2/install/setup.bash
|
|
||||||
export ROS_DOMAIN_ID=1
|
|
||||||
cd /home/elephant/agv_pro_ros2
|
|
||||||
nohup ros2 daemon start >/dev/null 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
|
|
||||||
sleep 8
|
|
||||||
nohup python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py > /tmp/scan_fixer.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_navigation2 navigation2_active.launch.py autostart:=True > /tmp/ros2_nav2.log 2>&1 &
|
|
||||||
sleep 15
|
|
||||||
cd /home/elephant/work/agv_app && nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
echo "ALL_STARTED"
|
|
||||||
ps aux | grep -E 'lslidar|agv_pro_node|nav2_container|scan_timestamp_fixer|ros2-daemon|app.py' | grep -v grep
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 启动 AGV 拍摄系统
|
|
||||||
|
|
||||||
cd ~/work/agv_app
|
|
||||||
python3 app.py
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
source /opt/ros/humble/setup.bash
|
|
||||||
source /home/elephant/agv_pro_ros2/install/setup.bash
|
|
||||||
export ROS_DOMAIN_ID=1
|
|
||||||
cd /home/elephant/agv_pro_ros2
|
|
||||||
nohup ros2 daemon start >/dev/null 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller > /tmp/ros2_bringup.log 2>&1 &
|
|
||||||
sleep 8
|
|
||||||
nohup python3 /home/elephant/work/scan_fixer/fix_scan_timestamp.py > /tmp/scan_fixer.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
nohup ros2 launch agv_pro_navigation2 navigation2_active.launch.py autostart:=True > /tmp/ros2_nav2.log 2>&1 &
|
|
||||||
sleep 15
|
|
||||||
cd /home/elephant/work/agv_app && nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
|
||||||
sleep 5
|
|
||||||
echo "ALL_STARTED"
|
|
||||||
ps aux | grep -E 'lslidar|agv_pro_node|nav2_container|scan_timestamp_fixer|ros2-daemon|app.py' | grep -v grep
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/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: $!"
|
|
||||||
@@ -59,6 +59,49 @@ a:hover { text-decoration: underline; }
|
|||||||
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
||||||
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
.status-item.idle { background: #2a2a2a; color: #9aa0a6; }
|
||||||
|
|
||||||
|
/* ========== 环境切换开关 ========== */
|
||||||
|
.env-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.env-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.env-label.test { color: #ff9800; }
|
||||||
|
.env-label.prod { color: #4fc3f7; }
|
||||||
|
.toggle-switch {
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-radius: 11px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.25s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch.active {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
.toggle-knob {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: left 0.25s;
|
||||||
|
}
|
||||||
|
.toggle-switch.active .toggle-knob {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 卡片 ========== */
|
/* ========== 卡片 ========== */
|
||||||
.card {
|
.card {
|
||||||
background: #1a2332;
|
background: #1a2332;
|
||||||
@@ -465,6 +508,10 @@ a:hover { text-decoration: underline; }
|
|||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.camera-img.arm {
|
||||||
|
/* no flip */
|
||||||
|
}
|
||||||
|
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -1057,10 +1104,10 @@ a:hover { text-decoration: underline; }
|
|||||||
@keyframes navPulse { 0%,100% { border-color: #4caf50; } 50% { border-color: #1b5e20; } }
|
@keyframes navPulse { 0%,100% { border-color: #4caf50; } 50% { border-color: #1b5e20; } }
|
||||||
|
|
||||||
/* 机器单元格状态 */
|
/* 机器单元格状态 */
|
||||||
.machine-cell { min-height: 62px; padding: 6px 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
|
.machine-cell { min-height: 62px; padding: 6px 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; overflow: hidden; }
|
||||||
.machine-label { font-size: 12px; font-weight: 600; color: #ccc; }
|
.machine-label { font-size: 12px; font-weight: 600; color: #ccc; }
|
||||||
.machine-steps-mini { display: flex; gap: 8px; font-size: 13px; }
|
.machine-steps-mini { display: flex; gap: 8px; font-size: 13px; }
|
||||||
.machine-qr-mini { font-size: 10px; color: #4fc3f7; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.machine-qr-mini { font-size: 10px; color: #4fc3f7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.empty-cell { color: #445566; font-size: 12px; }
|
.empty-cell { color: #445566; font-size: 12px; }
|
||||||
|
|
||||||
/* 步骤圆点 */
|
/* 步骤圆点 */
|
||||||
@@ -1076,3 +1123,185 @@ a:hover { text-decoration: underline; }
|
|||||||
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
||||||
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
||||||
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
||||||
|
|
||||||
|
/* ===== 报关单选择 ===== */
|
||||||
|
.customs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.customs-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.customs-select {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1a2535;
|
||||||
|
border: 1px solid #2a3a4a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.customs-select:focus {
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.customs-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.customs-select option {
|
||||||
|
background: #1a2535;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.customs-select:disabled option {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.customs-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8899aa;
|
||||||
|
}
|
||||||
|
.customs-badge {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.customs-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 数据表格 ===== */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f1923;
|
||||||
|
color: #8899aa;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #2a3a4a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a2a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: #1a2a3a;
|
||||||
|
}
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.clickable-row:hover {
|
||||||
|
background: #1a2535 !important;
|
||||||
|
}
|
||||||
|
.row-selected {
|
||||||
|
background: #142a3a !important;
|
||||||
|
border-left: 3px solid #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Badge 状态标签 ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-unknown { background: #2a3441; color: #8899aa; }
|
||||||
|
.badge-normal { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-active { background: #1a3050; color: #4fc3f7; }
|
||||||
|
.badge-finished { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-waiting { background: #3a3020; color: #ffc107; }
|
||||||
|
.badge-error { background: #3a1a1a; color: #f44336; }
|
||||||
|
|
||||||
|
/* ===== 分页控件 ===== */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 查验进度 ===== */
|
||||||
|
.inspection-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.inspection-item {
|
||||||
|
background: rgba(26, 26, 46, 0.7);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #2a2a3e;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.inspection-item.insp-done {
|
||||||
|
border-color: #4caf50;
|
||||||
|
background: rgba(76, 175, 80, 0.08);
|
||||||
|
}
|
||||||
|
.inspection-item.insp-active {
|
||||||
|
border-color: #ff9800;
|
||||||
|
background: rgba(255, 152, 0, 0.08);
|
||||||
|
}
|
||||||
|
.insp-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.insp-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8899aa;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.insp-spec {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667788;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.insp-count {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.insp-num {
|
||||||
|
color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.insp-sep {
|
||||||
|
color: #667788;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.insp-total {
|
||||||
|
color: #8899aa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.insp-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: #0a0a14;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.insp-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #4fc3f7, #4caf50);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ createApp({
|
|||||||
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
||||||
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
||||||
agvCameraError: false,
|
agvCameraError: false,
|
||||||
|
hasAgvCamera: false, // AGV 车体是否有可用相机
|
||||||
armCameraError: false,
|
armCameraError: false,
|
||||||
reconnectingDevice: null
|
reconnectingDevice: null,
|
||||||
|
// 环境切换
|
||||||
|
testMode: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -36,6 +39,8 @@ createApp({
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
|
this.refreshCameraCapabilities()
|
||||||
|
this.loadEnvMode()
|
||||||
setInterval(this.refreshStatus, 3000)
|
setInterval(this.refreshStatus, 3000)
|
||||||
this.refreshCams()
|
this.refreshCams()
|
||||||
setInterval(() => this.refreshCams(), 2000)
|
setInterval(() => this.refreshCams(), 2000)
|
||||||
@@ -47,6 +52,17 @@ createApp({
|
|||||||
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refreshCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
|
refreshAgvCamera() {
|
||||||
|
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now()
|
||||||
|
this.agvCameraError = false
|
||||||
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
await this.refreshStatus()
|
await this.refreshStatus()
|
||||||
await this.loadPoints()
|
await this.loadPoints()
|
||||||
@@ -58,6 +74,10 @@ createApp({
|
|||||||
this.agvConnected = data.agv_connected
|
this.agvConnected = data.agv_connected
|
||||||
this.armConnected = data.arm_connected
|
this.armConnected = data.arm_connected
|
||||||
this.cameraOpened = data.camera_opened
|
this.cameraOpened = data.camera_opened
|
||||||
|
// 尝试从后端获取摄像头能力,若无字段则保持默认 false
|
||||||
|
if (data.has_agv_camera !== undefined) {
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
}
|
||||||
this.armCameraOpened = data.arm_camera_opened
|
this.armCameraOpened = data.arm_camera_opened
|
||||||
this.mapLoaded = data.map_loaded
|
this.mapLoaded = data.map_loaded
|
||||||
this.currentState = data.state || 'idle'
|
this.currentState = data.state || 'idle'
|
||||||
@@ -116,6 +136,36 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
window.location.href = '/running'
|
window.location.href = '/running'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
async loadEnvMode() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/config/mode')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
this.testMode = data.test_mode
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载环境配置失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async toggleEnvMode() {
|
||||||
|
const newMode = !this.testMode
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/config/mode', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({test_mode: newMode})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
this.testMode = data.test_mode
|
||||||
|
alert('已切换至: ' + data.label)
|
||||||
|
} else {
|
||||||
|
alert('切换失败: ' + (data.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('切换请求失败: ' + e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ createApp({
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
report: null,
|
report: null,
|
||||||
armCameraOpened: false,
|
armCameraOpened: false,
|
||||||
|
hasAgvCamera: false,
|
||||||
agvPreviewUrl: API + '/api/camera/preview',
|
agvPreviewUrl: API + '/api/camera/preview',
|
||||||
armPreviewUrl: '',
|
armPreviewUrl: '',
|
||||||
polling: null,
|
polling: null,
|
||||||
@@ -27,14 +28,17 @@ createApp({
|
|||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
waitingStep: false,
|
waitingStep: false,
|
||||||
stepLabel: '',
|
stepLabel: '',
|
||||||
|
qrMessage: '所有姿态均未识别到二维码,请手动输入:',
|
||||||
// 任务步骤控制开关(机械臂初始化并入AGV移动)
|
// 任务步骤控制开关(机械臂初始化并入AGV移动)
|
||||||
agvMoveEnabled: true,
|
agvMoveEnabled: true,
|
||||||
qrScanEnabled: true,
|
qrScanEnabled: true,
|
||||||
frontPhotoEnabled: true,
|
frontPhotoEnabled: true,
|
||||||
backPhotoEnabled: true,
|
backPhotoEnabled: true,
|
||||||
// 速度控制
|
// 速度控制
|
||||||
agvSpeed: 0.5,
|
agvSpeed: 1.0,
|
||||||
armSpeed: 500,
|
armSpeed: 1000,
|
||||||
|
// 查验
|
||||||
|
inspection: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -50,6 +54,14 @@ createApp({
|
|||||||
}
|
}
|
||||||
return map[this.missionState] || '未知'
|
return map[this.missionState] || '未知'
|
||||||
},
|
},
|
||||||
|
inspectionTotal() {
|
||||||
|
if (!this.inspection || !this.inspection.items) return 0
|
||||||
|
return this.inspection.items.reduce((s, i) => s + (i.inspected || 0), 0)
|
||||||
|
},
|
||||||
|
inspectionTarget() {
|
||||||
|
if (!this.inspection || !this.inspection.items) return 0
|
||||||
|
return this.inspection.items.reduce((s, i) => s + (i.quantify || 0), 0)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.poll()
|
this.poll()
|
||||||
@@ -63,13 +75,20 @@ createApp({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/api/status')
|
const res = await fetch(API + '/api/status')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!this.armCameraOpened) {
|
const opened = data.arm_camera_opened
|
||||||
this.armPreviewUrl = ''
|
if (opened !== this.armCameraOpened || (opened && !this.armPreviewUrl)) {
|
||||||
} else if (!this.armPreviewUrl) {
|
this.armCameraOpened = opened
|
||||||
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
this.armPreviewUrl = opened ? API + '/api/camera/arm_preview' : ''
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
|
async checkAgvCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
poll() {
|
poll() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
this.pollLogs()
|
this.pollLogs()
|
||||||
@@ -93,7 +112,11 @@ createApp({
|
|||||||
if (data.grid) this.missionGrid = data.grid
|
if (data.grid) this.missionGrid = data.grid
|
||||||
if (data.point_status) this.pointStatus = data.point_status
|
if (data.point_status) this.pointStatus = data.point_status
|
||||||
if (data.machine_status) this.machineStatus = data.machine_status
|
if (data.machine_status) this.machineStatus = data.machine_status
|
||||||
|
if (data.inspection) this.inspection = data.inspection
|
||||||
this.armCameraOpened = data.arm_camera_opened
|
this.armCameraOpened = data.arm_camera_opened
|
||||||
|
if (this.armCameraOpened && !this.armPreviewUrl) {
|
||||||
|
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
||||||
|
}
|
||||||
|
|
||||||
// 错误弹窗
|
// 错误弹窗
|
||||||
if (data.waiting_error) {
|
if (data.waiting_error) {
|
||||||
@@ -111,6 +134,11 @@ createApp({
|
|||||||
this.waitingStep = false
|
this.waitingStep = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QR 弹窗消息
|
||||||
|
if (data.qr_message) {
|
||||||
|
this.qrMessage = data.qr_message
|
||||||
|
}
|
||||||
|
|
||||||
// QR 弹窗(防止提交后重复弹出)
|
// QR 弹窗(防止提交后重复弹出)
|
||||||
if (this.missionState !== 'waiting_qr') {
|
if (this.missionState !== 'waiting_qr') {
|
||||||
this.qrSubmitting = false
|
this.qrSubmitting = false
|
||||||
@@ -118,6 +146,9 @@ createApp({
|
|||||||
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
|
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
|
||||||
this.showQrModal = true
|
this.showQrModal = true
|
||||||
this.qrValue = ''
|
this.qrValue = ''
|
||||||
|
if (!this.qrMessage) {
|
||||||
|
this.qrMessage = '所有姿态均未识别到二维码,请手动输入:'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 完成后获取报告
|
// 完成后获取报告
|
||||||
@@ -145,6 +176,11 @@ createApp({
|
|||||||
},
|
},
|
||||||
async startMission() {
|
async startMission() {
|
||||||
if (this.missionState !== 'idle') return
|
if (this.missionState !== 'idle') return
|
||||||
|
// 没有设置报关单时阻止启动(后端也会校验,这里提前友好提示)
|
||||||
|
if (!this.inspection) {
|
||||||
|
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.logs = []
|
this.logs = []
|
||||||
this.progress = 0
|
this.progress = 0
|
||||||
this.report = null
|
this.report = null
|
||||||
@@ -175,6 +211,11 @@ createApp({
|
|||||||
},
|
},
|
||||||
async startSingleStep() {
|
async startSingleStep() {
|
||||||
if (this.missionState !== 'idle') return
|
if (this.missionState !== 'idle') return
|
||||||
|
// 没有设置报关单时阻止启动(后端会校验,这里提前友好提示)
|
||||||
|
if (!this.inspection) {
|
||||||
|
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.logs = []
|
this.logs = []
|
||||||
this.progress = 0
|
this.progress = 0
|
||||||
this.report = null
|
this.report = null
|
||||||
@@ -244,11 +285,28 @@ createApp({
|
|||||||
body: JSON.stringify({ qr: 'SKIP' })
|
body: JSON.stringify({ qr: 'SKIP' })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async rescanQr() {
|
||||||
|
this.showQrModal = false
|
||||||
|
this.qrValue = ''
|
||||||
|
this.qrSubmitting = true
|
||||||
|
await fetch(API + '/api/mission/manual-qr', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ qr: 'RESCAN' })
|
||||||
|
})
|
||||||
|
// 4秒后允许弹窗重新出现(后端重试扫码约3秒)
|
||||||
|
setTimeout(() => { this.qrSubmitting = false }, 4000)
|
||||||
|
},
|
||||||
onAgvPreviewError(e) {
|
onAgvPreviewError(e) {
|
||||||
e.target.style.display = 'none'
|
e.target.style.display = 'none'
|
||||||
},
|
},
|
||||||
onArmPreviewError(e) {
|
onArmPreviewError(e) {
|
||||||
e.target.style.display = 'none'
|
// 重新加载:加时间戳避免缓存
|
||||||
|
const url = this.armPreviewUrl
|
||||||
|
if (url) {
|
||||||
|
const sep = url.includes('?') ? '&' : '?'
|
||||||
|
this.armPreviewUrl = url + sep + '_t=' + Date.now()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// ===== 网格任务显示方法 =====
|
// ===== 网格任务显示方法 =====
|
||||||
getPointStatus(pr, c) {
|
getPointStatus(pr, c) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ createApp({
|
|||||||
agvConnected: false,
|
agvConnected: false,
|
||||||
agvBattery: null,
|
agvBattery: null,
|
||||||
agvPosition: null,
|
agvPosition: null,
|
||||||
agvSpeed: 0.5,
|
agvSpeed: 1.0,
|
||||||
agvMoveInterval: null,
|
agvMoveInterval: null,
|
||||||
agvCameraUrl: API + '/api/camera/refresh',
|
agvCameraUrl: API + '/api/camera/refresh',
|
||||||
agvCameraTimer: null,
|
agvCameraTimer: null,
|
||||||
@@ -57,9 +57,24 @@ createApp({
|
|||||||
qrScanning: false,
|
qrScanning: false,
|
||||||
qrConfigs: [],
|
qrConfigs: [],
|
||||||
qrScanningId: null,
|
qrScanningId: null,
|
||||||
|
showQrInputDialog: false,
|
||||||
|
qrInputId: null,
|
||||||
|
qrInputValue: '',
|
||||||
armCameraUrl: API + '/api/camera/arm_preview',
|
armCameraUrl: API + '/api/camera/arm_preview',
|
||||||
|
armSnapshotUrl: '',
|
||||||
|
showArmSnapshot: false,
|
||||||
|
armSnapshotLoading: false,
|
||||||
newQrName: '',
|
newQrName: '',
|
||||||
armInitialPose: [0, 0, 0, 0, 0, 0],
|
armInitialPose: [0, 0, 0, 0, 0, 0],
|
||||||
|
// 报关单
|
||||||
|
customsList: [],
|
||||||
|
customsLoading: false,
|
||||||
|
customsPage: 1,
|
||||||
|
customsPageSize: 15,
|
||||||
|
customsTotal: 0,
|
||||||
|
selectedCustomsId: '',
|
||||||
|
selectedCustomsName: '',
|
||||||
|
customsMachines: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -67,9 +82,17 @@ createApp({
|
|||||||
this.refreshAngles()
|
this.refreshAngles()
|
||||||
this.loadQrConfigs()
|
this.loadQrConfigs()
|
||||||
this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
|
this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
|
||||||
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
this.armSnapshotUrl = ""; this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
||||||
},
|
this.armSnapshotUrl = ""; this.armCameraUrl = API + "/api/camera/arm_preview?t=" + Date.now()
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
customsTotalPages() {
|
||||||
|
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
|
||||||
|
},
|
||||||
|
customsPageData() {
|
||||||
|
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
|
||||||
|
return this.customsList
|
||||||
|
},
|
||||||
hasQr() {
|
hasQr() {
|
||||||
return !!(this.selectedMachine && this.selectedMachine.qr)
|
return !!(this.selectedMachine && this.selectedMachine.qr)
|
||||||
},
|
},
|
||||||
@@ -1104,10 +1127,39 @@ createApp({
|
|||||||
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
|
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
|
||||||
else msg += ' 未匹配到机型'
|
else msg += ' 未匹配到机型'
|
||||||
alert(msg)
|
alert(msg)
|
||||||
} else { alert(data.error || '扫描失败') }
|
} else {
|
||||||
} catch (e) { alert('扫描失败: ' + e.message) }
|
// 自动扫描失败,弹出手动输入框
|
||||||
|
this.qrInputId = qrId
|
||||||
|
this.qrInputValue = ''
|
||||||
|
this.showQrInputDialog = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.qrInputId = qrId
|
||||||
|
this.qrInputValue = ''
|
||||||
|
this.showQrInputDialog = true
|
||||||
|
}
|
||||||
this.qrScanningId = null
|
this.qrScanningId = null
|
||||||
},
|
},
|
||||||
|
async submitManualQr() {
|
||||||
|
if (!this.qrInputId || !this.qrInputValue.trim()) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/qr/configs/' + this.qrInputId, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ qr_value: this.qrInputValue.trim() })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok) {
|
||||||
|
await this.loadQrConfigs()
|
||||||
|
alert('✅ 二维码值已保存')
|
||||||
|
} else {
|
||||||
|
alert('保存失败')
|
||||||
|
}
|
||||||
|
} catch (e) { alert('保存失败: ' + e.message) }
|
||||||
|
this.showQrInputDialog = false
|
||||||
|
this.qrInputId = null
|
||||||
|
this.qrInputValue = ''
|
||||||
|
},
|
||||||
async applyQrAngles(qrId) {
|
async applyQrAngles(qrId) {
|
||||||
if (!this.armConnected) { alert('机械臂未连接'); return }
|
if (!this.armConnected) { alert('机械臂未连接'); return }
|
||||||
const q = this.qrConfigs.find(x => x.id === qrId)
|
const q = this.qrConfigs.find(x => x.id === qrId)
|
||||||
@@ -1126,6 +1178,12 @@ createApp({
|
|||||||
onArmPreviewError() {
|
onArmPreviewError() {
|
||||||
// 机械臂摄像头预览失败,静默处理
|
// 机械臂摄像头预览失败,静默处理
|
||||||
},
|
},
|
||||||
|
async captureArmSnapshot() {
|
||||||
|
this.armSnapshotLoading = true
|
||||||
|
this.armSnapshotUrl = API + '/api/camera/arm_refresh?t=' + Date.now()
|
||||||
|
this.showArmSnapshot = true
|
||||||
|
setTimeout(() => { this.armSnapshotLoading = false }, 500)
|
||||||
|
},
|
||||||
async agvResetCollision() {
|
async agvResetCollision() {
|
||||||
if (!this.agvConnected) {
|
if (!this.agvConnected) {
|
||||||
alert('AGV 未连接')
|
alert('AGV 未连接')
|
||||||
@@ -1146,5 +1204,121 @@ createApp({
|
|||||||
alert('❌ 复位请求失败: ' + e.message)
|
alert('❌ 复位请求失败: ' + e.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
// ===== 报关单方法 =====
|
||||||
|
async loadCustomsList() {
|
||||||
|
this.customsLoading = true
|
||||||
|
try {
|
||||||
|
const url = API + '/api/customs/list?pageNum=' + this.customsPage + '&pageSize=' + this.customsPageSize + '&customsName=' + encodeURIComponent(this.customsName) + '&customsNo=' + encodeURIComponent(this.customsNo)
|
||||||
|
const res = await fetch(url)
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.data) {
|
||||||
|
const raw = d.data
|
||||||
|
let list = []
|
||||||
|
let total = 0
|
||||||
|
if (raw.rows) { list = raw.rows; total = raw.total || list.length }
|
||||||
|
else if (raw.records) { list = raw.records; total = raw.total || list.length }
|
||||||
|
else if (Array.isArray(raw)) { list = raw; total = list.length }
|
||||||
|
else if (raw.data && raw.data.rows) { list = raw.data.rows; total = raw.data.total || list.length }
|
||||||
|
else if (raw.data && raw.data.records) { list = raw.data.records; total = raw.data.total || list.length }
|
||||||
|
else if (raw.data && Array.isArray(raw.data)) { list = raw.data; total = list.length }
|
||||||
|
this.customsList = list
|
||||||
|
this.customsTotal = total || list.length
|
||||||
|
} else {
|
||||||
|
this.customsList = []
|
||||||
|
this.customsTotal = 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载报关单列表失败', e)
|
||||||
|
this.customsList = []
|
||||||
|
this.customsTotal = 0
|
||||||
|
} finally {
|
||||||
|
this.customsLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async selectCustomsRow(item) {
|
||||||
|
// 新数据结构: { customs:{id,orderId,..}, orderCode, drawCode }
|
||||||
|
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
|
||||||
|
if (!id) return
|
||||||
|
this.selectedCustomsId = id
|
||||||
|
this.selectedCustomsName = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
|
||||||
|
this.customsMachines = []
|
||||||
|
try {
|
||||||
|
const url = API + '/api/customs/machines?customsId=' + encodeURIComponent(id)
|
||||||
|
const res = await fetch(url)
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.data) {
|
||||||
|
const raw = d.data
|
||||||
|
let machines = []
|
||||||
|
// customsMachines 返回格式: {"code":"0","data":[{serialNumber,inventoryName,...}]}
|
||||||
|
if (raw.rows) { machines = raw.rows }
|
||||||
|
else if (raw.records) { machines = raw.records }
|
||||||
|
else if (raw.data && Array.isArray(raw.data)) { machines = raw.data }
|
||||||
|
else if (Array.isArray(raw)) { machines = raw }
|
||||||
|
else if (Array.isArray(raw.data)) { machines = raw.data }
|
||||||
|
this.customsMachines = machines
|
||||||
|
} else {
|
||||||
|
this.customsMachines = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载机器列表失败', e)
|
||||||
|
this.customsMachines = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async startInspection(item) {
|
||||||
|
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
|
||||||
|
const name = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
|
||||||
|
if (!id) return
|
||||||
|
if (!confirm(`确定要对报关单「${name}」开始查验吗?\n点击确定后,运行页将以该报关单的机器进行查验。`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/customs/inspection/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customsId: id, customsName: name })
|
||||||
|
})
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok) {
|
||||||
|
alert(`✅ 查验已开始!\n报关单: ${name}\n机型: ${d.inspection.items.length} 种\n总数: ${d.inspection.items.reduce((s,i)=>s+i.quantify,0)} 台\n\n请前往「运行」页执行任务。`)
|
||||||
|
// 同时选中该报关单,显示机器列表
|
||||||
|
this.selectedCustomsId = id
|
||||||
|
this.selectedCustomsName = name
|
||||||
|
// 用 inspection items 填充 customsMachines 显示(聚合后)
|
||||||
|
this.customsMachines = d.inspection.items.map(it => ({
|
||||||
|
inventoryCode: it.inventoryCode,
|
||||||
|
inventoryName: it.inventoryName,
|
||||||
|
inventorySpecification: it.spec,
|
||||||
|
serialNumber: '',
|
||||||
|
quantify: it.quantify,
|
||||||
|
inspectionCount: it.inspected,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
alert('❌ 开始查验失败: ' + (d.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('❌ 请求失败: ' + e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadInspectionCounts() {
|
||||||
|
// 轮询查验计数,更新 customsMachines 的 inspectionCount
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/customs/inspection')
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.inspection && this.customsMachines.length) {
|
||||||
|
for (const item of d.inspection.items) {
|
||||||
|
const match = this.customsMachines.find(m => m.inventoryCode === item.inventoryCode)
|
||||||
|
if (match) {
|
||||||
|
match.inspectionCount = item.inspected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tab(newVal) {
|
||||||
|
if (newVal === 'customs' && this.customsMachines.length > 0) {
|
||||||
|
// 切换到报关单 tab 时刷新查验计数
|
||||||
|
this.loadInspectionCounts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
-1045
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,13 @@
|
|||||||
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
||||||
<a href="/running" class="nav-link">▶️ 运行</a>
|
<a href="/running" class="nav-link">▶️ 运行</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="status-bar">
|
<div class="status-bar" style="display:flex;align-items:center;gap:12px">
|
||||||
|
<label class="env-toggle" title="切换测试/正式环境">
|
||||||
|
<span class="env-label" :class="testMode ? 'test' : 'prod'">{% raw %}{{ testMode ? '🧪 测试' : '🏭 正式' }}{% endraw %}</span>
|
||||||
|
<div class="toggle-switch" @click="toggleEnvMode" :class="{active: testMode}">
|
||||||
|
<div class="toggle-knob"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<span class="status-item" :class="statusClass">
|
<span class="status-item" :class="statusClass">
|
||||||
{% raw %}{{ statusText }}{% endraw %}
|
{% raw %}{{ statusText }}{% endraw %}
|
||||||
</span>
|
</span>
|
||||||
@@ -93,9 +99,10 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-row">
|
<div class="camera-row">
|
||||||
<div class="camera-box">
|
<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>
|
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
|
||||||
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
<img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||||
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
|
<div v-if="cameraOpened && agvCameraError && hasAgvCamera" class="camera-placeholder">AGV 摄像头异常</div>
|
||||||
|
<div v-if="cameraOpened && !hasAgvCamera" class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>运行监控 - AGV 拍摄系统</title>
|
<title>运行监控 - AGV 拍摄系统</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529a">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260616a">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -48,6 +48,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 查验进度 -->
|
||||||
|
<section class="card" v-if="inspection">
|
||||||
|
<h2>🔍 查验进度 — {% raw %}{{ inspection.customsName }}{% endraw %}</h2>
|
||||||
|
<p class="hint" style="margin-bottom:12px">
|
||||||
|
总进度: {% raw %}{{ inspectionTotal }}{% endraw %} / {% raw %}{{ inspectionTarget }}{% endraw %} 台
|
||||||
|
<span v-if="inspectionTotal >= inspectionTarget && inspectionTarget > 0" style="color:#4caf50;font-weight:bold"> ✅ 已完成</span>
|
||||||
|
</p>
|
||||||
|
<div class="inspection-grid">
|
||||||
|
<div v-for="(item, ii) in inspection.items" :key="ii" class="inspection-item" :class="{ 'insp-done': item.inspected >= item.quantify, 'insp-active': item.inspected > 0 && item.inspected < item.quantify }">
|
||||||
|
<div class="insp-name">{% raw %}{{ item.inventoryName }}{% endraw %}</div>
|
||||||
|
<div class="insp-code">{% raw %}{{ item.inventoryCode }}{% endraw %}</div>
|
||||||
|
<div class="insp-spec">{% raw %}{{ item.spec }}{% endraw %}</div>
|
||||||
|
<div class="insp-count">
|
||||||
|
<span class="insp-num">{% raw %}{{ item.inspected }}{% endraw %}</span>
|
||||||
|
<span class="insp-sep">/</span>
|
||||||
|
<span class="insp-total">{% raw %}{{ item.quantify }}{% endraw %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="insp-bar">
|
||||||
|
<div class="insp-fill" :style="{width: (item.quantify > 0 ? (item.inspected / item.quantify * 100) : 0) + '%'}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 任务步骤控制开关 -->
|
<!-- 任务步骤控制开关 -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>🎛️ 任务步骤控制</h2>
|
<h2>🎛️ 任务步骤控制</h2>
|
||||||
@@ -140,7 +164,7 @@
|
|||||||
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'front')" title="正面照">📸正</span>
|
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'front')" title="正面照">📸正</span>
|
||||||
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'back')" title="背面照">📸背</span>
|
<span class="step-dot" :class="'dot-'+getMachineField(ri-1,c-1,'back')" title="背面照">📸背</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="getMachineField(ri-1,c-1,'qr_val')" class="machine-qr-mini">🏷 {% raw %}{{ getMachineField(ri-1,c-1,'qr_val').substring(0,6) }}{% endraw %}</div>
|
<div v-if="getMachineField(ri-1,c-1,'qr_val')" class="machine-qr-mini">🏷 {% raw %}{{ getMachineField(ri-1,c-1,'qr_val') }}{% endraw %}</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty-cell">空</div>
|
<div v-else class="empty-cell">空</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,8 +197,9 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-dual">
|
<div class="camera-dual">
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
<div class="camera-label">🎥 AGV 摄像头</div>
|
<div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
|
||||||
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
<img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
||||||
|
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box" v-if="armCameraOpened">
|
<div class="camera-box" v-if="armCameraOpened">
|
||||||
<div class="camera-label">🦾 机械臂摄像头</div>
|
<div class="camera-label">🦾 机械臂摄像头</div>
|
||||||
@@ -201,9 +226,10 @@
|
|||||||
<div class="modal-overlay" v-if="showQrModal">
|
<div class="modal-overlay" v-if="showQrModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>⌨️ 手动输入二维码</h3>
|
<h3>⌨️ 手动输入二维码</h3>
|
||||||
<p>所有姿态均未识别到二维码,请手动输入:</p>
|
<p>{% raw %}{{ qrMessage }}{% endraw %}</p>
|
||||||
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
|
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-success" @click="rescanQr" style="margin-right:auto">🔄 重新扫描</button>
|
||||||
<button class="btn btn-primary" @click="submitQr">确认</button>
|
<button class="btn btn-primary" @click="submitQr">确认</button>
|
||||||
<button class="btn" @click="cancelQr">跳过</button>
|
<button class="btn" @click="cancelQr">跳过</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,6 +265,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vue3.global.prod.js"></script>
|
<script src="/static/js/vue3.global.prod.js"></script>
|
||||||
<script src="/static/js/running.js?v=20260529a"></script>
|
<script src="/static/js/running.js?v=20260616c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+145
-12
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>设置 - AGV 拍摄系统</title>
|
<title>设置 - AGV 拍摄系统</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529b">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260612b">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
||||||
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
||||||
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
||||||
|
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#1a2332;text-align:left">
|
<tr style="background:#1a2332;text-align:left">
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型名称</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型申请时间</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
|
||||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
|
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" v-model="newPoseForm[m.id + '_front']"
|
<input type="text" v-model="newPoseForm[m.id + '_front']"
|
||||||
placeholder="姿态名称(如:取料)"
|
placeholder="姿态申请时间(如:取料)"
|
||||||
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
||||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])">➕ 添加正面姿态(当前角度)</button>
|
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])">➕ 添加正面姿态(当前角度)</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
<div style="margin-top:8px">
|
<div style="margin-top:8px">
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||||
<input type="text" v-model="newPoseForm[m.id + '_back']"
|
<input type="text" v-model="newPoseForm[m.id + '_back']"
|
||||||
placeholder="姿态名称(如:放料)"
|
placeholder="姿态申请时间(如:放料)"
|
||||||
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
||||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])">➕ 添加背面姿态(当前角度)</button>
|
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])">➕ 添加背面姿态(当前角度)</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +225,7 @@
|
|||||||
<button class="btn-icon" @click="showAddModelModal = false">✕</button>
|
<button class="btn-icon" @click="showAddModelModal = false">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
<div class="form-group" style="margin-bottom:12px">
|
||||||
<label>机型名称</label>
|
<label>机型申请时间</label>
|
||||||
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
|
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:12px">
|
<div class="form-group" style="margin-bottom:12px">
|
||||||
@@ -433,13 +434,18 @@
|
|||||||
<h2>📷 二维码配置</h2>
|
<h2>📷 二维码配置</h2>
|
||||||
<p style="color:#9aa0a6;font-size:13px;margin-bottom:16px">配置机械臂姿态(6个关节角度),通过机械臂摄像头识别二维码并匹配机型。</p>
|
<p style="color:#9aa0a6;font-size:13px;margin-bottom:16px">配置机械臂姿态(6个关节角度),通过机械臂摄像头识别二维码并匹配机型。</p>
|
||||||
<!-- 机械臂摄像头画面 -->
|
<!-- 机械臂摄像头画面 -->
|
||||||
<div style="margin-bottom:16px">
|
<div style="margin-bottom:8px">
|
||||||
<div class="camera-preview" style="max-width:640px">
|
<div class="camera-preview" style="max-width:640px">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
<input type="text" v-model="newQrName" placeholder="输入名称..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
|
<button class="btn btn-secondary btn-small" @click="captureArmSnapshot" :disabled="armSnapshotLoading">
|
||||||
|
📸 获取图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
|
<input type="text" v-model="newQrName" placeholder="输入申请时间..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
|
||||||
<button class="btn btn-primary" @click="addQrConfig()">➕ 添加</button>
|
<button class="btn btn-primary" @click="addQrConfig()">➕ 添加</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
|
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
|
||||||
@@ -448,7 +454,7 @@
|
|||||||
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
|
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background:#1a2332;text-align:left">
|
<tr style="background:#1a2332;text-align:left">
|
||||||
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">名称</th>
|
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">申请时间</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
|
||||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
|
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
|
||||||
@@ -476,6 +482,7 @@
|
|||||||
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
|
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
|
||||||
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
|
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
|
||||||
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
|
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
|
||||||
|
<button class="btn btn-secondary btn-small" @click="qrInputId = q.id; qrInputValue = q.qr_value || ''; showQrInputDialog = true" style="margin-left:3px" title="手动输入二维码值">✏️</button>
|
||||||
<button class="btn btn-danger btn-small" @click="deleteQrConfig(q.id)" style="margin-left:3px" title="删除">🗑️</button>
|
<button class="btn btn-danger btn-small" @click="deleteQrConfig(q.id)" style="margin-left:3px" title="删除">🗑️</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -493,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="camera-preview">
|
<div class="camera-preview">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
<div class="joints-panel">
|
<div class="joints-panel">
|
||||||
<h3>关节角度控制</h3>
|
<h3>关节角度控制</h3>
|
||||||
@@ -581,10 +588,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ========== 报关单 Tab ========== -->
|
||||||
|
<div v-if="tab === 'customs'">
|
||||||
|
<section class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2>📋 报关单列表</h2>
|
||||||
|
<button class="btn btn-secondary" @click="loadCustomsList" :disabled="customsLoading">
|
||||||
|
<span v-if="customsLoading">⏳</span><span v-else>🔄</span> 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-bottom:12px">选择报关单查看其中的机器列表,点击报关单 ID 展开机器信息</p>
|
||||||
|
|
||||||
|
<!-- 分页表格 -->
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">序号</th>
|
||||||
|
<th>报关单号</th>
|
||||||
|
<th>申请时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>机器数</th>
|
||||||
|
<th style="width:80px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, idx) in customsPageData" :key="(item.customs && item.customs.id) || item.id || idx"
|
||||||
|
class="clickable-row"
|
||||||
|
:class="{ 'row-selected': selectedCustomsId === ((item.customs && item.customs.id) || item.id || item.customsId || item.customs_id) }"
|
||||||
|
@click="selectCustomsRow(item)">
|
||||||
|
<td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ (item.customs && item.customs.customsCode) || item.orderCode || (item.customs && item.customs.id) || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ item.orderCode || item.drawCode || '-' }}{% endraw %}</td>
|
||||||
|
<td><span class="badge" :class="((item.customs && item.customs.customsCode) ? 'badge-success' : 'badge-pending')">{% raw %}{{ (item.customs && item.customs.customsCode) ? '已报关' : '待报关' }}{% endraw %}</span></td>
|
||||||
|
<td>{% raw %}{{ (item.customs && item.customs.orderId) ? item.customs.orderId.split(',').length : '?' }}{% endraw %}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item)">
|
||||||
|
📦 查看机器
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-small btn-success" style="margin-left:4px" @click.stop="startInspection(item)">
|
||||||
|
🔍 开始查验
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!customsPageData.length && !customsLoading">
|
||||||
|
<td colspan="7" style="text-align:center;color:#8899aa;padding:24px">暂无报关单数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页控件 -->
|
||||||
|
<div class="pagination" v-if="customsTotal > customsPageSize">
|
||||||
|
<button class="btn btn-small" :disabled="customsPage <= 1" @click="customsPage = customsPage - 1; loadCustomsList()">‹ 上一页</button>
|
||||||
|
<span style="margin:0 12px;color:#9aa0a6;font-size:13px">
|
||||||
|
第 {% raw %}{{ customsPage }}{% endraw %} / {% raw %}{{ customsTotalPages }}{% endraw %} 页(共 {% raw %}{{ customsTotal }}{% endraw %} 条)
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-small" :disabled="customsPage >= customsTotalPages" @click="customsPage = customsPage + 1; loadCustomsList()">下一页 ›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 机器列表(点击报关单行时展开) -->
|
||||||
|
<section class="card" v-if="customsMachines.length">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2>📦 机器列表</h2>
|
||||||
|
<span style="color:#9aa0a6;font-size:13px">报关单: <strong style="color:#e0e0e0">{% raw %}{{ selectedCustomsName }}{% endraw %}</strong></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-bottom:12px">共 <strong>{% raw %}{{ customsMachines.length }}{% endraw %}</strong> 台机器</p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:50px">序号</th>
|
||||||
|
<th>物料编码</th>
|
||||||
|
<th>物料名称</th>
|
||||||
|
<th>规格</th>
|
||||||
|
<th>序列号</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>查验数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(m, mi) in customsMachines" :key="mi">
|
||||||
|
<td>{% raw %}{{ mi + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ m.inventoryCode || m.machineCode || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ m.inventoryName || m.machineName || m.name || '-' }}{% endraw %}</td>
|
||||||
|
<td>{% raw %}{{ m.inventorySpecification || m.spec || '-' }}{% endraw %}</td>
|
||||||
|
<td style="font-family:monospace;color:#4fc3f7">{% raw %}{{ m.serialNumber || m.serialNumbers || m.qrValue || '-' }}{% endraw %}</td>
|
||||||
|
<td>{% raw %}{{ m.quantify || m.quantity || (m.quantify ? m.quantify : '?') }}{% endraw %}</td>
|
||||||
|
<td><span :class="(m.inspectionCount > 0) ? 'badge badge-success' : 'badge badge-pending'">{% raw %}{{ m.inspectionCount || 0 }}{% endraw %}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
<!-- 手动输入二维码弹窗 -->
|
||||||
|
<div class="modal-overlay" v-if="showQrInputDialog">
|
||||||
|
<div class="modal" style="max-width:420px">
|
||||||
|
<h3>⌨️ 手动输入二维码</h3>
|
||||||
|
<p style="color:#9aa0a6;font-size:13px;margin:8px 0 16px">自动扫码未识别到二维码,请手动输入二维码内容:</p>
|
||||||
|
<input type="text" v-model="qrInputValue" placeholder="输入二维码内容..."
|
||||||
|
style="width:100%;background:#0f1923;border:1px solid #2a3441;color:#fff;padding:10px 12px;border-radius:6px;font-size:14px;box-sizing:border-box"
|
||||||
|
autofocus @keyup.enter="submitManualQr">
|
||||||
|
<div class="modal-actions" style="margin-top:16px">
|
||||||
|
<button class="btn btn-primary" @click="submitManualQr">确认</button>
|
||||||
|
<button class="btn" @click="showQrInputDialog = false; qrInputId = null; qrInputValue = ''">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 机械臂截图弹窗 -->
|
||||||
|
<div class="modal-overlay" v-if="showArmSnapshot && armSnapshotUrl" @click.self="showArmSnapshot = false">
|
||||||
|
<div class="modal" style="max-width:800px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
|
<h3>📸 机械臂摄像头截图</h3>
|
||||||
|
<button class="btn btn-small" @click="showArmSnapshot = false" style="font-size:18px;background:none;border:none;color:#9aa0a6;cursor:pointer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="background:#000;border-radius:8px;overflow:hidden">
|
||||||
|
<img :src="armSnapshotUrl" style="width:100%;display:block">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;text-align:center;color:#9aa0a6;font-size:12px">
|
||||||
|
点击弹窗外关闭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
||||||
<script src="/static/js/setting.js?v=20260529b"></script>
|
<script src="/static/js/setting.js?v=20260616f"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -76,7 +76,7 @@ class AGVController:
|
|||||||
if rc != 0:
|
if rc != 0:
|
||||||
logger.warning(f"发布 cmd_vel 失败: {err}")
|
logger.warning(f"发布 cmd_vel 失败: {err}")
|
||||||
|
|
||||||
def move_forward(self, speed: float = 0.5, duration: float = None):
|
def move_forward(self, speed: float = 1.0, duration: float = None):
|
||||||
"""前进"""
|
"""前进"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -85,7 +85,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_backward(self, speed: float = 0.5, duration: float = None):
|
def move_backward(self, speed: float = 1.0, duration: float = None):
|
||||||
"""后退"""
|
"""后退"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -94,7 +94,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def turn_left(self, speed: float = 0.5, duration: float = None):
|
def turn_left(self, speed: float = 1.0, duration: float = None):
|
||||||
"""左转"""
|
"""左转"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -103,7 +103,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def turn_right(self, speed: float = 0.5, duration: float = None):
|
def turn_right(self, speed: float = 1.0, duration: float = None):
|
||||||
"""右转"""
|
"""右转"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -112,7 +112,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_left_lateral(self, speed: float = 0.5, duration: float = None):
|
def move_left_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
"""向左横向移动"""
|
"""向左横向移动"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -121,7 +121,7 @@ class AGVController:
|
|||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def move_right_lateral(self, speed: float = 0.5, duration: float = None):
|
def move_right_lateral(self, speed: float = 1.0, duration: float = None):
|
||||||
"""向右横向移动"""
|
"""向右横向移动"""
|
||||||
if not self.is_connected():
|
if not self.is_connected():
|
||||||
return
|
return
|
||||||
@@ -176,7 +176,7 @@ class AGVController:
|
|||||||
return self._position
|
return self._position
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 0.5) -> bool:
|
def go_to_point(self, x: float, y: float, rz: float = None, speed: float = 1.0) -> bool:
|
||||||
"""移动到目标点(需要 ROS2 导航栈)"""
|
"""移动到目标点(需要 ROS2 导航栈)"""
|
||||||
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
||||||
return True
|
return True
|
||||||
|
|||||||
+23
-14
@@ -61,14 +61,18 @@ class ArmClient:
|
|||||||
def get_angles(self) -> Tuple[bool, List[float]]:
|
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取所有关节角度"""
|
"""获取所有关节角度"""
|
||||||
ok, resp = self.send_command("get_angles()")
|
ok, resp = self.send_command("get_angles()")
|
||||||
if ok and resp.startswith("get_angles:["):
|
if ok:
|
||||||
try:
|
try:
|
||||||
# get_angles:[0.174, 0.520, ...] → list
|
# 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
angles = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, angles
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
angles = [float(x) for x in nums]
|
||||||
|
if len(angles) == 6:
|
||||||
|
return True, angles
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
||||||
@@ -94,13 +98,17 @@ class ArmClient:
|
|||||||
def get_coords(self) -> Tuple[bool, List[float]]:
|
def get_coords(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
||||||
ok, resp = self.send_command("get_coords()")
|
ok, resp = self.send_command("get_coords()")
|
||||||
if ok and "get_coords:" in resp:
|
if ok:
|
||||||
try:
|
try:
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
coords = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, coords
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
coords = [float(x) for x in nums]
|
||||||
|
if len(coords) == 6:
|
||||||
|
return True, coords
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||||
@@ -132,19 +140,20 @@ class ArmClient:
|
|||||||
def state_check(self) -> bool:
|
def state_check(self) -> bool:
|
||||||
"""检查机械臂状态是否正常"""
|
"""检查机械臂状态是否正常"""
|
||||||
ok, resp = self.send_command("state_check()")
|
ok, resp = self.send_command("state_check()")
|
||||||
return ok and resp == "state_check:1"
|
# 兼容 "state_check:1" 和 "1" 两种格式
|
||||||
|
return ok and resp.strip().lstrip("state_check:") == "1"
|
||||||
|
|
||||||
def check_running(self) -> bool:
|
def check_running(self) -> bool:
|
||||||
"""检查机械臂是否在运行"""
|
"""检查机械臂是否在运行"""
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
return ok and resp == "check_running:1"
|
return ok and resp.strip().lstrip("check_running:") == "1"
|
||||||
|
|
||||||
def wait_done(self, timeout: float = 30) -> bool:
|
def wait_done(self, timeout: float = 30) -> bool:
|
||||||
"""等待上一条命令执行完成"""
|
"""等待上一条命令执行完成"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
if ok and resp == "check_running:0":
|
if ok and resp.strip().lstrip("check_running:") == "0":
|
||||||
return True
|
return True
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
配置文件 - 所有可配置参数集中管理
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# 基础路径(部署后对应 ~/work/agv_app)
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
|
||||||
AGV_HOST = "192.168.60.177"
|
|
||||||
ARM_HOST = "192.168.60.88"
|
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
|
||||||
AGV_CONFIG = {
|
|
||||||
"device": "/dev/agvpro_controller",
|
|
||||||
"baudrate": 10000000,
|
|
||||||
"move_speed": 0.5,
|
|
||||||
"turn_speed": 0.5,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 机械臂 TCP 客户端 ==========
|
|
||||||
ARM_CONFIG = {
|
|
||||||
"host": ARM_HOST,
|
|
||||||
"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": f"http://{ARM_HOST}:5003/api/camera/preview",
|
|
||||||
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ========== 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"
|
|
||||||
@@ -1,663 +0,0 @@
|
|||||||
"""
|
|
||||||
地图导航模块 - 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=1 && source /opt/ros/humble/setup.bash && 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
|
|
||||||
@@ -28,10 +28,10 @@ from utils.nav2_navigator import Nav2Navigator, Nav2Status
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
||||||
from config import ARM_CAMERA_CONFIG
|
from config import ARM_CAMERA_CONFIG, UPLOAD_CONFIG, ZHIJIAN_AUTH_TOKEN
|
||||||
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
|
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
|
||||||
PHOTOS_DIR = "/home/elephant/photos"
|
PHOTOS_DIR = "/home/elephant/photos"
|
||||||
UPLOAD_URL = "https://ts.zhijian168.com/prod-api/file/uploadImage"
|
# UPLOAD_CONFIG["url"] 随环境切换动态变化,每次使用时直接读取
|
||||||
|
|
||||||
# 二维码扫描重试参数
|
# 二维码扫描重试参数
|
||||||
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
|
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
|
||||||
@@ -101,11 +101,10 @@ class MissionExecutorV3:
|
|||||||
self._nav = Nav2Navigator()
|
self._nav = Nav2Navigator()
|
||||||
|
|
||||||
# 速度控制(默认值,可在 execute_mission 时覆写)
|
# 速度控制(默认值,可在 execute_mission 时覆写)
|
||||||
self.arm_speed = 500
|
self.arm_speed = 1000
|
||||||
self.agv_speed = 0.5
|
self.agv_speed = 1.0
|
||||||
|
|
||||||
# 照片上传序号计数器(连续递增,从1开始)
|
# 照片上传序号计数器(连续递增,从1开始)
|
||||||
self.next_upload_index = 1
|
|
||||||
|
|
||||||
# ==================== 连接 ====================
|
# ==================== 连接 ====================
|
||||||
|
|
||||||
@@ -239,8 +238,7 @@ class MissionExecutorV3:
|
|||||||
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
|
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
|
||||||
|
|
||||||
# 重置照片上传序号(每次任务开始时重置,从1开始)
|
# 重置照片上传序号(每次任务开始时重置,从1开始)
|
||||||
self.next_upload_index = 1
|
|
||||||
|
|
||||||
# 任务步骤控制开关
|
# 任务步骤控制开关
|
||||||
if options is None:
|
if options is None:
|
||||||
options = {}
|
options = {}
|
||||||
@@ -258,8 +256,8 @@ class MissionExecutorV3:
|
|||||||
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
|
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
|
||||||
|
|
||||||
# 速度控制(从前端传入)
|
# 速度控制(从前端传入)
|
||||||
self.arm_speed = int(options.get("arm_speed", 500))
|
self.arm_speed = int(options.get("arm_speed", 1000))
|
||||||
self.agv_speed = float(options.get("agv_speed", 0.5))
|
self.agv_speed = float(options.get("agv_speed", 1.0))
|
||||||
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
|
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
|
||||||
|
|
||||||
# 设置 Nav2 导航速度(仅在任务开始时设一次)
|
# 设置 Nav2 导航速度(仅在任务开始时设一次)
|
||||||
@@ -721,6 +719,7 @@ class MissionExecutorV3:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
|
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
|
||||||
if resp.status_code != 200 or not resp.content:
|
if resp.status_code != 200 or not resp.content:
|
||||||
|
self._log(f" 📷 arm snapshot attempt {attempt+1}: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
arr = np.frombuffer(resp.content, dtype=np.uint8)
|
arr = np.frombuffer(resp.content, dtype=np.uint8)
|
||||||
@@ -737,23 +736,41 @@ class MissionExecutorV3:
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _request_manual_qr(self) -> Optional[str]:
|
def _request_manual_qr(self, message: str = None) -> Optional[str]:
|
||||||
"""暂停任务,等待手动输入(不超时,必须输入才能继续;stop 时解除)"""
|
"""暂停任务,等待手动输入(支持重新扫描)
|
||||||
self.status = MissionStatus.WAITING_QR
|
message: 自定义弹窗消息(None 则使用默认消息)"""
|
||||||
self.report["status"] = "waiting_qr"
|
while True:
|
||||||
self.report["step"] = "等待手动输入二维码"
|
self.status = MissionStatus.WAITING_QR
|
||||||
self._log(" ⌨️ 弹窗等待手动输入二维码(不可跳过)...")
|
self.report["status"] = "waiting_qr"
|
||||||
|
self.report["step"] = message or "等待手动输入二维码"
|
||||||
|
self.report["qr_message"] = message or "所有姿态均未识别到二维码,请手动输入:"
|
||||||
|
if message:
|
||||||
|
self._log(f" ⌨️ {message}")
|
||||||
|
else:
|
||||||
|
self._log(" ⌨️ 弹窗等待手动输入二维码...")
|
||||||
|
|
||||||
self._qr_event.clear()
|
self._qr_event.clear()
|
||||||
self._qr_event.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
self._qr_event.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
||||||
self.status = MissionStatus.RUNNING
|
|
||||||
self.report["status"] = "running"
|
if self._qr_value == 'RESCAN':
|
||||||
if self._qr_value:
|
self.status = MissionStatus.RUNNING
|
||||||
self._log(f" ✏️ 手动输入: {self._qr_value}")
|
self.report["status"] = "running"
|
||||||
return self._qr_value
|
self._log(" 🔄 用户点击重新扫描,重试...")
|
||||||
else:
|
qr = self._decode_qr_from_arm()
|
||||||
self._log(f" ⚠️ 任务已停止")
|
if qr:
|
||||||
return None
|
self._log(f" ✅ 重新扫描成功: {qr}")
|
||||||
|
return qr
|
||||||
|
self._log(" ❌ 重新扫描仍未识别到二维码")
|
||||||
|
continue # 继续弹窗
|
||||||
|
|
||||||
|
self.status = MissionStatus.RUNNING
|
||||||
|
self.report["status"] = "running"
|
||||||
|
if self._qr_value:
|
||||||
|
self._log(f" ✏️ 手动输入: {self._qr_value}")
|
||||||
|
return self._qr_value
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ 任务已停止")
|
||||||
|
return None
|
||||||
|
|
||||||
def set_manual_qr(self, value: str):
|
def set_manual_qr(self, value: str):
|
||||||
self._qr_value = value.strip()
|
self._qr_value = value.strip()
|
||||||
@@ -762,8 +779,73 @@ class MissionExecutorV3:
|
|||||||
# ==================== 机型查询 ====================
|
# ==================== 机型查询 ====================
|
||||||
|
|
||||||
def _lookup_model(self, qr_value: Optional[str]) -> str:
|
def _lookup_model(self, qr_value: Optional[str]) -> str:
|
||||||
"""TODO: 后续通过 HTTP 接口查询机型"""
|
"""通过 /api/customs/printer 接口查询机型,同时更新查验计数
|
||||||
return "机器1"
|
如果机型不在当前报关单中/超量/查询失败,均弹窗要求重新扫码/输入(不可跳过)"""
|
||||||
|
if not qr_value:
|
||||||
|
return "机器1"
|
||||||
|
while True:
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
try:
|
||||||
|
printer_url = f"http://127.0.0.1:5000/api/customs/printer?serialNumber={qr_value}"
|
||||||
|
self._log(f" 🔍 查询机型 → {printer_url}")
|
||||||
|
resp = requests.get(printer_url, timeout=10)
|
||||||
|
self._log(f" 📡 printer 响应 HTTP {resp.status_code}: {resp.text[:500]}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
model = data.get("modelName", "机器1")
|
||||||
|
inv_code = data.get("inventoryCode", "")
|
||||||
|
matched = data.get("matchedItem")
|
||||||
|
has_inspection = data.get("hasInspection", False)
|
||||||
|
self._log(f" 📊 解析结果: modelName={model}, inventoryCode={inv_code}, hasInspection={has_inspection}, matched={'yes' if matched else 'no'}")
|
||||||
|
if matched:
|
||||||
|
inspected = matched.get('inspected', 0)
|
||||||
|
quantify = matched.get('quantify', 0)
|
||||||
|
# 超量检查:已查验数量超过报关单数量
|
||||||
|
if inspected >= quantify:
|
||||||
|
self._log(f" ⚠️ 机型「{model}」已查验 {inspected} 台,超过报关单数量 {quantify} 台")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"机型「{model}」在报关单中只需查验 {quantify} 台,\n当前已扫到第 {inspected + 1} 台,请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return model
|
||||||
|
qr_value = new_qr
|
||||||
|
continue
|
||||||
|
self._log(f" 🏷️ 机型: {model} (物料:{inv_code}) — 查验 {inspected}/{quantify}")
|
||||||
|
return model
|
||||||
|
elif has_inspection and not matched:
|
||||||
|
# 有查验但机型不在报关单中 → 弹窗(不可跳过)
|
||||||
|
self._log(f" ⚠️ 机型「{model}」(物料:{inv_code})不在当前报关单中")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"二维码「{qr_value}」对应机型「{model}」不在当前报关单中,\n请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return model
|
||||||
|
qr_value = new_qr
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 无查验或匹配成功
|
||||||
|
self._log(f" 🏷️ 机型: {model} (物料:{inv_code})")
|
||||||
|
return model
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ printer 返回 ok=false: {data}")
|
||||||
|
# API 失败 / ok=false / 无数据 → 弹窗(不可跳过)
|
||||||
|
self._log(f" ⚠️ 查询机型失败, HTTP {resp.status_code}")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"无法查询二维码「{qr_value}」对应的机型信息,\n请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
qr_value = new_qr
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f" ⚠️ 查询机型失败: {e}")
|
||||||
|
new_qr = self._request_manual_qr(
|
||||||
|
f"查询机型接口异常,请重新扫描或手动输入正确的二维码:"
|
||||||
|
)
|
||||||
|
if self._stop.is_set():
|
||||||
|
return "机器1"
|
||||||
|
qr_value = new_qr
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_model(models: list, name: str) -> Optional[dict]:
|
def _find_model(models: list, name: str) -> Optional[dict]:
|
||||||
@@ -795,6 +877,13 @@ class MissionExecutorV3:
|
|||||||
self._log(f" ⚠️ 机型无{side_label}姿态配置")
|
self._log(f" ⚠️ 机型无{side_label}姿态配置")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 计算起始 upload_index:背面时接在正面照片之后
|
||||||
|
if side == "back":
|
||||||
|
front_poses = [p for p in all_poses if p.get("photo_type") == "front"]
|
||||||
|
base_index = len(front_poses)
|
||||||
|
else:
|
||||||
|
base_index = 0
|
||||||
|
|
||||||
self._log(f" 📷 {side_label}拍照 ({len(poses)} 个姿态)")
|
self._log(f" 📷 {side_label}拍照 ({len(poses)} 个姿态)")
|
||||||
for pi, pose in enumerate(poses):
|
for pi, pose in enumerate(poses):
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
@@ -818,9 +907,9 @@ class MissionExecutorV3:
|
|||||||
self.arm_client.set_angles(angles, speed=self.arm_speed)
|
self.arm_client.set_angles(angles, speed=self.arm_speed)
|
||||||
self._wait_arm_ready(angles)
|
self._wait_arm_ready(angles)
|
||||||
|
|
||||||
# 拍照(upload_index 连续递增)
|
# 拍照:正面从1开始,背面接着正面数量继续编号
|
||||||
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=self.next_upload_index)
|
upload_index = base_index + pi + 1
|
||||||
self.next_upload_index += 1
|
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=upload_index)
|
||||||
if path:
|
if path:
|
||||||
self._log(f" 💾 {os.path.basename(path)}")
|
self._log(f" 💾 {os.path.basename(path)}")
|
||||||
|
|
||||||
@@ -829,12 +918,12 @@ class MissionExecutorV3:
|
|||||||
upload_index: int = 0) -> Optional[str]:
|
upload_index: int = 0) -> Optional[str]:
|
||||||
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
|
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
|
||||||
|
|
||||||
upload_index: 从1开始,先正面后背面,由调用方维护
|
upload_index: 每台机器独立,从0开始
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
|
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
|
||||||
if resp.status_code != 200 or not resp.content:
|
if resp.status_code != 200 or not resp.content:
|
||||||
logger.error("arm snapshot 请求失败")
|
self._log(f" 📷 拍照 arm snapshot 失败: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 生成文件名(用于上传)
|
# 生成文件名(用于上传)
|
||||||
@@ -843,13 +932,14 @@ class MissionExecutorV3:
|
|||||||
|
|
||||||
# 直接上传到服务器(不保存本地)
|
# 直接上传到服务器(不保存本地)
|
||||||
if qr_value:
|
if qr_value:
|
||||||
|
self._log(f" 📷 拍照成功 {len(resp.content)} bytes → {fname}")
|
||||||
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
|
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
|
||||||
else:
|
else:
|
||||||
self._log(" ⚠️ 无二维码,跳过上传")
|
self._log(" ⚠️ 无二维码,跳过上传")
|
||||||
|
|
||||||
return fname # 返回文件名(用于日志)
|
return fname # 返回文件名(用于日志)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"拍照异常: {e}")
|
self._log(f" ❌ 拍照异常: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
|
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
|
||||||
@@ -862,13 +952,14 @@ class MissionExecutorV3:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
filename = os.path.basename(filepath)
|
filename = os.path.basename(filepath)
|
||||||
headers = {
|
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
|
||||||
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
upload_url = UPLOAD_CONFIG["url"]
|
||||||
}
|
self._log(f" 📤 上传请求 → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
files = {"file": (filename, f, "image/jpeg")}
|
files = {"file": (filename, f, "image/jpeg")}
|
||||||
data = {"serialNumber": serial_number, "index": str(index)}
|
data = {"serialNumber": serial_number, "index": str(index)}
|
||||||
resp = requests.post(UPLOAD_URL, files=files, data=data, headers=headers, timeout=30)
|
resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
|
||||||
|
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
||||||
return True
|
return True
|
||||||
@@ -879,6 +970,33 @@ class MissionExecutorV3:
|
|||||||
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _upload_photo_bytes(self, filename: str, image_data: bytes, serial_number: str, index: int) -> bool:
|
||||||
|
"""上传照片 bytes 到远程服务器(不保存本地文件)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: 文件名(用于服务器存储)
|
||||||
|
image_data: 图片二进制数据
|
||||||
|
serial_number: 二维码/序列号
|
||||||
|
index: 上传序号(从1开始递增)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
|
||||||
|
upload_url = UPLOAD_CONFIG["url"]
|
||||||
|
self._log(f" 📤 上传请求(内存) → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
|
||||||
|
files = {"file": (filename, image_data, "image/jpeg")}
|
||||||
|
data = {"serialNumber": serial_number, "index": str(index)}
|
||||||
|
resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
|
||||||
|
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._log(f" ⚠️ 上传失败 [{index}] HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# ==================== 控制 ====================
|
# ==================== 控制 ====================
|
||||||
|
|
||||||
def _wait_pause(self):
|
def _wait_pause(self):
|
||||||
|
|||||||
+86
-15
@@ -29,19 +29,22 @@ class QRScanner:
|
|||||||
def open(self) -> bool:
|
def open(self) -> bool:
|
||||||
"""打开摄像头"""
|
"""打开摄像头"""
|
||||||
try:
|
try:
|
||||||
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
|
# 强制 V4L2 后端
|
||||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||||
if self._cap.isOpened():
|
if not self._cap.isOpened():
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# fallback: 不指定后端
|
|
||||||
self._cap = cv2.VideoCapture(self.device_index)
|
self._cap = cv2.VideoCapture(self.device_index)
|
||||||
if self._cap.isOpened():
|
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
|
if not self._cap.isOpened():
|
||||||
return True
|
|
||||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 确保 OpenCV 做 BGR 转换(部分 V4L2 后端默认不做 YUYV→BGR 转换)
|
||||||
|
self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
|
||||||
|
# 设置分辨率(使用默认分辨率,不强制 MJPG)
|
||||||
|
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
logger.info(f"摄像头 {self.device_index} 已打开,分辨率 {w}x{h}")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"摄像头打开失败: {e}")
|
logger.error(f"摄像头打开失败: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -51,14 +54,82 @@ class QRScanner:
|
|||||||
self._cap.release()
|
self._cap.release()
|
||||||
self._cap = None
|
self._cap = None
|
||||||
|
|
||||||
def read_frame(self) -> Optional[np.ndarray]:
|
def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]:
|
||||||
"""读取一帧"""
|
"""修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None"""
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
if h < 10 or w < 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ndim = len(frame.shape)
|
||||||
|
|
||||||
|
# 情况 1: 2 通道原始 YUYV → 手动转换 BGR
|
||||||
|
if ndim == 2:
|
||||||
|
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUYV)
|
||||||
|
logger.debug("YUYV 2ch → BGR 转换")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
# 情况 2: 3 通道但实际帧数据显示为 YUYV(绿屏特征:G 通道全满,B/R 近空)
|
||||||
|
if ndim == 3:
|
||||||
|
g_mean = frame[:, :, 1].mean()
|
||||||
|
if g_mean > 80 and frame[:, :, 0].mean() < 30 and frame[:, :, 2].mean() < 30:
|
||||||
|
# 典型的"Lime"绿屏 — 当做 YUYV 原始数据解码
|
||||||
|
logger.debug(f"检测到绿屏 (G={g_mean:.0f}, B={frame[:,:,0].mean():.0f}, R={frame[:,:,2].mean():.0f}),尝试修复")
|
||||||
|
try:
|
||||||
|
# 把内存当做 YUYV 数据重新解析
|
||||||
|
raw_bytes = frame.tobytes()
|
||||||
|
# 3ch w*h 的数据量 = w*h*3 字节
|
||||||
|
# YUYV 每像素 2 字节,所以一幅 YUYV 图像的总字节 = w*h*2
|
||||||
|
# 我们只需要取前 w*h*2 字节作为 YUYV 数据
|
||||||
|
yuyv_len = w * h * 2
|
||||||
|
if len(raw_bytes) >= yuyv_len:
|
||||||
|
yuyv_img = np.frombuffer(raw_bytes[:yuyv_len], dtype=np.uint8).reshape(h, w, 2)
|
||||||
|
frame = cv2.cvtColor(yuyv_img, cv2.COLOR_YUV2BGR_YUYV)
|
||||||
|
logger.debug("绿屏修复完成")
|
||||||
|
return frame
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"绿屏修复失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 情况 3: 全黑帧
|
||||||
|
if frame.mean() < 5:
|
||||||
|
logger.warning("全黑帧,丢弃")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 正常 BGR 帧
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def read_frame(self, timeout: float = 2.0) -> Optional[np.ndarray]:
|
||||||
|
"""读取一帧(带超时保护,避免 V4L2 select() 永久阻塞)"""
|
||||||
if not self._cap or not self._cap.isOpened():
|
if not self._cap or not self._cap.isOpened():
|
||||||
return None
|
return None
|
||||||
ret, frame = self._cap.read()
|
|
||||||
if not ret:
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||||
|
|
||||||
|
pool = ThreadPoolExecutor(max_workers=1)
|
||||||
|
try:
|
||||||
|
fut = pool.submit(self._cap.read)
|
||||||
|
ret, frame = fut.result(timeout=timeout)
|
||||||
|
if not ret or frame is None:
|
||||||
|
return None
|
||||||
|
return self._fix_frame(frame)
|
||||||
|
except FuturesTimeout:
|
||||||
|
logger.warning(f"摄像头 read_frame 超时 ({timeout}s),尝试重建 _cap")
|
||||||
|
self.close()
|
||||||
|
self.open()
|
||||||
|
# 重建后重试一次
|
||||||
|
if self._cap and self._cap.isOpened():
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if ret and frame is not None:
|
||||||
|
return self._fix_frame(frame)
|
||||||
return None
|
return None
|
||||||
return frame
|
except Exception as e:
|
||||||
|
logger.error(f"read_frame 异常: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
|
||||||
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||||
"""从图像帧中检测二维码"""
|
"""从图像帧中检测二维码"""
|
||||||
@@ -96,4 +167,4 @@ class QRScanner:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
self.close()
|
self.close()
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""
|
||||||
|
机械臂服务端 - 机械臂端主程序
|
||||||
|
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
|
||||||
|
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot)
|
||||||
|
同时通过 ffmpeg 提供 HTTP 视频流
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
from flask import Flask, Response, jsonify
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
|
# 添加当前目录到路径
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
LOG_FILE = os.environ.get("ARM_SERVER_LOG_FILE", os.path.join(BASE_DIR, "server.log"))
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler(LOG_FILE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("arm_server")
|
||||||
|
|
||||||
|
# ========== Flask HTTP 服务器 - 视频流 (ffmpeg) ==========
|
||||||
|
arm_video_app = Flask(__name__)
|
||||||
|
|
||||||
|
ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号
|
||||||
|
_ffmpeg_proc = None
|
||||||
|
_ffmpeg_thread = None
|
||||||
|
_ffmpeg_lock = threading.Lock()
|
||||||
|
_frame_cond = threading.Condition()
|
||||||
|
_latest_frame = None
|
||||||
|
_latest_frame_ts = 0.0
|
||||||
|
_stop_ffmpeg_reader = threading.Event()
|
||||||
|
_invalid_count = 0 # 连续无效帧计数
|
||||||
|
_MAX_INVALID = 30 # 连续 30 帧无效 → 重启 ffmpeg
|
||||||
|
_MAX_BUF_SIZE = 2 * 1024 * 1024 # 2MB buffer 上限
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_jpeg(data):
|
||||||
|
"""验证 JPEG 数据是否有效,返回 True/False"""
|
||||||
|
try:
|
||||||
|
Image.open(io.BytesIO(data)).verify()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_ffmpeg():
|
||||||
|
"""停止 ffmpeg 采集进程和读帧线程。"""
|
||||||
|
global _ffmpeg_proc
|
||||||
|
_stop_ffmpeg_reader.set()
|
||||||
|
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
|
||||||
|
_ffmpeg_proc.terminate()
|
||||||
|
try:
|
||||||
|
_ffmpeg_proc.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_ffmpeg_proc.kill()
|
||||||
|
_ffmpeg_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_reader():
|
||||||
|
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,校验有效性并缓存最新一帧。
|
||||||
|
|
||||||
|
当摄像头 USB 掉线重连时,ffmpeg 会从失效 fd 读取垃圾数据,
|
||||||
|
产生假 JPEG 帧(花屏)。这里通过 PIL 校验帧有效性,
|
||||||
|
连续无效帧过多时自动重启 ffmpeg 恢复。
|
||||||
|
"""
|
||||||
|
global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
|
||||||
|
buf = b""
|
||||||
|
while not _stop_ffmpeg_reader.is_set():
|
||||||
|
proc = _ffmpeg_proc
|
||||||
|
if proc is None or proc.poll() is not None or proc.stdout is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
chunk = proc.stdout.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
continue
|
||||||
|
buf += chunk
|
||||||
|
# 防止垃圾数据撑爆内存
|
||||||
|
if len(buf) > _MAX_BUF_SIZE:
|
||||||
|
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
|
||||||
|
buf = b""
|
||||||
|
_stop_ffmpeg()
|
||||||
|
continue
|
||||||
|
while True:
|
||||||
|
start = buf.find(b"\xff\xd8")
|
||||||
|
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
|
||||||
|
if start < 0:
|
||||||
|
buf = buf[-2:]
|
||||||
|
break
|
||||||
|
if end < 0:
|
||||||
|
buf = buf[start:]
|
||||||
|
break
|
||||||
|
frame = buf[start:end + 2]
|
||||||
|
buf = buf[end + 2:]
|
||||||
|
# JPEG 校验:摄像头掉线时帧数据会损坏
|
||||||
|
if _validate_jpeg(frame):
|
||||||
|
with _frame_cond:
|
||||||
|
_latest_frame = frame
|
||||||
|
_latest_frame_ts = time.time()
|
||||||
|
_frame_cond.notify_all()
|
||||||
|
_invalid_count = 0
|
||||||
|
else:
|
||||||
|
_invalid_count += 1
|
||||||
|
if _invalid_count >= _MAX_INVALID:
|
||||||
|
logger.error(f"连续 {_MAX_INVALID} 帧无效,摄像头可能掉线,重启 ffmpeg")
|
||||||
|
_stop_ffmpeg()
|
||||||
|
_invalid_count = 0
|
||||||
|
break # 跳出循环让 _ensure_ffmpeg 重建
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_ffmpeg():
|
||||||
|
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
|
||||||
|
global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
|
||||||
|
with _ffmpeg_lock:
|
||||||
|
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
_stop_ffmpeg_reader.set()
|
||||||
|
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
|
||||||
|
_ffmpeg_proc.terminate()
|
||||||
|
_stop_ffmpeg_reader.clear()
|
||||||
|
_invalid_count = 0 # 重置错误计数
|
||||||
|
|
||||||
|
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
|
||||||
|
_ffmpeg_proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-f", "v4l2",
|
||||||
|
"-input_format", "mjpeg",
|
||||||
|
"-framerate", "8",
|
||||||
|
"-video_size", "640x480",
|
||||||
|
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
||||||
|
"-fflags", "nobuffer",
|
||||||
|
|
||||||
|
"-analyzeduration", "0",
|
||||||
|
"-f", "mjpeg",
|
||||||
|
"-"
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
_ffmpeg_thread = threading.Thread(target=_frame_reader, daemon=True)
|
||||||
|
_ffmpeg_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_frame(timeout: float = 3.0):
|
||||||
|
"""返回缓存的最新 JPEG 帧;必要时等待首帧。"""
|
||||||
|
_ensure_ffmpeg()
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
with _frame_cond:
|
||||||
|
while _latest_frame is None and time.time() < deadline:
|
||||||
|
_frame_cond.wait(timeout=0.2)
|
||||||
|
return _latest_frame
|
||||||
|
|
||||||
|
|
||||||
|
@arm_video_app.route("/api/camera/preview")
|
||||||
|
def arm_camera_preview():
|
||||||
|
"""机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。"""
|
||||||
|
_ensure_ffmpeg()
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
last_ts = 0.0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame = _get_latest_frame(timeout=3.0)
|
||||||
|
if frame is None:
|
||||||
|
logger.warning("等待摄像头帧超时,重启 ffmpeg")
|
||||||
|
_stop_ffmpeg()
|
||||||
|
continue
|
||||||
|
with _frame_cond:
|
||||||
|
if _latest_frame_ts <= last_ts:
|
||||||
|
_frame_cond.wait(timeout=1.0)
|
||||||
|
frame = _latest_frame
|
||||||
|
last_ts = _latest_frame_ts
|
||||||
|
if frame:
|
||||||
|
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"视频流异常: {e}")
|
||||||
|
finally:
|
||||||
|
logger.info("视频流连接关闭")
|
||||||
|
|
||||||
|
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
|
|
||||||
|
@arm_video_app.route("/api/camera/status")
|
||||||
|
def arm_camera_status():
|
||||||
|
"""摄像头状态"""
|
||||||
|
global _ffmpeg_proc
|
||||||
|
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
|
||||||
|
return jsonify({
|
||||||
|
"opened": running,
|
||||||
|
"frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None,
|
||||||
|
"invalid_count": _invalid_count
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@arm_video_app.route("/api/camera/restart", methods=["POST"])
|
||||||
|
def arm_camera_restart():
|
||||||
|
"""重启视频流"""
|
||||||
|
global _latest_frame, _latest_frame_ts, _invalid_count
|
||||||
|
_stop_ffmpeg()
|
||||||
|
with _frame_cond:
|
||||||
|
_latest_frame = None
|
||||||
|
_latest_frame_ts = 0.0
|
||||||
|
_invalid_count = 0
|
||||||
|
_ensure_ffmpeg()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@arm_video_app.route("/api/camera/snapshot")
|
||||||
|
def arm_camera_snapshot():
|
||||||
|
"""机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。"""
|
||||||
|
frame = _get_latest_frame(timeout=3.0)
|
||||||
|
if frame:
|
||||||
|
r = Response(frame, mimetype="image/jpeg")
|
||||||
|
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
r.headers["Pragma"] = "no-cache"
|
||||||
|
r.headers["Expires"] = "0"
|
||||||
|
return r
|
||||||
|
logger.warning("snapshot failed: no cached frame")
|
||||||
|
return "", 500
|
||||||
|
|
||||||
|
|
||||||
|
# ========== TCP 服务器 - 接收 AGV 指令 ==========
|
||||||
|
class AGVCommandServer:
|
||||||
|
"""TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
|
||||||
|
|
||||||
|
def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._sock: socket.socket = None
|
||||||
|
self._running = False
|
||||||
|
# 直接从外部注入已激活的 ElephantRobot 实例
|
||||||
|
if elephant is None:
|
||||||
|
logger.warning("ElephantRobot 实例为空,命令将返回错误")
|
||||||
|
self._el = elephant
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self._sock.bind((self.host, self.port))
|
||||||
|
self._sock.listen(5)
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"=" * 50)
|
||||||
|
logger.info(f"机械臂服务端已启动,监听 {self.host}:{self.port}")
|
||||||
|
logger.info(f"等待 AGV 连接...")
|
||||||
|
logger.info(f"=" * 50)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._sock.settimeout(1.0)
|
||||||
|
try:
|
||||||
|
client_sock, addr = self._sock.accept()
|
||||||
|
logger.info(f"AGV 已连接: {addr}")
|
||||||
|
threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True).start()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
if self._running:
|
||||||
|
logger.error(f"服务器异常: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
def _handle_client(self, client_sock: socket.socket):
|
||||||
|
try:
|
||||||
|
client_sock.settimeout(30)
|
||||||
|
buffer = ""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
data = client_sock.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buffer += data.decode("utf-8")
|
||||||
|
while "\n" in buffer:
|
||||||
|
line, buffer = buffer.split("\n", 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
response = self._execute_command(line)
|
||||||
|
client_sock.sendall((response + "\n").encode("utf-8"))
|
||||||
|
logger.info(f"CMD: {line} → {response}")
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"客户端处理异常: {e}")
|
||||||
|
finally:
|
||||||
|
client_sock.close()
|
||||||
|
logger.info("AGV 客户端已断开")
|
||||||
|
|
||||||
|
def _execute_command(self, cmd: str) -> str:
|
||||||
|
"""通过 ElephantRobot.send_command 转发给 RoboFlow"""
|
||||||
|
if self._el is None:
|
||||||
|
return "ERROR: Robot not initialized"
|
||||||
|
try:
|
||||||
|
return self._el.send_command(cmd)
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: {e}"
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._sock:
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
logger.info("机械臂服务端已停止")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 入口 ==========
|
||||||
|
_elephant = None # 全局 ElephantRobot 实例
|
||||||
|
|
||||||
|
def power_on_arm(max_retries: int = 5) -> bool:
|
||||||
|
"""通过 ElephantRobot 给机械臂上电并激活(带重试),返回 ElephantRobot 实例"""
|
||||||
|
global _elephant
|
||||||
|
from pymycobot import ElephantRobot
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
logger.info(f"正在通过 ElephantRobot 连接 RoboFlow (尝试 {attempt}/{max_retries})...")
|
||||||
|
el = ElephantRobot("127.0.0.1", 5001)
|
||||||
|
el.start_client()
|
||||||
|
logger.info("ElephantRobot start_client 成功,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el._power_on()
|
||||||
|
logger.info("power_on 指令已发送,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el.start_robot()
|
||||||
|
logger.info("start_robot 指令已发送,等待5秒...")
|
||||||
|
time.sleep(5)
|
||||||
|
logger.info("✅ 机械臂上电+激活 全部完成")
|
||||||
|
|
||||||
|
# 保存到全局,确保后续复用
|
||||||
|
_elephant = el
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.info(f"等待 3 秒后重试...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ 所有 {max_retries} 次尝试均失败,将以 limited 模式运行")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import signal
|
||||||
|
|
||||||
|
# 先通过 ElephantRobot 给机械臂上电并激活
|
||||||
|
power_on_arm()
|
||||||
|
|
||||||
|
# 将全局 _elephant 传给指令服务器
|
||||||
|
server = AGVCommandServer(_elephant, port=5002)
|
||||||
|
|
||||||
|
# 启动 Flask 视频流服务(端口 5003)
|
||||||
|
arm_server_http = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
|
||||||
|
break
|
||||||
|
except OSError as e:
|
||||||
|
if attempt < 4 and "Address already in use" in str(e):
|
||||||
|
logger.warning(f"端口 5003 被占用(第{attempt+1}次),等待...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
|
||||||
|
http_thread.start()
|
||||||
|
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
logger.info("收到停止信号...")
|
||||||
|
global _ffmpeg_proc, _elephant
|
||||||
|
if _ffmpeg_proc:
|
||||||
|
_ffmpeg_proc.terminate()
|
||||||
|
server.stop()
|
||||||
|
arm_server_http.shutdown()
|
||||||
|
if _elephant:
|
||||||
|
try:
|
||||||
|
_elephant.stop_client()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+57
-19
@@ -15,7 +15,9 @@ from flask import Flask, Response, jsonify
|
|||||||
from werkzeug.serving import make_server
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
# 添加当前目录到路径
|
# 添加当前目录到路径
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
LOG_FILE = os.environ.get("ARM_SERVER_LOG_FILE", os.path.join(BASE_DIR, "server.log"))
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -23,7 +25,7 @@ logging.basicConfig(
|
|||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
logging.FileHandler(os.path.expanduser("~/work/arm_server/server.log"))
|
logging.FileHandler(LOG_FILE)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger("arm_server")
|
logger = logging.getLogger("arm_server")
|
||||||
@@ -105,11 +107,11 @@ def _ensure_ffmpeg():
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-f", "v4l2",
|
"-f", "v4l2",
|
||||||
"-input_format", "mjpeg",
|
"-input_format", "mjpeg",
|
||||||
"-framerate", "15",
|
"-framerate", "12",
|
||||||
"-video_size", "640x480",
|
"-video_size", "1280x720",
|
||||||
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
||||||
"-vf", "rotate=PI",
|
"-vf", "rotate=PI",
|
||||||
"-q:v", "8",
|
"-q:v", "4",
|
||||||
"-f", "mjpeg",
|
"-f", "mjpeg",
|
||||||
"-"
|
"-"
|
||||||
],
|
],
|
||||||
@@ -254,19 +256,7 @@ class AGVCommandServer:
|
|||||||
def _connect_roboflow(self):
|
def _connect_roboflow(self):
|
||||||
self.roboflow = RoboFlowClient()
|
self.roboflow = RoboFlowClient()
|
||||||
if self.roboflow.connect():
|
if self.roboflow.connect():
|
||||||
logger.info("RoboFlow 连接成功")
|
logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)")
|
||||||
# 连接成功后自动上电并激活机械臂
|
|
||||||
time.sleep(1)
|
|
||||||
try:
|
|
||||||
resp = self.roboflow.send_recv("power_on()")
|
|
||||||
logger.info(f"机械臂上电: {resp}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"机械臂上电失败: {e}")
|
|
||||||
try:
|
|
||||||
resp = self.roboflow.send_recv("state_on()")
|
|
||||||
logger.info(f"机械臂激活: {resp}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"机械臂激活失败: {e}")
|
|
||||||
else:
|
else:
|
||||||
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
|
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
|
||||||
|
|
||||||
@@ -342,13 +332,61 @@ class AGVCommandServer:
|
|||||||
|
|
||||||
|
|
||||||
# ========== 入口 ==========
|
# ========== 入口 ==========
|
||||||
|
import time
|
||||||
|
|
||||||
|
def power_on_arm(max_retries: int = 5) -> bool:
|
||||||
|
"""通过 ElephantRobot 给机械臂上电并激活(带重试)"""
|
||||||
|
from pymycobot import ElephantRobot
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
logger.info(f"正在通过 ElephantRobot 连接 RoboFlow (尝试 {attempt}/{max_retries})...")
|
||||||
|
el = ElephantRobot("127.0.0.1", 5001)
|
||||||
|
el.start_client()
|
||||||
|
logger.info("ElephantRobot start_client 成功,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el._power_on()
|
||||||
|
logger.info("power_on 指令已发送,等待2秒...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
el.start_robot()
|
||||||
|
logger.info("start_robot 指令已发送,等待5秒...")
|
||||||
|
time.sleep(5)
|
||||||
|
logger.info("✅ 机械臂上电+激活 全部完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.info(f"等待 3 秒后重试...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
logger.error(f"❌ 所有 {max_retries} 次尝试均失败,将以 limited 模式运行")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
|
# 先通过 ElephantRobot 给机械臂上电并激活
|
||||||
|
power_on_arm()
|
||||||
|
|
||||||
server = AGVCommandServer(port=5002)
|
server = AGVCommandServer(port=5002)
|
||||||
|
|
||||||
# 启动 Flask 视频流服务(端口 5003)
|
# 启动 Flask 视频流服务(端口 5003)
|
||||||
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
|
from werkzeug.serving import make_server
|
||||||
|
arm_server_http = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
|
||||||
|
break
|
||||||
|
except OSError as e:
|
||||||
|
if attempt < 4 and "Address already in use" in str(e):
|
||||||
|
logger.warning(f"端口 5003 被占用(第{attempt+1}次),等待...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
|
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
|
||||||
http_thread.start()
|
http_thread.start()
|
||||||
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Arm Server (TCP 5002 + Camera 5003)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/home/pi/work/smart-inspection/arm_server
|
||||||
|
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStartPre=/bin/sleep 5
|
||||||
|
ExecStart=/usr/bin/env uv run --locked python arm_server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/home/pi/work/smart-inspection/arm_server/stdout.log
|
||||||
|
StandardError=append:/home/pi/work/smart-inspection/arm_server/stderr.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 机械臂端依赖(最少依赖)
|
|
||||||
# RoboFlow 已在树莓派上运行,此端仅做透传
|
|
||||||
flask>=1.0,<2.3
|
|
||||||
+6
-8
@@ -1,12 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 启动机械臂服务端
|
# 启动机械臂服务端
|
||||||
|
set -e
|
||||||
|
|
||||||
cd ~/work/arm_server
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ARM_SERVER_DIR="${ARM_SERVER_DIR:-$PROJECT_DIR/arm_server}"
|
||||||
|
|
||||||
if ! "$PYTHON_BIN" -c "import flask" >/dev/null 2>&1; then
|
cd "$ARM_SERVER_DIR"
|
||||||
echo "Flask 未安装,正在安装 requirements.txt..."
|
exec uv run --locked python arm_server.py
|
||||||
"$PYTHON_BIN" -m pip install --user -r requirements.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$PYTHON_BIN" arm_server.py
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
+6458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "customs-tablet-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.2.5",
|
||||||
|
"@ant-design/nextjs-registry": "^1.3.0",
|
||||||
|
"antd": "^6.4.4",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"photoswipe": "^5.4.4",
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"react-photoswipe-gallery": "^4.1.2",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.35",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Table, Form, Button, DatePicker, Select, Space, Row, Col, Input } from 'antd';
|
||||||
|
import { SearchOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { CustomsDeclaration } from '../../types';
|
||||||
|
import { StatusBadge } from '../../components/StatusBadge';
|
||||||
|
import { useAppStore } from '../../store/useAppStore';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function CustomsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [filteredData, setFilteredData] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const { setSelectedCustoms } = useAppStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadCustomsList = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const res = await MockApi.getCustomsList();
|
||||||
|
if (!isMounted) return;
|
||||||
|
setData(res);
|
||||||
|
setFilteredData(res);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('报关单列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCustomsList();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartInspection = (record: CustomsDeclaration) => {
|
||||||
|
setSelectedCustoms(record);
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(record.customsId)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const keyword = values.searchText?.trim().toLowerCase() || '';
|
||||||
|
const status = values.statusFilter || 'all';
|
||||||
|
const dateRange = values.dateRange;
|
||||||
|
|
||||||
|
const nextData = data.filter(item => {
|
||||||
|
const matchesStatus = status === 'all' || item.status === status;
|
||||||
|
const matchesKeyword = !keyword || item.customsId.toLowerCase().includes(keyword);
|
||||||
|
const createdAt = new Date(item.createdAt);
|
||||||
|
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1]
|
||||||
|
|| (createdAt >= dateRange[0].startOf('day').toDate() && createdAt <= dateRange[1].endOf('day').toDate());
|
||||||
|
|
||||||
|
return matchesStatus && matchesKeyword && matchesDateRange;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredData(nextData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setFilteredData(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandedRowRender = (record: CustomsDeclaration) => {
|
||||||
|
const columns = [
|
||||||
|
{ title: '料号', dataIndex: 'inventoryCode', key: 'inventoryCode' },
|
||||||
|
{ title: '品名', dataIndex: 'inventoryName', key: 'inventoryName' },
|
||||||
|
{ title: '规格', dataIndex: 'spec', key: 'spec' },
|
||||||
|
{ title: '总数', dataIndex: 'quantify', key: 'quantify' },
|
||||||
|
{ title: '已查验', dataIndex: 'inspected', key: 'inspected' },
|
||||||
|
];
|
||||||
|
return <Table columns={columns} dataSource={record.items} pagination={false} size="small" rowKey="inventoryCode" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title="筛选条件" style={{ marginBottom: 24 }}>
|
||||||
|
<Form form={form} layout="inline">
|
||||||
|
<Row gutter={24} align="middle">
|
||||||
|
<Col>
|
||||||
|
<Form.Item label="状态" name="statusFilter" initialValue="all">
|
||||||
|
<Select style={{ width: 120 }}>
|
||||||
|
<Select.Option value="all">全部</Select.Option>
|
||||||
|
<Select.Option value="pending">待查验</Select.Option>
|
||||||
|
<Select.Option value="inspecting">查验中</Select.Option>
|
||||||
|
<Select.Option value="released">已放行</Select.Option>
|
||||||
|
<Select.Option value="abnormal">异常</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Form.Item label="日期范围" name="dateRange">
|
||||||
|
<RangePicker />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Form.Item name="searchText">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索报关单号..."
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
style={{ width: 250 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" onClick={handleSearch}>查询</Button>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="报关单列表" styles={{ body: { padding: 0 } }}>
|
||||||
|
<Table
|
||||||
|
dataSource={filteredData}
|
||||||
|
loading={loading}
|
||||||
|
rowKey="id"
|
||||||
|
expandable={{ expandedRowRender }}
|
||||||
|
>
|
||||||
|
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text: string) => <b>{text}</b>} />
|
||||||
|
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
|
||||||
|
<Table.Column title="机器总数" dataIndex="machineCount" key="machineCount" render={(count: number) => `${count} 台`} />
|
||||||
|
<Table.Column title="创建时间" dataIndex="createdAt" key="createdAt" />
|
||||||
|
<Table.Column
|
||||||
|
title="操作"
|
||||||
|
key="action"
|
||||||
|
render={(_, record: CustomsDeclaration) => (
|
||||||
|
<Space size="middle">
|
||||||
|
{record.status === 'pending' || record.status === 'inspecting' ? (
|
||||||
|
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleStartInspection(record)}>
|
||||||
|
开始查验
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="link">查看详情</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
/* Ant Design 自带主题 Token,移除不必要的自定义变量 */
|
||||||
|
:root {
|
||||||
|
--color-border-light: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
background: #f0f2f5;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appBody {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.antAppRoot {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appMain {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { Suspense, useEffect, useState, useRef } from 'react';
|
||||||
|
import { Alert, Row, Col, Card, Button, Progress, List, Typography, Space, Modal, Input, Empty, Badge, Spin, Flex, Select, Segmented, theme, Divider, Timeline } from 'antd';
|
||||||
|
import {
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CaretRightOutlined,
|
||||||
|
PauseCircleFilled,
|
||||||
|
VideoCameraOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { CustomsDeclaration, InspectionItem } from '../../types';
|
||||||
|
import { useAppStore } from '../../store/useAppStore';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
type InspectionStatus = 'idle' | 'running' | 'paused' | 'completed';
|
||||||
|
|
||||||
|
interface ProgressItem extends InspectionItem {
|
||||||
|
currentInspected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
|
||||||
|
<Spin tip="正在加载查验任务..." />
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
<InspectionContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectionContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const customsId = searchParams.get('customsId');
|
||||||
|
const { selectedCustoms, setSelectedCustoms } = useAppStore();
|
||||||
|
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
|
||||||
|
const [loadingCustoms, setLoadingCustoms] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [status, setStatus] = useState<InspectionStatus>('idle');
|
||||||
|
const [logs, setLogs] = useState<{time: string, msg: string, type: 'info'|'warning'|'success'}[]>([]);
|
||||||
|
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
|
||||||
|
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
|
||||||
|
const [pauseReason, setPauseReason] = useState('');
|
||||||
|
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [currentView, setCurrentView] = useState<string>('摄像头1');
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingList(true);
|
||||||
|
MockApi.getCustomsList().then(list => {
|
||||||
|
setCustomsList(list);
|
||||||
|
setLoadingList(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadInspectionCustoms = async () => {
|
||||||
|
setLoadingCustoms(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (customsId) {
|
||||||
|
const cachedCustoms = selectedCustoms?.customsId === customsId ? selectedCustoms : null;
|
||||||
|
const customs = cachedCustoms ?? await MockApi.getCustomsById(customsId);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!customs) {
|
||||||
|
setCurrentCustoms(null);
|
||||||
|
setErrorMessage(`未找到报关单 ${customsId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentCustoms(customs);
|
||||||
|
setSelectedCustoms(customs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentCustoms(selectedCustoms);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCurrentCustoms(null);
|
||||||
|
setErrorMessage('查验任务加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoadingCustoms(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInspectionCustoms();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [customsId, selectedCustoms, setSelectedCustoms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentCustoms) {
|
||||||
|
setProgressData([]);
|
||||||
|
setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgressData(currentCustoms.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
currentInspected: item.inspected
|
||||||
|
})));
|
||||||
|
setStatus(currentCustoms.status === 'inspecting' ? 'running' : 'idle');
|
||||||
|
setLogs([]);
|
||||||
|
}, [currentCustoms]);
|
||||||
|
|
||||||
|
// 模拟查验过程
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (status === 'running') {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
setProgressData(prev => {
|
||||||
|
let allDone = true;
|
||||||
|
const next = prev.map(item => {
|
||||||
|
if (item.currentInspected < item.quantify) {
|
||||||
|
allDone = false;
|
||||||
|
// 随机增加进度
|
||||||
|
if (Math.random() > 0.5) {
|
||||||
|
const newInspected = Math.min(item.currentInspected + 1, item.quantify);
|
||||||
|
if (newInspected > item.currentInspected) {
|
||||||
|
addLog(`料号 ${item.inventoryCode} (${item.inventoryName}) 核销 +1`, 'info');
|
||||||
|
}
|
||||||
|
return { ...item, currentInspected: newInspected };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
setStatus('completed');
|
||||||
|
addLog('全部机器核销完成', 'success');
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 3000); // 每 3 秒更新一次模拟数据
|
||||||
|
}
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// 自动滚动到最新日志
|
||||||
|
useEffect(() => {
|
||||||
|
if (logsEndRef.current) {
|
||||||
|
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const addLog = (msg: string, type: 'info'|'warning'|'success' = 'info') => {
|
||||||
|
setLogs(prev => [...prev, { time: new Date().toLocaleTimeString(), msg, type }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
setStatus('running');
|
||||||
|
addLog('开始自动化查验作业', 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPauseModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPause = () => {
|
||||||
|
setStatus('paused');
|
||||||
|
addLog(`查验已暂停。原因:${pauseReason || '未填写'}`, 'warning');
|
||||||
|
setIsPauseModalVisible(false);
|
||||||
|
setPauseReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认结束查验?',
|
||||||
|
content: '结束查验后无法继续当前任务。',
|
||||||
|
onOk: () => {
|
||||||
|
setStatus('completed');
|
||||||
|
addLog('用户手动结束查验', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotalProgress = () => {
|
||||||
|
if (!progressData.length) return 0;
|
||||||
|
const total = progressData.reduce((acc, curr) => acc + curr.quantify, 0);
|
||||||
|
const inspected = progressData.reduce((acc, curr) => acc + curr.currentInspected, 0);
|
||||||
|
return Math.round((inspected / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingCustoms) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
|
||||||
|
<Spin tip="正在加载查验任务..." />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
|
||||||
|
{/* 左侧:AGV 及监控画面 */}
|
||||||
|
<Col span={14}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Space>
|
||||||
|
<VideoCameraOutlined style={{ color: token.colorPrimary }} />
|
||||||
|
<span>AGV 实时画面</span>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Badge status="processing" text="设备在线" />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', flex: 1 } }}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG, overflow: 'hidden' }}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#000000'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'running' ? (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
|
||||||
|
<CaretRightOutlined style={{ fontSize: 48, color: token.colorPrimary, opacity: 0.8 }} />
|
||||||
|
<Text style={{ color: '#fff' }}>正在接收实时流...</Text>
|
||||||
|
<div style={{ padding: '8px 16px', border: `1px dashed ${token.colorPrimary}`, borderRadius: token.borderRadius }}>
|
||||||
|
<span style={{ color: token.colorPrimary }}>AI 识别分析中</span>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: token.colorTextDescription }}>
|
||||||
|
<PauseCircleFilled style={{ fontSize: 48 }} />
|
||||||
|
<Text type="secondary">画面已暂停或未启动</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 24px', borderTop: `1px solid ${token.colorBorderSecondary}`, background: token.colorFillAlter }}>
|
||||||
|
<Flex align="center" gap="middle">
|
||||||
|
<Text strong>视角切换:</Text>
|
||||||
|
<Segmented
|
||||||
|
options={['摄像头1', '摄像头2', '摄像头3', '摄像头4', '摄像头5']}
|
||||||
|
value={currentView}
|
||||||
|
onChange={setCurrentView}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧:查验控制面板 */}
|
||||||
|
<Col span={10}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<span>查验任务</span>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择报关单..."
|
||||||
|
style={{ width: 240 }}
|
||||||
|
loading={loadingList}
|
||||||
|
optionFilterProp="label"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
options={customsList.map(item => ({
|
||||||
|
value: item.customsId,
|
||||||
|
label: `${item.customsId} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : '已放行'}`
|
||||||
|
}))}
|
||||||
|
value={currentCustoms?.customsId || undefined}
|
||||||
|
onChange={(value) => {
|
||||||
|
router.push(`/inspection?customsId=${value}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
styles={{ body: { overflow: 'hidden', padding: '24px', display: 'flex', flexDirection: 'column', flex: 1 } }}
|
||||||
|
style={{ height: '100%', display: 'flex', flexDirection: 'column', borderRadius: token.borderRadiusLG }}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
{!currentCustoms ? (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ flex: 1 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24, width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Empty description="请在右上角选择要查验的报关单" />
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Flex justify="center" gap="middle" style={{ marginBottom: 32 }}>
|
||||||
|
{status === 'idle' || status === 'paused' ? (
|
||||||
|
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} style={{ width: 140 }}>
|
||||||
|
{status === 'idle' ? '开始查验' : '继续查验'}
|
||||||
|
</Button>
|
||||||
|
) : status === 'running' ? (
|
||||||
|
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={handlePause} style={{ width: 140 }}>
|
||||||
|
暂停查验
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'}>重置</Button>
|
||||||
|
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}>结束</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong>当前核销进度</Text></Divider>
|
||||||
|
<div style={{ marginBottom: 16, padding: '0 8px' }}>
|
||||||
|
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: '0 1 35%', marginBottom: 24, overflowY: 'auto', paddingRight: 8 }}>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={progressData}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item style={{ padding: '12px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
|
||||||
|
<Text strong>{item.inventoryName}</Text>
|
||||||
|
<Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>{item.inventoryCode}</Text>
|
||||||
|
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
<Progress percent={Math.round((item.currentInspected / item.quantify) * 100)} showInfo={false} size="small" />
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider titlePlacement="start" plain style={{ margin: '0 0 16px 0' }}><Text strong>查验日志</Text></Divider>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
padding: 16
|
||||||
|
}}>
|
||||||
|
{logs.length > 0 ? (
|
||||||
|
<Timeline
|
||||||
|
items={logs.map((item) => ({
|
||||||
|
color: item.type === 'success' ? 'green' : item.type === 'warning' ? 'orange' : 'blue',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{item.time}</Text>
|
||||||
|
<Text>{item.msg}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
|
||||||
|
)}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="暂停查验"
|
||||||
|
open={isPauseModalVisible}
|
||||||
|
onOk={confirmPause}
|
||||||
|
onCancel={() => setIsPauseModalVisible(false)}
|
||||||
|
okText="确认暂停"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
|
||||||
|
<Text>请确认是否暂停当前自动查验任务?</Text>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入暂停原因(可选)..."
|
||||||
|
value={pauseReason}
|
||||||
|
onChange={e => setPauseReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||||
|
import { ConfigProvider, App } from 'antd';
|
||||||
|
import { TopHeader } from '../components/TopHeader';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "海关平板前端系统",
|
||||||
|
description: "海关查验系统平板端原型",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className={`${inter.className} appBody`}>
|
||||||
|
<AntdRegistry>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Statistic: {
|
||||||
|
contentFontSize: 32,
|
||||||
|
titleFontSize: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App className="antAppRoot">
|
||||||
|
<div className="appShell">
|
||||||
|
<TopHeader />
|
||||||
|
<main className="appMain">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</App>
|
||||||
|
</ConfigProvider>
|
||||||
|
</AntdRegistry>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, Typography, Space, Button, Tabs, Table, Image as AntImage, Empty, Spin, Flex } from 'antd';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../../components/Breadcrumb';
|
||||||
|
import { MockApi } from '../../../services/mockApi';
|
||||||
|
import { MachineDetail, ImageItem } from '../../../types';
|
||||||
|
import { StatusBadge } from '../../../components/StatusBadge';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [machine, setMachine] = useState<MachineDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadMachineDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const data = await MockApi.getMachineDetail(params.serialNumber);
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(data);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setMachine(null);
|
||||||
|
setErrorMessage('机器详情加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMachineDetail();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [params.serialNumber]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 48 }}>
|
||||||
|
<Spin tip="正在加载机器详情..." />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!machine) {
|
||||||
|
return (
|
||||||
|
<Flex vertical align="center" style={{ padding: 48 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ maxWidth: 480, marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Empty description="暂无机器详情" />
|
||||||
|
<Button type="primary" onClick={() => router.push('/machines')} style={{ marginTop: 16 }}>
|
||||||
|
返回机器查询
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderImageGroup = (images: ImageItem[]) => {
|
||||||
|
if (!images || images.length === 0) return <Empty description="暂无图片" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntImage.PreviewGroup>
|
||||||
|
<Space size={[16, 16]} wrap>
|
||||||
|
{images.map((img) => (
|
||||||
|
<Flex
|
||||||
|
key={img.id}
|
||||||
|
vertical
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: 120,
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
|
||||||
|
<AntImage
|
||||||
|
src={img.url}
|
||||||
|
alt={img.name}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
preview={{
|
||||||
|
src: img.url,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text style={{ fontSize: 12, textAlign: 'center' }}>{img.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{img.createdAt}</Text>
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</AntImage.PreviewGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageTabs = [
|
||||||
|
{ key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) },
|
||||||
|
{ key: 'startup', label: '开机测试样张', children: renderImageGroup(machine.images.startupTestSample) },
|
||||||
|
{ key: 'production', label: '生产加工单', children: renderImageGroup(machine.images.productionOrder) },
|
||||||
|
{ key: 'robot', label: '机器人查验拍照', children: renderImageGroup(machine.images.robotInspection) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => router.back()}>返回查询</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card title="机器基本信息" style={{ marginBottom: 24 }}>
|
||||||
|
<Row gutter={[24, 16]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">序列号:</Text> <Text strong>{machine.serialNumber}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">机器型号:</Text> <Text strong>{machine.modelName}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">所属报关单:</Text> <Button type="link">{machine.customsId}</Button>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Text type="secondary">当前状态:</Text> <StatusBadge status={machine.status} />
|
||||||
|
</Col>
|
||||||
|
{Object.entries(machine.specs).map(([key, value]) => (
|
||||||
|
<Col span={8} key={key}>
|
||||||
|
<Text type="secondary">{key}:</Text> <Text>{value}</Text>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="图片资料" style={{ marginBottom: 24 }}>
|
||||||
|
<Tabs items={imageTabs} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="查验记录">
|
||||||
|
<Table
|
||||||
|
dataSource={machine.inspectionRecords}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
locale={{ emptyText: <Empty description="暂无查验记录" /> }}
|
||||||
|
>
|
||||||
|
<Table.Column title="查验时间" dataIndex="time" />
|
||||||
|
<Table.Column title="操作人" dataIndex="operator" />
|
||||||
|
<Table.Column title="结果" dataIndex="result" />
|
||||||
|
<Table.Column title="备注" dataIndex="remark" />
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Row, Col, Input, Button, Table, Typography, Space, Modal, Upload, Flex } from 'antd';
|
||||||
|
import { CameraOutlined, BarcodeOutlined, FileImageOutlined, SearchOutlined, BulbOutlined } from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function MachineQueryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
|
const [isScanModalVisible, setIsScanModalVisible] = useState(false);
|
||||||
|
const [recentQueries, setRecentQueries] = useState<{serialNumber: string, name: string, time: string}[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load recent queries from localStorage or mock
|
||||||
|
const saved = localStorage.getItem('recent_queries');
|
||||||
|
if (saved) {
|
||||||
|
setRecentQueries(JSON.parse(saved));
|
||||||
|
} else {
|
||||||
|
setRecentQueries([
|
||||||
|
{ serialNumber: 'BG042110276', name: '打印机型号A', time: '06-19 14:30' },
|
||||||
|
{ serialNumber: 'BG042110285', name: '扫描仪型号B', time: '06-19 10:15' }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = (sn: string) => {
|
||||||
|
if (!sn) return;
|
||||||
|
|
||||||
|
// Save to recent queries
|
||||||
|
const newQuery = { serialNumber: sn, name: '未知设备 (模拟)', time: new Date().toLocaleString() };
|
||||||
|
const updated = [newQuery, ...recentQueries.filter(q => q.serialNumber !== sn)].slice(0, 10);
|
||||||
|
setRecentQueries(updated);
|
||||||
|
localStorage.setItem('recent_queries', JSON.stringify(updated));
|
||||||
|
|
||||||
|
router.push(`/machines/${sn}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
|
<Card title="查询方式选择" style={{ marginBottom: 24 }}>
|
||||||
|
<Row gutter={48}>
|
||||||
|
<Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<CameraOutlined style={{ fontSize: 48, color: 'var(--color-primary)' }} />
|
||||||
|
<Title level={4} style={{ margin: 0 }}>扫描二维码</Title>
|
||||||
|
<Text type="secondary">使用平板摄像头扫描机器机身二维码</Text>
|
||||||
|
<Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}>打开扫码</Button>
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
|
||||||
|
<BarcodeOutlined style={{ fontSize: 48, color: 'var(--color-primary)' }} />
|
||||||
|
<Title level={4} style={{ margin: 0 }}>输入序列号</Title>
|
||||||
|
<Text type="secondary">输入机器序列号精确查询机器信息</Text>
|
||||||
|
<Space.Compact style={{ width: '80%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入序列号..."
|
||||||
|
size="large"
|
||||||
|
value={serialNumber}
|
||||||
|
onChange={(e) => setSerialNumber(e.target.value)}
|
||||||
|
onPressEnter={() => handleSearch(serialNumber)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}>查询</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Flex>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}>
|
||||||
|
<Upload.Dragger
|
||||||
|
accept="image/*"
|
||||||
|
showUploadList={false}
|
||||||
|
customRequest={({ onSuccess }) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess?.('ok');
|
||||||
|
handleSearch('BG042110276'); // 模拟识别成功
|
||||||
|
}, 1000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<FileImageOutlined style={{ fontSize: 48 }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">拖拽或点击上传二维码照片</p>
|
||||||
|
<p className="ant-upload-hint">支持 JPG / PNG / BMP,自动识别二维码内容</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="最近查询记录">
|
||||||
|
<Table
|
||||||
|
dataSource={recentQueries}
|
||||||
|
rowKey="serialNumber"
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<Table.Column title="序列号" dataIndex="serialNumber" />
|
||||||
|
<Table.Column title="机器名称" dataIndex="name" />
|
||||||
|
<Table.Column title="查询时间" dataIndex="time" />
|
||||||
|
<Table.Column
|
||||||
|
title="操作"
|
||||||
|
render={(_, record: {serialNumber: string}) => (
|
||||||
|
<Button type="link" onClick={() => handleSearch(record.serialNumber)}>再次查看</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="扫描二维码"
|
||||||
|
open={isScanModalVisible}
|
||||||
|
onCancel={() => setIsScanModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="manual" onClick={() => setIsScanModalVisible(false)}>
|
||||||
|
手动输入序列号
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width={600}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
height: 300,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#000000'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}>
|
||||||
|
<CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} />
|
||||||
|
<div>平板摄像头实时画面模拟</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsScanModalVisible(false);
|
||||||
|
handleSearch('BG042110276');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
模拟扫码成功 (BG042110276)
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 模拟扫码框 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
border: '2px solid rgba(24, 144, 255, 0.5)',
|
||||||
|
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', top: -2, left: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', top: -2, right: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', bottom: -2, left: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
|
||||||
|
<div style={{ position: 'absolute', bottom: -2, right: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
|
||||||
|
<BulbOutlined /> 将二维码对准框内,自动识别
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, List, Button, Table, Statistic, Flex } from 'antd';
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
ProfileOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { MockApi } from '../services/mockApi';
|
||||||
|
import { CustomsStats, ActivityItem, CustomsDeclaration } from '../types';
|
||||||
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<CustomsStats | null>(null);
|
||||||
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
|
const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setErrorMessage('');
|
||||||
|
const [statsData, actData, customsData] = await Promise.all([
|
||||||
|
MockApi.getCustomsStats(),
|
||||||
|
MockApi.getRecentActivities(),
|
||||||
|
MockApi.getPendingCustoms()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
setStats(statsData);
|
||||||
|
setActivities(actData);
|
||||||
|
setPendingCustoms(customsData);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('首页数据加载失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const inspectingCustoms = pendingCustoms.find(item => item.status === 'inspecting');
|
||||||
|
const goToInspection = () => {
|
||||||
|
if (inspectingCustoms) {
|
||||||
|
router.push(`/inspection?customsId=${encodeURIComponent(inspectingCustoms.customsId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/customs');
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
title: '待查验',
|
||||||
|
value: stats?.pendingCount || 0,
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
valueStyle: { color: '#1890ff' },
|
||||||
|
onClick: () => router.push('/customs')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '今日已放行',
|
||||||
|
value: stats?.releasedToday || 0,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
suffix: '份报关单',
|
||||||
|
valueStyle: { color: '#52c41a' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '查验进行中',
|
||||||
|
value: stats?.inspectingCount || 0,
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
suffix: '个任务',
|
||||||
|
valueStyle: { color: '#faad14' },
|
||||||
|
onClick: goToInspection
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '异常',
|
||||||
|
value: stats?.abnormalCount || 0,
|
||||||
|
icon: <WarningOutlined />,
|
||||||
|
suffix: '个异常',
|
||||||
|
valueStyle: { color: '#ff4d4f' }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines') },
|
||||||
|
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines') },
|
||||||
|
{ title: '视频监控', desc: '查看厂房实时监控画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 24 }}>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 统计卡片区域 */}
|
||||||
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
|
{statCards.map((stat, idx) => (
|
||||||
|
<Col span={6} key={idx}>
|
||||||
|
<Card hoverable={!!stat.onClick} onClick={stat.onClick}>
|
||||||
|
<Statistic
|
||||||
|
title={stat.title}
|
||||||
|
value={stat.value}
|
||||||
|
suffix={stat.suffix}
|
||||||
|
prefix={stat.icon}
|
||||||
|
valueStyle={stat.valueStyle}
|
||||||
|
loading={!stats}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 快捷操作区域 */}
|
||||||
|
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||||
|
{quickActions.map((action, idx) => (
|
||||||
|
<Col span={8} key={idx}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={action.onClick}
|
||||||
|
styles={{ body: { background: 'linear-gradient(135deg, #f6f8fc 0%, #eef2f9 100%)' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={16}>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.1)',
|
||||||
|
color: '#1890ff',
|
||||||
|
fontSize: 24
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
</Flex>
|
||||||
|
<Flex vertical>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18 }}>{action.title}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)', fontSize: 14 }}>{action.desc}</div>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
{/* 最近查验动态 */}
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title="最近查验动态"
|
||||||
|
extra={<Button type="link" icon={<RightOutlined />}>查看全部</Button>}
|
||||||
|
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={activities}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
|
||||||
|
<ProfileOutlined style={{ fontSize: 20 }} />
|
||||||
|
}
|
||||||
|
title={<span style={{ fontWeight: 500 }}>{item.message}</span>}
|
||||||
|
description={item.time}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 待查验报关单 */}
|
||||||
|
<Col span={12}>
|
||||||
|
<Card
|
||||||
|
title="待查验报关单"
|
||||||
|
extra={<Button type="link" onClick={() => router.push('/customs')}>查看全部 <RightOutlined /></Button>}
|
||||||
|
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={pendingCustoms}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
onRow={() => ({
|
||||||
|
onClick: () => router.push('/customs'),
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text) => <b>{text}</b>} />
|
||||||
|
<Table.Column title="机器数量" dataIndex="machineCount" key="machineCount" render={(count) => `${count} 台`} />
|
||||||
|
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, Row, Col, Button, Typography, Space, Spin, Flex, Breadcrumb as AntdBreadcrumb, Badge, Empty } from 'antd';
|
||||||
|
import { FullscreenOutlined, ReloadOutlined, CameraOutlined, ArrowLeftOutlined, CaretRightOutlined, HomeOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { MockApi } from '../../services/mockApi';
|
||||||
|
import { CameraInfo } from '../../types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function VideoPage() {
|
||||||
|
const [cameras, setCameras] = useState<CameraInfo[]>([]);
|
||||||
|
const [fullscreenCamera, setFullscreenCamera] = useState<CameraInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadCameras = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
const cameraList = await MockApi.getCameraList();
|
||||||
|
if (!isMounted) return;
|
||||||
|
setCameras(cameraList);
|
||||||
|
} catch {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setErrorMessage('摄像头列表加载失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCameras();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 模拟的视频播放器组件
|
||||||
|
const MockVideoPlayer = ({ camera, height, aspectRatio }: { camera: CameraInfo, height?: number | string, aspectRatio?: string }) => (
|
||||||
|
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderRadius: 8, background: '#141414', height, aspectRatio, boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)' }}>
|
||||||
|
{camera.status === 'online' ? (
|
||||||
|
<>
|
||||||
|
{/* 居中播放按钮 */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
icon={<CaretRightOutlined style={{ fontSize: 40, color: 'white' }} />}
|
||||||
|
style={{ width: 80, height: 80, backgroundColor: 'rgba(255, 255, 255, 0.15)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.2)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 底部状态标识 */}
|
||||||
|
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6, backdropFilter: 'blur(4px)' }}>
|
||||||
|
<Badge status="success" text={<Text style={{ color: 'white', fontSize: 12 }}>在线 / 实时画面模拟</Text>} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
|
||||||
|
imageStyle={{ height: 64, marginBottom: 16 }}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text type="danger" strong style={{ fontSize: 16 }}>设备离线</Text>
|
||||||
|
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}>无法连接到视频流</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 模拟重连
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试连接
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullscreenCamera) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||||
|
<Space align="center" size="middle">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => setFullscreenCamera(null)}>返回</Button>
|
||||||
|
<AntdBreadcrumb
|
||||||
|
items={[
|
||||||
|
{ title: <Link href="/"><HomeOutlined /> 首页</Link> },
|
||||||
|
{ title: <a href="#" onClick={(e) => { e.preventDefault(); setFullscreenCamera(null); }}>视频监控</a> },
|
||||||
|
{ title: fullscreenCamera.name }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />}>截图</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="center" align="center" style={{ height: 'calc(100vh - 180px)', marginBottom: 16 }}>
|
||||||
|
<div style={{ height: '100%', maxWidth: '100%', aspectRatio: '16 / 9', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
|
||||||
|
<MockVideoPlayer camera={fullscreenCamera} height="100%" />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||||||
|
<Breadcrumb />
|
||||||
|
<Space>
|
||||||
|
<Button icon={<FullscreenOutlined />}>全屏模式</Button>
|
||||||
|
<Button type="primary" icon={<CameraOutlined />}>全部截图</Button>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={errorMessage}
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Flex vertical align="center" justify="center" style={{ padding: 64 }}>
|
||||||
|
<Spin size="large" tip="正在加载摄像头..." />
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<Col xs={24} lg={12} key={camera.id}>
|
||||||
|
<Card
|
||||||
|
hoverable={camera.status === 'online'}
|
||||||
|
style={{ overflow: 'hidden', borderRadius: 12, borderColor: camera.status === 'online' ? '#f0f0f0' : '#ffccc7' }}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
onClick={() => camera.status === 'online' && setFullscreenCamera(camera)}
|
||||||
|
>
|
||||||
|
<MockVideoPlayer camera={camera} height={300} />
|
||||||
|
<Flex justify="space-between" align="center" style={{ padding: '12px 20px', background: camera.status === 'online' ? '#ffffff' : '#fff1f0', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
<Space size="middle">
|
||||||
|
<Badge status={camera.status === 'online' ? 'processing' : 'error'} />
|
||||||
|
<Text strong style={{ fontSize: 15, color: camera.status === 'online' ? 'inherit' : '#cf1322' }}>{camera.name}</Text>
|
||||||
|
</Space>
|
||||||
|
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small">全屏观看</Button>}
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||||
|
import { HomeOutlined } from '@ant-design/icons';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const breadcrumbNameMap: Record<string, string> = {
|
||||||
|
'/video': '视频监控',
|
||||||
|
'/machines': '机器查询',
|
||||||
|
'/customs': '报关单管理',
|
||||||
|
'/inspection': '远程查验',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Breadcrumb: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathSnippets = pathname.split('/').filter(i => i);
|
||||||
|
|
||||||
|
const extraBreadcrumbItems = pathSnippets.map((_, index) => {
|
||||||
|
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
|
||||||
|
const isLast = index === pathSnippets.length - 1;
|
||||||
|
let name = breadcrumbNameMap[url] || pathSnippets[index];
|
||||||
|
|
||||||
|
if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'machines') {
|
||||||
|
name = '机器详情';
|
||||||
|
} else if (!breadcrumbNameMap[url] && pathSnippets[index - 1] === 'customs') {
|
||||||
|
name = '报关单详情';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: url,
|
||||||
|
title: isLast ? name : <Link href={url}>{name}</Link>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||||
|
},
|
||||||
|
...extraBreadcrumbItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<AntdBreadcrumb items={breadcrumbItems} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
QuestionCircleOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
type StatusType = 'pending' | 'inspecting' | 'released' | 'abnormal' | 'idle' | 'running' | 'paused' | 'completed' | 'online' | 'offline';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: StatusType;
|
||||||
|
type?: 'badge' | 'tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => {
|
||||||
|
const getStatusConfig = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> };
|
||||||
|
case 'inspecting':
|
||||||
|
case 'running':
|
||||||
|
return { color: 'processing', text: '查验中', icon: <SyncOutlined style={{ color: '#1890ff' }} /> };
|
||||||
|
case 'released':
|
||||||
|
case 'completed':
|
||||||
|
return { color: 'success', text: '已放行', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||||
|
case 'abnormal':
|
||||||
|
return { color: 'error', text: '异常', icon: <WarningOutlined style={{ color: '#ff4d4f' }} /> };
|
||||||
|
case 'idle':
|
||||||
|
return { color: 'default', text: '空闲', icon: <MinusCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||||
|
case 'paused':
|
||||||
|
return { color: 'warning', text: '已暂停', icon: <PauseCircleOutlined style={{ color: '#faad14' }} /> };
|
||||||
|
case 'online':
|
||||||
|
return { color: 'success', text: '在线', icon: <CheckCircleOutlined style={{ color: '#52c41a' }} /> };
|
||||||
|
case 'offline':
|
||||||
|
return { color: 'error', text: '离线', icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} /> };
|
||||||
|
default:
|
||||||
|
return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getStatusConfig();
|
||||||
|
|
||||||
|
if (type === 'tag') {
|
||||||
|
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{config.icon} <span>{config.text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Layout, Menu, Badge, Avatar, Button, Dropdown, Space, Typography, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
ScanOutlined,
|
||||||
|
SafetyCertificateOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
|
const { Header } = Layout;
|
||||||
|
|
||||||
|
export const TopHeader: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, notifications } = useAppStore();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/', icon: <DashboardOutlined />, label: '首页' },
|
||||||
|
{ key: '/video', icon: <VideoCameraOutlined />, label: '视频监控' },
|
||||||
|
{ key: '/machines', icon: <SearchOutlined />, label: '机器查询' },
|
||||||
|
{ key: '/customs', icon: <FileTextOutlined />, label: '报关单' },
|
||||||
|
{ key: '/inspection', icon: <ScanOutlined />, label: '远程查验' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuClick = (e: { key: string }) => {
|
||||||
|
router.push(e.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationMenu = {
|
||||||
|
items: notifications.map(n => ({
|
||||||
|
key: n.id,
|
||||||
|
label: (
|
||||||
|
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
|
||||||
|
<Typography.Text strong={!n.read} type={n.read ? 'secondary' : undefined} style={{ display: 'block' }}>
|
||||||
|
{n.title}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{n.message}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
|
||||||
|
{n.time}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Header style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 24px',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}>
|
||||||
|
<Space
|
||||||
|
size="middle"
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
style={{ cursor: 'pointer', marginRight: 48, display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<SafetyCertificateOutlined style={{ fontSize: 24, color: token.colorPrimary }} />
|
||||||
|
<Typography.Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
|
||||||
|
海关智慧查验平台
|
||||||
|
</Typography.Title>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
mode="horizontal"
|
||||||
|
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Dropdown menu={notificationMenu} placement="bottomRight" trigger={['click']}>
|
||||||
|
<Badge count={unreadCount} size="small" offset={[-4, 4]}>
|
||||||
|
<Button type="text" shape="circle" icon={<BellOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||||
|
</Badge>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<Button type="text" shape="circle" icon={<SettingOutlined style={{ fontSize: 18, color: token.colorText }} />} />
|
||||||
|
|
||||||
|
<Space size="small" style={{ cursor: 'pointer', padding: '0 8px', borderRadius: token.borderRadius, transition: 'background 0.3s' }} className="user-entry">
|
||||||
|
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
|
||||||
|
<Typography.Text style={{ fontSize: 14 }}>{user?.name}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
CustomsStats,
|
||||||
|
ActivityItem,
|
||||||
|
CustomsDeclaration,
|
||||||
|
CameraInfo,
|
||||||
|
MachineDetail
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const customsDeclarations: CustomsDeclaration[] = [
|
||||||
|
{
|
||||||
|
id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'P001', inventoryName: '打印机型号A', spec: 'A4', quantify: 3, inspected: 0 },
|
||||||
|
{ inventoryCode: 'S001', inventoryName: '扫描仪型号B', spec: 'A3', quantify: 2, inspected: 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'P002', inventoryName: '打印机型号C', spec: 'A4', quantify: 8, inspected: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', customsId: 'CD20260618004', status: 'released', machineCount: 10, createdAt: '2026-06-18 10:00',
|
||||||
|
items: [
|
||||||
|
{ inventoryCode: 'M001', inventoryName: '显示器型号A', spec: '27寸', quantify: 10, inspected: 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟真实接口耗时,便于原型验证加载和错误状态。
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export const MockApi = {
|
||||||
|
getCustomsStats: async (): Promise<CustomsStats> => {
|
||||||
|
await delay(300);
|
||||||
|
return {
|
||||||
|
pendingCount: 12,
|
||||||
|
releasedToday: 28,
|
||||||
|
inspectingCount: 1,
|
||||||
|
abnormalCount: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getRecentActivities: async (): Promise<ActivityItem[]> => {
|
||||||
|
await delay(300);
|
||||||
|
return [
|
||||||
|
{ id: '1', time: '14:30', type: 'start', message: 'CD20260619003 开始查验' },
|
||||||
|
{ id: '2', time: '14:15', type: 'success', message: 'CD20260619002 查验完成/放行' },
|
||||||
|
{ id: '3', time: '14:00', type: 'info', message: '新增报关单 CD20260618005' },
|
||||||
|
{ id: '4', time: '13:45', type: 'warning', message: 'CD20260618001 查验异常暂停' },
|
||||||
|
{ id: '5', time: '13:20', type: 'success', message: 'CD20260618004 查验完成/放行' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingCustoms: async (): Promise<CustomsDeclaration[]> => {
|
||||||
|
await delay(400);
|
||||||
|
return [
|
||||||
|
{ id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00', items: [] },
|
||||||
|
{ id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00', items: [] },
|
||||||
|
{ id: '3', customsId: 'CD20260619004', status: 'pending', machineCount: 3, createdAt: '2026-06-19 12:00', items: [] },
|
||||||
|
{ id: '4', customsId: 'CD20260618005', status: 'pending', machineCount: 6, createdAt: '2026-06-18 16:00', items: [] },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomsList: async (): Promise<CustomsDeclaration[]> => {
|
||||||
|
await delay(500);
|
||||||
|
return customsDeclarations;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustomsById: async (customsId: string): Promise<CustomsDeclaration | null> => {
|
||||||
|
await delay(300);
|
||||||
|
return customsDeclarations.find(item => item.customsId === customsId) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCameraList: async (): Promise<CameraInfo[]> => {
|
||||||
|
await delay(300);
|
||||||
|
return [
|
||||||
|
{ id: '1', name: '摄像头 1', location: '入料口', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '2', name: '摄像头 2', location: '生产线 A', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '3', name: '摄像头 3', location: '生产线 B', streamUrl: '', status: 'online' },
|
||||||
|
{ id: '4', name: '摄像头 4', location: '出库区', streamUrl: '', status: 'offline' },
|
||||||
|
{ id: '5', name: '摄像头 5', location: 'AGV 作业区', streamUrl: '', status: 'online' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
getMachineDetail: async (serialNumber: string): Promise<MachineDetail> => {
|
||||||
|
await delay(400);
|
||||||
|
return {
|
||||||
|
serialNumber: serialNumber,
|
||||||
|
modelName: '打印机型号A',
|
||||||
|
modelId: 'MDL-A4-001',
|
||||||
|
customsId: 'CD20260619001',
|
||||||
|
customsName: '某科技公司进口设备批次',
|
||||||
|
status: 'pending',
|
||||||
|
specs: {
|
||||||
|
'尺寸': '480×320×260mm',
|
||||||
|
'重量': '12.5kg',
|
||||||
|
'产地': '中国 / 深圳',
|
||||||
|
'入库日期': '2026-06-15'
|
||||||
|
},
|
||||||
|
createdAt: '2026-06-15 10:00',
|
||||||
|
images: {
|
||||||
|
incomingInspection: [
|
||||||
|
{ id: 'i1', url: 'https://picsum.photos/800/600?1', thumbnailUrl: 'https://picsum.photos/200/150?1', name: '来料检验单 第1页', createdAt: '2026-06-10' },
|
||||||
|
{ id: 'i2', url: 'https://picsum.photos/800/600?2', thumbnailUrl: 'https://picsum.photos/200/150?2', name: '来料检验单 第2页', createdAt: '2026-06-10' }
|
||||||
|
],
|
||||||
|
startupTestSample: [
|
||||||
|
{ id: 'i3', url: 'https://picsum.photos/800/600?3', thumbnailUrl: 'https://picsum.photos/200/150?3', name: '开机测试样张', createdAt: '2026-06-12' }
|
||||||
|
],
|
||||||
|
productionOrder: [
|
||||||
|
{ id: 'i4', url: 'https://picsum.photos/800/600?4', thumbnailUrl: 'https://picsum.photos/200/150?4', name: '生产加工单', createdAt: '2026-06-12' }
|
||||||
|
],
|
||||||
|
robotInspection: [
|
||||||
|
{ id: 'i5', url: 'https://picsum.photos/800/600?5', thumbnailUrl: 'https://picsum.photos/200/150?5', name: '正面照', createdAt: '2026-06-19' },
|
||||||
|
{ id: 'i6', url: 'https://picsum.photos/800/600?6', thumbnailUrl: 'https://picsum.photos/200/150?6', name: '背面照', createdAt: '2026-06-19' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
inspectionRecords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { User, Notification, CustomsDeclaration, InspectionState } from '../types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
user: User | null;
|
||||||
|
notifications: Notification[];
|
||||||
|
selectedCustoms: CustomsDeclaration | null;
|
||||||
|
inspection: InspectionState | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
addNotification: (notification: Notification) => void;
|
||||||
|
markNotificationRead: (id: string) => void;
|
||||||
|
setSelectedCustoms: (customs: CustomsDeclaration | null) => void;
|
||||||
|
setInspection: (inspection: InspectionState | null) => void;
|
||||||
|
updateInspectionStatus: (status: InspectionState['status']) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
user: { name: '张三', role: '海关查验员' },
|
||||||
|
notifications: [
|
||||||
|
{ id: '1', title: '异常告警', message: '入料口摄像头离线', time: '14:30', read: false },
|
||||||
|
{ id: '2', title: '查验完成', message: '报关单 CD20260619002 查验完成', time: '14:15', read: false },
|
||||||
|
{ id: '3', title: '系统通知', message: '新增 5 份待查验报关单', time: '14:00', read: true }
|
||||||
|
],
|
||||||
|
selectedCustoms: null,
|
||||||
|
inspection: null,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
|
addNotification: (notification) => set((state) => ({
|
||||||
|
notifications: [notification, ...state.notifications]
|
||||||
|
})),
|
||||||
|
|
||||||
|
markNotificationRead: (id) => set((state) => ({
|
||||||
|
notifications: state.notifications.map(n =>
|
||||||
|
n.id === id ? { ...n, read: true } : n
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
|
||||||
|
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
|
||||||
|
|
||||||
|
setInspection: (inspection) => set({ inspection }),
|
||||||
|
|
||||||
|
updateInspectionStatus: (status) => set((state) => ({
|
||||||
|
inspection: state.inspection ? { ...state.inspection, status } : null
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MachineDetail {
|
||||||
|
serialNumber: string;
|
||||||
|
modelName: string;
|
||||||
|
modelId: string;
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: 'pending' | 'inspecting' | 'released' | 'abnormal';
|
||||||
|
specs: Record<string, string>;
|
||||||
|
createdAt: string;
|
||||||
|
images: {
|
||||||
|
incomingInspection: ImageItem[];
|
||||||
|
startupTestSample: ImageItem[];
|
||||||
|
productionOrder: ImageItem[];
|
||||||
|
robotInspection: ImageItem[];
|
||||||
|
};
|
||||||
|
inspectionRecords: InspectionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionRecord {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
operator: string;
|
||||||
|
result: string;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomsStats {
|
||||||
|
pendingCount: number;
|
||||||
|
releasedToday: number;
|
||||||
|
inspectingCount: number;
|
||||||
|
abnormalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
streamUrl: string;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
snapshot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomsDeclaration {
|
||||||
|
id: string;
|
||||||
|
customsId: string;
|
||||||
|
status: 'pending' | 'released' | 'abnormal' | 'inspecting';
|
||||||
|
machineCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
items: InspectionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionState {
|
||||||
|
customsId: string;
|
||||||
|
customsName: string;
|
||||||
|
status: 'idle' | 'running' | 'paused' | 'completed';
|
||||||
|
items: InspectionItem[];
|
||||||
|
startedAt: number;
|
||||||
|
currentMachine?: {
|
||||||
|
machineId: string;
|
||||||
|
serialNumber: string;
|
||||||
|
step: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionItem {
|
||||||
|
inventoryCode: string;
|
||||||
|
inventoryName: string;
|
||||||
|
spec: string;
|
||||||
|
quantify: number;
|
||||||
|
inspected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
type: 'start' | 'success' | 'info' | 'warning';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
# AGV + 机械臂 移动拍摄平台 — 技术说明文档
|
||||||
|
|
||||||
|
> **版本**: V3.0 | **更新时间**: 2026-06-17 | **作者**: 自动生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [项目概述](#1-项目概述)
|
||||||
|
2. [系统架构](#2-系统架构)
|
||||||
|
3. [硬件环境与网络拓扑](#3-硬件环境与网络拓扑)
|
||||||
|
4. [核心模块详解](#4-核心模块详解)
|
||||||
|
5. [通信协议](#5-通信协议)
|
||||||
|
6. [完整 API 接口文档](#6-完整-api-接口文档)
|
||||||
|
7. [任务执行流程](#7-任务执行流程)
|
||||||
|
8. [数据配置格式](#8-数据配置格式)
|
||||||
|
9. [部署与运维](#9-部署与运维)
|
||||||
|
10. [关键决策与约束](#10-关键决策与约束)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 1.1 业务目标
|
||||||
|
|
||||||
|
自动巡检拍摄系统:AGV(Automated Guided Vehicle)搭载大象机器人 630 六轴机械臂 + Orbbec Gemini 深度相机,按 M×N 网格布局自动导航到每台待检机器前,识别机器二维码→匹配机型→按预设姿态拍摄正面/背面照片→上传至后端管理系统。
|
||||||
|
|
||||||
|
### 1.2 核心能力
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **自主导航** | 基于 ROS2 Humble + Nav2 导航栈,读取预建地图,精确导航至每个目标坐标 |
|
||||||
|
| **多姿态拍摄** | 每台机器支持自定义正/背面多姿态(机械臂6关节角度预设) |
|
||||||
|
| **二维码识别** | 机械臂摄像头(倒装)+ 双引擎识别(pyzbar + OpenCV QRCodeDetector)|
|
||||||
|
| **蛇形路径** | M×N 网格蛇形路径优化,相邻路径点高效串联,避免无效往返 |
|
||||||
|
| **报关单查验** | 集成外部报关系统,按报关单机器清单逐台核对,自动统计查验进度 |
|
||||||
|
| **照片上传** | 拍摄后即时上传至 Java 后端文件服务,附带 serialNumber + index |
|
||||||
|
| **双摄像头** | AGV Orbbec 深度相机 + 机械臂 USB 摄像头,物理翻转纠正 + 花屏自动检测 |
|
||||||
|
| **单步执行/错误处理** | 支持单步调试模式、错误弹窗中断/跳过 |
|
||||||
|
|
||||||
|
### 1.3 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| **后端** | Python 3 + Flask 2.x(端口 5000) |
|
||||||
|
| **前端** | Vue 3(CDN)+ 原生 JS + HTML/CSS |
|
||||||
|
| **机器人控制** | ROS2 Humble + nav2_simple_commander |
|
||||||
|
| **机械臂** | RoboFlow 630 → TCP Socket(arm_server)|
|
||||||
|
| **导航** | Nav2 (Behavior Tree) + AMCL 定位 |
|
||||||
|
| **部署** | SSH + expect 脚本远程重启 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系统架构
|
||||||
|
|
||||||
|
### 2.1 整体架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AGV (Ubuntu 22.04) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Flask Web 服务 (:5000) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │
|
||||||
|
│ │ │ 控制面板 │ │ 设置页 │ │ 任务运行页 │ │ │
|
||||||
|
│ │ │ index │ │ setting │ │ running │ │ │
|
||||||
|
│ │ └──────────┘ └──────────┘ └───────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ GlobalState (全局状态) │ │ │
|
||||||
|
│ │ │ state / arm_client / agv_controller / qr_scanner │ │ │
|
||||||
|
│ │ └──────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────┐ ┌──────────────────────────────┐ │ │
|
||||||
|
│ │ │ 98 个 API 端点 │ │ MissionExecutorV3 任务核 │ │ │
|
||||||
|
│ │ │ RESTful JSON │ │ M×N 网格 + 蛇形路径 │ │ │
|
||||||
|
│ │ └─────────────────┘ └──────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ AGVController │ │ ArmClient │ │ Nav2Navigator │ │
|
||||||
|
│ │ (ROS2/cmd_vel)│ │ TCP Socket │ │ BasicNavigator API │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────┴──────┐ ┌─────┴──────────┐ ┌───────┴──────────┐ │
|
||||||
|
│ │ ROS2 Topics │ │ arm_server (:5002)│ │ Nav2 Action Srv │ │
|
||||||
|
│ │ /cmd_vel │ │ RoboFlow 630 │ │ /amcl_pose │ │
|
||||||
|
│ │ /odom │ │ │ │ /navigate_to_pose│ │
|
||||||
|
│ └─────────────┘ └─────────────────┘ └──────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ 机械臂 (Pi) │ │ 外部服务 (Java 后端) │
|
||||||
|
│ arm_server.py │ │ zhijian168.com / 192.168.60.159 │
|
||||||
|
│ :5002 TCP │ │ customsListPage / customsMachines│
|
||||||
|
│ :5003 Camera │ │ profile/printer / file/uploadImage│
|
||||||
|
└──────────────┘ └──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心文件清单
|
||||||
|
|
||||||
|
| 文件 | 行数 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app.py` | 2132 | Flask 主程序,98 个 API 端点,GlobalState 全局状态管理 |
|
||||||
|
| `config.py` | 114 | 集中配置(IP、端口、API密钥、环境切换) |
|
||||||
|
| `utils/mission_executor.py` | 1198 | 任务执行器 V3:蛇形路径、导航、QR扫描、拍照、上传 |
|
||||||
|
| `utils/agv_controller_ros2.py` | 216 | AGV 运动控制(ROS2 topic 发布 cmd_vel) |
|
||||||
|
| `utils/arm_client.py` | 170 | 机械臂 TCP 客户端(set_angles/jog/power_on) |
|
||||||
|
| `utils/nav2_navigator.py` | 350 | Nav2 导航器(BasicNavigator API + /amcl_pose 位置) |
|
||||||
|
| `utils/qr_scanner.py` | 170 | 二维码扫描(V4L2 + 绿屏修复 + 双引擎识别) |
|
||||||
|
| `utils/image_uploader.py` | 76 | 照片 HTTP 上传(multipart/form-data) |
|
||||||
|
| `templates/index.html` | - | AGV 控制页面(实时控制 + 双摄像头预览) |
|
||||||
|
| `templates/setting.html` | - | 配置页面(网格/机型/点位/报关单) |
|
||||||
|
| `templates/running.html` | - | 任务运行页(进度 + QR弹窗 + 查验状态) |
|
||||||
|
| `static/js/app.js` | - | 控制页交互逻辑 |
|
||||||
|
| `static/js/setting.js` | - | 设置页交互逻辑 |
|
||||||
|
| `static/js/running.js` | - | 运行页交互逻辑 + SSE 实时推送 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 硬件环境与网络拓扑
|
||||||
|
|
||||||
|
### 3.1 设备清单
|
||||||
|
|
||||||
|
| 设备 | 角色 | IP 地址 | SSH 凭证 | 关键软件 |
|
||||||
|
|------|------|---------|----------|----------|
|
||||||
|
| **AGV** | 主控 + 运动平台 | `192.168.60.80` | `elephant` / `Elephant` | ROS2 Humble, Nav2, Flask |
|
||||||
|
| **机械臂 Pi** | 机械臂 + 摄像头 | `192.168.60.120` | `pi` / `elephant` | arm_server.py, RoboFlow, ffmpeg |
|
||||||
|
| **Java 测试服务器** | 报关单/上传后端 | `192.168.60.159:8080` | - | Spring Boot |
|
||||||
|
| **生产服务器** | 正式环境 | `ts.zhijian168.com` | - | HTTPS + Nginx |
|
||||||
|
|
||||||
|
### 3.2 AGV 硬件映射
|
||||||
|
|
||||||
|
| 设备 | Linux 路径 | 用途 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| AGV 控制器 | `/dev/ttyCH341USB0` | AGV 底盘串口控制 |
|
||||||
|
| 雷达 | `/dev/ttyACM0` | LiDAR 传感器 |
|
||||||
|
| Orbbec Gemini | `/dev/video4` | 深度相机(彩色流 YUYV 640×480) |
|
||||||
|
|
||||||
|
### 3.3 网络参数
|
||||||
|
|
||||||
|
| 参数 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Flask 监听 | `0.0.0.0:5000` | AGV Web 服务 |
|
||||||
|
| 机械臂 TCP | `5002` | arm_server 控制端口 |
|
||||||
|
| 机械臂摄像头 | `5003` | arm_server MJPEG 流 |
|
||||||
|
| ROS_DOMAIN_ID | `1` | DDS 发现域(Flask/Nav2/AGV 节点统一) |
|
||||||
|
| AGV 串口波特率 | `1000000` | 底盘通信 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心模块详解
|
||||||
|
|
||||||
|
### 4.1 GlobalState — 全局状态管理
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GlobalState:
|
||||||
|
state: str # "idle" | "setting" | "running" | "paused"
|
||||||
|
arm_client: ArmClient # 机械臂 TCP 客户端实例
|
||||||
|
agv_controller: AGVController # ROS2 AGV 控制器
|
||||||
|
qr_scanner: QRScanner # AGV 摄像头二维码扫描器
|
||||||
|
navigator: Nav2Navigator # 导航实例
|
||||||
|
mission_config: dict # {rows, cols, grid[][], positions[{row,col,side,coords,poses}]}
|
||||||
|
machines_config: list # [{id, row, col, front:{coords,poses}, back:{coords,poses}}]
|
||||||
|
models_config: list # [{id, name, poses:[{id,name,photo_type,arm_angles,speed}]}]
|
||||||
|
qr_config: list # [{id, name, joint_angles, qr_value, model_id}]
|
||||||
|
inspection: dict # 查验状态 {customsId, customsName, items:[{inventoryCode,quantify,inspected}]}
|
||||||
|
current_customs: dict # 当前报关单 {id, name, machine_ids}
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态转换图**:
|
||||||
|
```
|
||||||
|
IDLE ──connect_all──▶ SETTING ──start_mission──▶ RUNNING
|
||||||
|
▲ ▲ │
|
||||||
|
│ │ ┌─────┼─────┐
|
||||||
|
│ │ ▼ ▼ ▼
|
||||||
|
└──disconnect── PAUSED ◀── error/stop ── COMPLETED
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 MissionExecutorV3 — 任务执行器核心
|
||||||
|
|
||||||
|
#### 类结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MissionExecutorV3
|
||||||
|
├── 连接管理: connect_all() / disconnect_all()
|
||||||
|
├── 主流程: execute_mission(mission_config, machines, models, options)
|
||||||
|
│ ├── 蛇形路径: _build_snake_path(rows, cols, grid) → 路径列表
|
||||||
|
│ ├── 导航: _navigate(point, label) → Nav2Navigator
|
||||||
|
│ ├── QR 扫描: _scan_qr_with_poses(qr_configs, machine_row)
|
||||||
|
│ │ ├── _decode_qr_from_arm() → pyzbar/OpenCV
|
||||||
|
│ │ └── _request_manual_qr(message) → 用户手动输入
|
||||||
|
│ ├── 机型查询: _lookup_model(qr_value) → 报关单API查询
|
||||||
|
│ ├── 拍照: _shoot(model, side, row, col, qr_value, machine_row)
|
||||||
|
│ │ ├── _capture_arm_photo() → 机械臂摄像头
|
||||||
|
│ │ └── _upload_photo_bytes() → HTTP上传
|
||||||
|
│ └── 返回原点: _return_to_origin()
|
||||||
|
├── 控制: pause() / resume() / stop()
|
||||||
|
├── 单步执行: set_step_choice("confirm"|"retry"|"abort")
|
||||||
|
└── 错误处理: set_error_choice("skip"|"abort")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 蛇形路径算法
|
||||||
|
|
||||||
|
```
|
||||||
|
假设 2行 × 5列,有机器位置: (0,0)(0,1)(0,2)(0,3)(0,4)(1,0)(1,1)(1,2)(1,3)(1,4)
|
||||||
|
|
||||||
|
蛇形路径(按点位行 pr 遍历):
|
||||||
|
pr=0 (1排正面): (0,0)→(0,1)→(0,2)→(0,3)→(0,4) [左→右]
|
||||||
|
pr=1 (1排背面 + 2排正面): (1,4)→(1,3)→(1,2)→(1,1)→(1,0) [右→左]
|
||||||
|
pr=2 (2排背面): (2,0)→(2,1)→(2,2)→(2,3)→(2,4) [左→右]
|
||||||
|
|
||||||
|
PR 为奇数时列序反向。
|
||||||
|
|
||||||
|
同一点位同时服务上一行背面和下一行正面时,先执行背面,再执行正面。
|
||||||
|
镜像规则:机器行号为奇数时,所有 J1 关节角度取反(镜像)。仅 J1 取反!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 任务步骤控制开关
|
||||||
|
|
||||||
|
前端执行任务时可选择性开启/关闭步骤:
|
||||||
|
|
||||||
|
| 开关 | 字段 | 默认 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 机械臂初始化 | `arm_init` | true | 每个点位移到后恢复默认姿态 |
|
||||||
|
| AGV 移动 | `agv_move` | true | 导航到目标坐标 |
|
||||||
|
| 二维码识别 | `qr_scan` | true | 扫描机器二维码 |
|
||||||
|
| 正面拍照 | `front_photo` | true | 正面姿态组拍摄 |
|
||||||
|
| 背面拍照 | `back_photo` | true | 背面姿态组拍摄 |
|
||||||
|
| AGV 速度 | `agv_speed` | 1.0 | m/s |
|
||||||
|
| 机械臂速度 | `arm_speed` | 1000 | RoboFlow 速度参数 |
|
||||||
|
|
||||||
|
### 4.3 AGVController — ROS2 运动控制
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AGVController:
|
||||||
|
def connect() # 检查 /odom topic 是否存在
|
||||||
|
def is_connected() # 连接状态
|
||||||
|
def move_forward() # 前进 (linear.x > 0)
|
||||||
|
def move_backward() # 后退 (linear.x < 0)
|
||||||
|
def turn_left() # 左转 (angular.z > 0)
|
||||||
|
def turn_right() # 右转 (angular.z < 0)
|
||||||
|
def move_left_lateral() # 左横移 (linear.y > 0)
|
||||||
|
def move_right_lateral() # 右横移 (linear.y < 0)
|
||||||
|
def stop() # 停止 (全 0)
|
||||||
|
def get_position() # 从 /odom 获取位置 [x, y, yaw]
|
||||||
|
def get_battery() # 获取电压
|
||||||
|
```
|
||||||
|
|
||||||
|
**原理**: 通过 `subprocess` 执行 `ros2 topic pub /cmd_vel geometry_msgs/msg/Twist` 发布速度指令。`--once` 参数发布一次后退出,底层 AGV 驱动收到后会持续执行直到收到下一条指令(或发送零值停止)。
|
||||||
|
|
||||||
|
**ROS 环境**: `source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash && export ROS_DOMAIN_ID=1`
|
||||||
|
|
||||||
|
### 4.4 ArmClient — 机械臂 TCP 客户端
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ArmClient:
|
||||||
|
def connect() # TCP 连接到 arm_server:5002
|
||||||
|
def send_command(cmd) # 发送文本命令,接收响应
|
||||||
|
def get_angles() # → [J1..J6] 当前关节角度
|
||||||
|
def set_angles(angles, speed) # 设置全部 6 关节角度
|
||||||
|
def set_angle(joint, angle, speed) # 设置单个关节
|
||||||
|
def jog_angle(joint, direction, speed) # 连续调节(-1/0/1)
|
||||||
|
def get_coords() # → [x, y, z, rx, ry, rz]
|
||||||
|
def power_on() / state_on() / state_off() # 上电控制
|
||||||
|
def state_check() / check_running() # 状态查询
|
||||||
|
def wait_done(timeout) # 等待命令执行完成
|
||||||
|
def task_stop() # 紧急停止
|
||||||
|
```
|
||||||
|
|
||||||
|
**通信协议**: 文本行协议(`\n` 分隔)。
|
||||||
|
- **请求**: `command_name(param1,param2,...)\n`
|
||||||
|
- **响应**: `command_name:result` 或 `ok`
|
||||||
|
|
||||||
|
**关节范围** (机械臂 630):
|
||||||
|
| 关节 | 范围 |
|
||||||
|
|------|------|
|
||||||
|
| J1 | ±180° |
|
||||||
|
| J2 | ±90° |
|
||||||
|
| J3 | ±90° |
|
||||||
|
| J4 | ±180° |
|
||||||
|
| J5 | ±90° |
|
||||||
|
| J6 | ±180° |
|
||||||
|
|
||||||
|
### 4.5 Nav2Navigator — 自主导航
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Nav2Navigator:
|
||||||
|
def navigate_to_pose(x, y, yaw, timeout_sec, blocking)
|
||||||
|
# 使用 BasicNavigator.goToPose() 发送导航目标
|
||||||
|
# 子线程中轮询 isTaskComplete(),超时自动取消
|
||||||
|
def navigate_through_poses(poses, timeout_per_pose, blocking)
|
||||||
|
# 多路径点连续导航
|
||||||
|
def stop() # 取消当前导航
|
||||||
|
def get_status() # {status, current_position, nav2_available}
|
||||||
|
def get_current_position() # 从 /amcl_pose topic 获取 [x,y,yaw]
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作原理**:
|
||||||
|
1. 使用 `nav2_simple_commander.BasicNavigator`(官方 Python API)
|
||||||
|
2. 在子线程中初始化 `rclpy`,构造 `PoseStamped` 消息并调用 `goToPose()`
|
||||||
|
3. 轮询 `isTaskComplete()` 查看导航是否完成
|
||||||
|
4. 超时时调用 `cancelTask()` 取消
|
||||||
|
5. 位置反馈从 `/amcl_pose`(AMCL 定位结果)而非 `/odom`(里程计)获取,避免累积漂移
|
||||||
|
|
||||||
|
**返回原点机制**: `_return_to_origin()` 导航到 `(0, 0)`,超时 180 秒,最多重试 3 次。
|
||||||
|
|
||||||
|
### 4.6 QRScanner — 二维码识别
|
||||||
|
|
||||||
|
```python
|
||||||
|
class QRScanner:
|
||||||
|
def open() # 打开摄像头(V4L2,device_index=4)
|
||||||
|
def read_frame() # 读取一帧(带超时保护)
|
||||||
|
def detect_qr(frame) # 双引擎:pyzbar > OpenCV QRCodeDetector
|
||||||
|
def scan_once() # 单次扫描
|
||||||
|
def scan_with_retry(max_attempts, interval) # 多次重试
|
||||||
|
```
|
||||||
|
|
||||||
|
**双引擎策略**:
|
||||||
|
1. **pyzbar**(优先): 识别率更高,支持多种条码格式
|
||||||
|
2. **OpenCV QRCodeDetector**(兜底): pyzbar 失败时启用
|
||||||
|
|
||||||
|
**绿屏/花屏修复**: `_fix_frame()` 方法检测 YUYV 格式未转换导致的绿屏(G 通道全满),自动做 `COLOR_YUV2BGR_YUYV` 转换。全黑帧直接丢弃。
|
||||||
|
|
||||||
|
### 4.7 ImageUploader — 照片上传
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ImageUploader:
|
||||||
|
def upload(image_path, serial_number, photo_index, photo_type)
|
||||||
|
def upload_batch(image_paths, serial_number, start_index)
|
||||||
|
```
|
||||||
|
|
||||||
|
**上传协议**:
|
||||||
|
- **方法**: HTTP POST(multipart/form-data)
|
||||||
|
- **URL**: `{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage`
|
||||||
|
- 正式: `https://ts.zhijian168.com/prod-api/file/uploadImage`
|
||||||
|
- 测试: `http://192.168.60.159:8080/file/uploadImage`
|
||||||
|
- **字段**: `file` (MultipartFile), `serialNumber` (String), `index` (Integer)
|
||||||
|
- **认证**: `Authorization: Bearer <JWT Token>`
|
||||||
|
- **重试**: 最多 3 次,间隔 2 秒
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 通信协议
|
||||||
|
|
||||||
|
### 5.1 Flask ↔ 前端
|
||||||
|
|
||||||
|
- **协议**: HTTP RESTful JSON
|
||||||
|
- **端口**: `5000`
|
||||||
|
- **格式**: `{"ok": bool, ...data}`
|
||||||
|
|
||||||
|
### 5.2 Flask ↔ AGV (ROS2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 发布速度指令
|
||||||
|
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" --once
|
||||||
|
|
||||||
|
# 获取位置 (AMCL)
|
||||||
|
ros2 topic echo /amcl_pose --once
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Flask ↔ 机械臂 (TCP)
|
||||||
|
|
||||||
|
```
|
||||||
|
请求: set_angles(-90.33,-90.08,0.16,-90.57,0.09,22.23,1000)\n
|
||||||
|
响应: set_angles:ok
|
||||||
|
|
||||||
|
请求: get_angles()\n
|
||||||
|
响应: get_angles:[-90.33,-90.08,0.16,-90.57,0.09,22.23]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Flask ↔ Java 后端
|
||||||
|
|
||||||
|
| 接口 | 方法 | URL 路径 | 说明 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| 报关单列表 | GET | `/zhijian/integration/customsListPage` | ?pageNum=&pageSize= |
|
||||||
|
| 机器列表 | GET | `/zhijian/integration/customsMachines` | ?customsId= |
|
||||||
|
| 机型查询 | GET | `/zhijian/profile/printer` | ?serialNumber= |
|
||||||
|
| 文件上传 | POST | `/file/uploadImage` | multipart/form-data |
|
||||||
|
|
||||||
|
**认证**: 所有请求携带 `Authorization: Bearer <token>` 头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 完整 API 接口文档
|
||||||
|
|
||||||
|
### 6.1 系统状态 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/status` | GET | 全局状态(连接/地图/任务统计) | - |
|
||||||
|
| `/api/system/connect` | POST | 一次性连接所有设备 | - |
|
||||||
|
| `/api/system/disconnect` | POST | 断开所有设备 | - |
|
||||||
|
| `/api/device/connect` | POST | 连接单个设备 | `{"device":"agv\|arm\|camera\|arm_camera"}` |
|
||||||
|
|
||||||
|
### 6.2 AGV 控制 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/agv/move` | POST | 控制移动 | `{"direction":"forward\|backward\|left\|right\|left_lateral\|right_lateral\|stop","speed":1.0}` |
|
||||||
|
| `/api/agv/position` | GET | 获取位置+电量 | - |
|
||||||
|
| `/api/agv/stop` | POST | 紧急停止 | - |
|
||||||
|
| `/api/agv/reset` | POST | 撞物体后复位 | - |
|
||||||
|
|
||||||
|
### 6.3 机械臂控制 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/arm/get_angles` | GET | 获取当前6关节角度 | - |
|
||||||
|
| `/api/arm/set_angles` | POST | 设置全部关节 | `{"angles":[],"speed":1000}` |
|
||||||
|
| `/api/arm/set_angle` | POST | 设置单个关节 | `{"joint":"J1","angle":90,"speed":500}` |
|
||||||
|
| `/api/arm/jog` | POST | 连续调节关节 | `{"joint":"J1","direction":1\|-1\|0,"speed":500}` |
|
||||||
|
| `/api/arm/get_coords` | GET | 获取末端坐标 | - |
|
||||||
|
| `/api/arm/power_on` | POST | 上电 | - |
|
||||||
|
| `/api/arm/state_on` | POST | 激活 | - |
|
||||||
|
| `/api/arm/state_off` | POST | 去激活 | - |
|
||||||
|
| `/api/arm/state_check` | GET | 检查状态 | - |
|
||||||
|
|
||||||
|
### 6.4 摄像头 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/camera/preview` | GET | AGV 摄像头 MJPEG 流 | - |
|
||||||
|
| `/api/camera/refresh` | GET | AGV 摄像头单帧 JPEG | - |
|
||||||
|
| `/api/camera/capture` | GET | 拍摄一张照片保存本地 | - |
|
||||||
|
| `/api/camera/arm_refresh` | GET | 机械臂摄像头单帧(翻转+花屏检测) | - |
|
||||||
|
| `/api/camera/arm_preview` | GET | 机械臂摄像头 MJPEG 代理流 | - |
|
||||||
|
| `/api/camera/qr_scan` | GET | AGV 摄像头扫码一次 | - |
|
||||||
|
| `/api/camera/capabilities` | GET | 摄像头能力信息 | - |
|
||||||
|
|
||||||
|
### 6.5 地图导航 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/map/load` | POST | 加载地图文件 | `{"map_dir":"...","map_file":"map.yaml"}` |
|
||||||
|
| `/api/map/save` | POST | 保存地图配置 | `{"map_dir":"...","map_file":"map.yaml"}` |
|
||||||
|
| `/api/map/image` | GET | 获取地图 PNG 图像 | - |
|
||||||
|
| `/api/map/meta` | GET | 获取地图元数据(分辨率/原点/尺寸) | - |
|
||||||
|
| `/api/navigate/to` | POST | 导航到目标坐标 | `{"x":1.0,"y":2.0,"yaw":0.0}` |
|
||||||
|
| `/api/navigate/stop` | POST | 停止导航 | - |
|
||||||
|
| `/api/navigate/cancel` | POST | 取消导航 | - |
|
||||||
|
| `/api/navigate/status` | GET | 获取导航状态 | - |
|
||||||
|
| `/api/navigate/path` | POST | 预览路径(Nav2 不可用) | `{"x":1.0,"y":2.0}` |
|
||||||
|
|
||||||
|
### 6.6 任务执行 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/mission/start` | POST | 开始执行任务 | `{"single_step":false,"arm_init":true,"agv_move":true,"qr_scan":true,"front_photo":true,"back_photo":true,"agv_speed":1.0,"arm_speed":1000}` |
|
||||||
|
| `/api/mission/stop` | POST | 停止任务 | - |
|
||||||
|
| `/api/mission/pause` | POST | 暂停任务 | - |
|
||||||
|
| `/api/mission/resume` | POST | 恢复任务 | - |
|
||||||
|
| `/api/mission/report` | GET | 获取执行报告 | - |
|
||||||
|
| `/api/mission/state` | GET | 任务实时状态(步骤/进度/查验/QR消息) | - |
|
||||||
|
| `/api/mission/log` | GET | 实时日志 | - |
|
||||||
|
| `/api/mission/manual-qr` | POST | 手动输入二维码(弹窗提交) | `{"qr":"BG042110276"}` |
|
||||||
|
| `/api/mission/error-skip` | POST | 错误弹窗:跳过 | - |
|
||||||
|
| `/api/mission/error-abort` | POST | 错误弹窗:中断 | - |
|
||||||
|
| `/api/mission/singlestep/confirm` | POST | 单步确认 | - |
|
||||||
|
| `/api/mission/singlestep/retry` | POST | 单步重试 | - |
|
||||||
|
| `/api/mission/singlestep/abort` | POST | 单步中断 | - |
|
||||||
|
|
||||||
|
### 6.7 任务配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/mission/config` | GET | 获取网格配置+空位矩阵 | - |
|
||||||
|
| `/api/mission/config` | POST | 设置网格配置 | `{"rows":2,"cols":5,"grid":[[],...],"arm_initial_pose":[]}` |
|
||||||
|
| `/api/mission/position` | GET | 获取 AGV 当前位置(设置点位用) | - |
|
||||||
|
| `/api/mission/init_pose` | POST | 将 AMCL 初始位置设为 (0,0,0) | - |
|
||||||
|
| `/api/mission/positions` | GET | 获取所有点位坐标 | - |
|
||||||
|
| `/api/mission/positions` | POST | 保存/更新单点位 | `{"row":0,"col":0,"side":"front","coords":[],"poses":[]}` |
|
||||||
|
| `/api/mission/machines` | GET | 获取所有机器配置 | - |
|
||||||
|
| `/api/mission/machines` | POST | 批量保存机器配置 | `{"machines":[...]}` |
|
||||||
|
| `/api/mission/machines/add` | POST | 添加单台机器 | `{"row":0,"col":0,"front":{},"back":{}}` |
|
||||||
|
| `/api/mission/machines/<id>` | PUT | 更新机器配置 | |
|
||||||
|
| `/api/mission/machines/<id>` | DELETE | 删除机器配置 | |
|
||||||
|
| `/api/mission/poses/<id>/<side>` | GET | 获取机器指定侧姿态 | - |
|
||||||
|
| `/api/mission/poses/<id>/<side>` | POST | 添加姿态到机器 | `{"arm_angles":[],"speed":500}` |
|
||||||
|
| `/api/mission/poses/<id>/<side>/<pid>` | DELETE | 删除姿态 | - |
|
||||||
|
| `/api/mission/qr_scan/<id>` | POST | AGV 摄像头扫码关联机器 | - |
|
||||||
|
| `/api/mission/generate_sequence` | GET | 生成蛇形拍摄序列预览 | - |
|
||||||
|
|
||||||
|
### 6.8 机型配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/models/list` | GET | 获取所有机型 | - |
|
||||||
|
| `/api/models/add` | POST | 添加机型 | `{"name":"机型1","serial_prefix":"BG"}` |
|
||||||
|
| `/api/models/<id>` | POST | 更新机型 | - |
|
||||||
|
| `/api/models/<id>` | DELETE | 删除机型 | - |
|
||||||
|
| `/api/models/poses/add` | POST | 添加姿态到机型 | `{"model_id":"xxx","name":"正1","photo_type":"front","arm_angles":[]}` |
|
||||||
|
| `/api/models/<id>/poses` | GET | 获取机型姿态列表 | - |
|
||||||
|
| `/api/models/<id>/poses/<pid>` | PUT | 更新姿态 | - |
|
||||||
|
| `/api/models/<id>/poses/<pid>` | DELETE | 删除姿态 | - |
|
||||||
|
|
||||||
|
### 6.9 二维码配置 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/qr/configs` | GET | 获取所有二维码配置 | - |
|
||||||
|
| `/api/qr/configs` | POST | 添加二维码配置 | `{"name":"二维码1","joint_angles":[]}` |
|
||||||
|
| `/api/qr/configs/<id>` | PUT | 更新二维码配置 | - |
|
||||||
|
| `/api/qr/configs/<id>` | DELETE | 删除二维码配置 | - |
|
||||||
|
| `/api/qr/configs/<id>/read-angles` | POST | 读取当前臂角度写入配置 | - |
|
||||||
|
| `/api/qr/scan/<id>` | POST | 机械臂摄像头扫码保存 | - |
|
||||||
|
|
||||||
|
### 6.10 报关单与查验 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/customs/list` | GET | 报关单列表(代理) | `?pageNum=1&pageSize=50` |
|
||||||
|
| `/api/customs/machines` | GET | 报关单机器列表(代理) | `?customsId=xxx` |
|
||||||
|
| `/api/customs/selected` | POST | 设定当前报关单 | `{"id":"xxx","name":"xxx","machine_ids":[]}` |
|
||||||
|
| `/api/customs/selected` | GET | 获取当前报关单 | - |
|
||||||
|
| `/api/customs/printer` | GET | 查询机型+更新查验计数 | `?serialNumber=xxx` |
|
||||||
|
| `/api/customs/inspection/start` | POST | 开始查验 | `{"customsId":"xxx"}` |
|
||||||
|
| `/api/customs/inspection` | GET | 获取查验状态 | - |
|
||||||
|
| `/api/customs/inspection/end` | POST | 结束查验 | - |
|
||||||
|
| `/api/customs/inspection/update` | POST | 直接更新计数 | `{"inventoryCode":"xxx"}` |
|
||||||
|
|
||||||
|
### 6.11 环境切换 API
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 | 参数 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `/api/config/mode` | GET | 获取当前环境 | - |
|
||||||
|
| `/api/config/mode` | POST | 切换测试/正式环境 | `{"test_mode":true}` |
|
||||||
|
|
||||||
|
**环境差异**:
|
||||||
|
| 项目 | 测试环境 | 正式环境 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Base URL | `http://192.168.60.159:8080` | `https://ts.zhijian168.com` |
|
||||||
|
| API 前缀 | 空 | `/prod-api` |
|
||||||
|
| 上传地址 | `http://192.168.60.159:8080/file/uploadImage` | `https://ts.zhijian168.com/prod-api/file/uploadImage` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 任务执行流程
|
||||||
|
|
||||||
|
### 7.1 完整生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] 前端设置页配置
|
||||||
|
├── 加载地图 → 设置 M×N 网格尺寸(rows/cols)
|
||||||
|
├── 标注空位(Machine Toggle 切换每个单元格有/无机器)
|
||||||
|
├── 逐点位标定坐标(AGV 开到机器前→读取位置→保存)
|
||||||
|
├── 配置二维码扫描角度(机械臂对准二维码位置)
|
||||||
|
├── 配置机型姿态组(正/背面,每面多角度)
|
||||||
|
└── 连接设备(AGV/机械臂/摄像头)
|
||||||
|
|
||||||
|
[2] 报关单查验
|
||||||
|
├── 选择报关单 → 开始查验
|
||||||
|
└── 系统按 inventoryCode 聚合统计各机型待查验数量
|
||||||
|
|
||||||
|
[3] 启动任务
|
||||||
|
├── POST /api/mission/start(可选单步模式+步骤开关)
|
||||||
|
└── MissionExecutorV3.execute_mission() 在新线程中运行
|
||||||
|
|
||||||
|
[4] 逐点位蛇形执行
|
||||||
|
For each 点位 (pr, c) in 蛇形路径:
|
||||||
|
├── [可选] 恢复机械臂初始姿态
|
||||||
|
├── [可选] 导航到该点位坐标
|
||||||
|
│ └── Nav2Navigator.navigate_to_pose() → BasicNavigator.goToPose()
|
||||||
|
│
|
||||||
|
├── 背面操作(如果 pr>0 且 (pr-1,c) 有机器)
|
||||||
|
│ ├── 切换到 QR 扫描姿态(可选)
|
||||||
|
│ ├── 扫描二维码 → 查机型 → [可选] 拍照
|
||||||
|
│ └── 上传照片 + 更新查验计数
|
||||||
|
│
|
||||||
|
└── 正面操作(如果 pr<rows 且 (pr,c) 有机器)
|
||||||
|
├── 切换到 QR 扫描姿态
|
||||||
|
├── _scan_qr_with_poses(qr_configs):
|
||||||
|
│ ├── 逐姿态尝试扫描(pyzbar + OpenCV)
|
||||||
|
│ ├── 失败 → 弹窗 _request_manual_qr()
|
||||||
|
│ └── 机型不在报关单 → 弹窗重新输入(不可跳过)
|
||||||
|
│
|
||||||
|
├── _lookup_model(qr_value):
|
||||||
|
│ ├── 请求 /api/customs/printer?serialNumber=xxx
|
||||||
|
│ ├── 超量检查(inspected >= quantify)
|
||||||
|
│ └── 返回机型名称
|
||||||
|
│
|
||||||
|
└── _shoot(model, "front"):
|
||||||
|
├── 逐姿态设置关节角度 + 等待就位
|
||||||
|
├── _capture_arm_photo() → 机械臂摄像头拍照
|
||||||
|
├── _upload_photo_bytes() → HTTP上传
|
||||||
|
└── 更新查验计数
|
||||||
|
|
||||||
|
[5] 任务完成
|
||||||
|
├── _return_to_origin() → 导航回 (0,0)
|
||||||
|
└── 生成执行报告
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 QR 扫描流程详解
|
||||||
|
|
||||||
|
```
|
||||||
|
_scan_qr_with_poses(qr_configs, machine_row):
|
||||||
|
1. 逐 QR 配置尝试
|
||||||
|
├── set_angles(qr_config.joint_angles) → 机械臂移到扫码位
|
||||||
|
├── _wait_arm_ready() → 等待到位(容差 2°)
|
||||||
|
└── _decode_qr_from_arm():
|
||||||
|
├── HTTP GET 机械臂摄像头单帧
|
||||||
|
├── 花屏检测 (_is_corrupted_jpeg)
|
||||||
|
├── pyzbar.decode() → 识别成功
|
||||||
|
└── OpenCV QRCodeDetector → 兜底
|
||||||
|
|
||||||
|
2. 如果识别失败:
|
||||||
|
├── 报错日志 + 弹窗 _request_manual_qr()
|
||||||
|
└── 强制用户扫描/输入(不可跳过,仅任务停止可退出)
|
||||||
|
|
||||||
|
3. 如果机型不在报关单 (_lookup_model 返回 matched=null):
|
||||||
|
├── 弹窗 _request_manual_qr() 强制重新输入
|
||||||
|
└── 循环直到匹配或任务停止
|
||||||
|
|
||||||
|
4. 如果已查验数量 ≥ 报关单数量 (_lookup_model 检测超量):
|
||||||
|
├── 弹窗 _request_manual_qr() 强制重新输入
|
||||||
|
└── 循环直到不超量或任务停止
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 拍照流程详解
|
||||||
|
|
||||||
|
```
|
||||||
|
_shoot(model, side, row, col, qr_value, machine_row):
|
||||||
|
1. 过滤姿态: 只取 photo_type == side 的姿态
|
||||||
|
2. 镜像规则: machine_row % 2 == 1 → J1 = -J1
|
||||||
|
3. 逐姿态执行:
|
||||||
|
├── set_angles(pose.arm_angles, speed)
|
||||||
|
├── _wait_arm_ready() → 等待姿态稳定
|
||||||
|
├── _capture_arm_photo():
|
||||||
|
│ ├── HTTP GET 机械臂摄像头 JPG
|
||||||
|
│ ├── 花屏检测
|
||||||
|
│ └── 保存到 /home/elephant/photos/
|
||||||
|
└── _upload_photo_bytes():
|
||||||
|
├── POST multipart/form-data
|
||||||
|
├── serialNumber = qr_value
|
||||||
|
├── index = next_upload_index(全局递增,从1开始)
|
||||||
|
└── 重试3次
|
||||||
|
4. 日志: "拍照完成 (机型=Mxx, 面=正面, 位置=r-c)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 错误处理
|
||||||
|
|
||||||
|
| 场景 | 触发条件 | 处理方式 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 导航失败 | Nav2 超时/返回 failed | 错误弹窗(跳过/中断) |
|
||||||
|
| QR 识别失败 | 所有姿态尝试均未识别 | 手动输入弹窗(不能跳过) |
|
||||||
|
| 机型不在报关单 | printer 返回空 matchedItem | 手动输入弹窗(不能跳过) |
|
||||||
|
| 查验超量 | inspected >= quantify | 手动输入弹窗(不能跳过) |
|
||||||
|
| 拍照失败 | HTTP 请求/文件损坏 | 记录日志,继续下一张 |
|
||||||
|
| 上传失败 | HTTP 超时/401/非200 | 重试3次,记录日志 |
|
||||||
|
| 机械臂超时 | _wait_arm_ready 15秒超时 | 记录实际偏差,继续执行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据配置格式
|
||||||
|
|
||||||
|
### 8.1 任务网格配置 (mission_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rows": 2,
|
||||||
|
"cols": 5,
|
||||||
|
"grid": [[true, true, true, true, true],
|
||||||
|
[true, true, true, true, true]],
|
||||||
|
"positions": [
|
||||||
|
{"row": 0, "col": 0, "side": "front", "coords": [0.54, -1.32, -0.05], "poses": []},
|
||||||
|
{"row": 1, "col": 0, "side": "back", "coords": [0.65, -3.63, -3.06], "poses": []}
|
||||||
|
],
|
||||||
|
"arm_initial_pose": [-90.33, -90.08, 0.16, -90.57, 0.09, 22.23]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `grid[r][c]` = `true` 表示该位置有机器
|
||||||
|
- `positions` 中 `row=pr` 表示点位行(非机器行),机器行 `mr = pr` (正面) 或 `mr = pr-1` (背面)
|
||||||
|
- `coords = [x, y, yaw]` 地图坐标和朝向
|
||||||
|
|
||||||
|
### 8.2 机器配置 (machines_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "m_0_0",
|
||||||
|
"row": 0, "col": 0,
|
||||||
|
"front": {
|
||||||
|
"coords": [0.54, -1.32, -0.05],
|
||||||
|
"poses": [{"id":"pose_xxx","name":"正1","arm_angles":[...],"speed":500}]
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"coords": [0.65, -3.63, -3.06],
|
||||||
|
"poses": [{"id":"pose_xxx","name":"背1","arm_angles":[...],"speed":500}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 机型配置 (models_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "m_1778767289",
|
||||||
|
"name": "MXM465N",
|
||||||
|
"serial_prefix": "BG",
|
||||||
|
"poses": [
|
||||||
|
{
|
||||||
|
"id": "pose_xxx1",
|
||||||
|
"name": "正面姿态1",
|
||||||
|
"photo_type": "front",
|
||||||
|
"arm_angles": [-93.59, -184.34, 50.58, -38.33, -85.15, 20.40],
|
||||||
|
"speed": 500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pose_xxx2",
|
||||||
|
"name": "背面姿态1",
|
||||||
|
"photo_type": "back",
|
||||||
|
"arm_angles": [15.86, -161.13, 138.0, -162.0, 168.0, 15.65],
|
||||||
|
"speed": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `photo_type`: `"front"` / `"back"` / `"nameplate"`
|
||||||
|
- `arm_angles`: `[J1, J2, J3, J4, J5, J6]` 单位为度
|
||||||
|
|
||||||
|
### 8.4 二维码扫描姿态 (qr_config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{
|
||||||
|
"id": "qr_001",
|
||||||
|
"name": "正面扫码位",
|
||||||
|
"joint_angles": [-89.80, -2.01, -87.18, -82.50, -93.32, 20.40],
|
||||||
|
"qr_value": "",
|
||||||
|
"model_id": ""
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 部署与运维
|
||||||
|
|
||||||
|
### 9.1 环境要求
|
||||||
|
|
||||||
|
**AGV (主控)**:
|
||||||
|
- Ubuntu 22.04 (ROS2 Humble)
|
||||||
|
- uv 管理的 Python 3.10 虚拟环境
|
||||||
|
- OpenCV (cv2), Flask, requests, numpy, pyzbar, PyYAML
|
||||||
|
- ROS2 Humble + nav2_simple_commander
|
||||||
|
- 系统依赖:ffmpeg、libzbar0
|
||||||
|
|
||||||
|
**机械臂 (Pi)**:
|
||||||
|
- arm_server.py(TCP 服务器端口 5002)
|
||||||
|
- arm_camera.py(MJPEG 服务器端口 5003)
|
||||||
|
- RoboFlow(大象机器人 SDK)
|
||||||
|
- uv 管理的 Python 3.10 虚拟环境
|
||||||
|
|
||||||
|
### 9.2 启动流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === 首次部署 / 依赖同步 ===
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# === 机械臂端 (Pi) ===
|
||||||
|
# 1. 启动 arm_server (TCP 5002) + arm_camera (MJPEG 5003)
|
||||||
|
sudo systemctl start arm_server
|
||||||
|
|
||||||
|
# === AGV 端 ===
|
||||||
|
# 2. 完整启动 ROS2 导航栈 + Flask
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
./scripts/start_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 部署命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地 → AGV 部署:同步仓库根目录后,在 AGV 上执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# 部署后验证远程文件
|
||||||
|
ssh elephant@192.168.60.80 "grep 'def _lookup_model' /home/elephant/work/smart-inspection/agv_app/utils/mission_executor.py"
|
||||||
|
|
||||||
|
# 重启 Flask
|
||||||
|
ssh elephant@192.168.60.80 'bash -s' < scripts/restart_flask.sh
|
||||||
|
|
||||||
|
# 清空 Python 缓存(关键!修改后必须清)
|
||||||
|
ssh elephant@192.168.60.80 "find /home/elephant/work/smart-inspection/agv_app -name '*.pyc' -delete; find /home/elephant/work/smart-inspection/agv_app -name '__pycache__' -type d -exec rm -rf {} +"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 关键运维经验
|
||||||
|
|
||||||
|
| 问题 | 根因 | 解决方案 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Flask 模板/JS 不生效 | 模板缓存 | 重启 Flask 服务 |
|
||||||
|
| Python 修改不生效 | `__pycache__` 缓存 | 删除所有 .pyc 和 __pycache__ |
|
||||||
|
| V4L2 摄像头无响应 | 设备独占 | kill 残留进程后重开 |
|
||||||
|
| ROS2 节点互相不可见 | ROS_DOMAIN_ID 不一致 | 统一设为 1 |
|
||||||
|
| 导航 DDS 发现失败 | FastRTPS 共享内存残留 | `rm -f /dev/shm/fastrtps_*` |
|
||||||
|
| 机械臂摄像头花屏 | USB 掉线致 ffmpeg 读失效 fd | arm_server 添加 JPEG 校验+自动重连 |
|
||||||
|
| Flask SIGTERM 被杀 | sshpass+nohup 触发 | 用 expect 脚本重启 |
|
||||||
|
| 照片上传 405 | 缺少 `/prod-api` 前缀 | config.py 动态拼接 API_PREFIX |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 关键决策与约束
|
||||||
|
|
||||||
|
### 10.1 架构决策
|
||||||
|
|
||||||
|
| 决策 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| **agv_controller 用 ROS2 CLI 而非 rclpy** | 避免 rclpy 初始化与 Flask 多线程冲突 |
|
||||||
|
| **Nav2 用 BasicNavigator API 而非 subprocess** | 原生 API 更可靠(subprocess 的 stdin pipe 有 Humble bug) |
|
||||||
|
| **机械臂用 TCP Socket 而非 pymycobot** | pymycobot 存在死锁问题 |
|
||||||
|
| **位置源用 /amcl_pose 而非 /odom** | /odom 累积漂移,/amcl_pose 有地图校正 |
|
||||||
|
| **Vue 用 `{% raw %}` 包裹** | Jinja2 与 Vue 3 `{{}}` 冲突 |
|
||||||
|
| **单例 MissionExecutorV3** | 一个任务实例全局可见,方便停止 |
|
||||||
|
| **蛇形路径镜像只取反 J1** | 用户要求:同一边只需镜像 J1 关节 |
|
||||||
|
| **QR 弹窗不可跳过** | 业务约束:机型不在报关单/超量必须人工介入 |
|
||||||
|
| **上传序号全局递增** | 连续编号便于后端核对 |
|
||||||
|
| **环境切换无需重启** | 运行时动态修改 config 变量 + 代理 URL |
|
||||||
|
|
||||||
|
### 10.2 已知约束
|
||||||
|
|
||||||
|
| 约束 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| **摄像头 device_index=4** | 固定的 Orbbec Gemini 设备号,不可修改 |
|
||||||
|
| **V4L2 设备独占** | 同时只能一个进程打开 /dev/video4 |
|
||||||
|
| **ROS 时钟漂移 ~5.5min** | 需检查 AGV 的 RTC/NTP 同步 |
|
||||||
|
| **机械臂精度 ±1.5°** | _wait_arm_ready 容差 2° 可能过严 |
|
||||||
|
| **AGV 重启自动切正式环境** | 无持久化配置方案 |
|
||||||
|
| **报关单数据依赖外部 API** | API 格式不稳定(裸数组 vs 包装对象) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
agv_app/
|
||||||
|
├── app.py # Flask 主程序 (2132行)
|
||||||
|
├── config.py # 集中配置
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # AGV 控制页
|
||||||
|
│ ├── setting.html # 设置页(网格/机型/报关单)
|
||||||
|
│ └── running.html # 任务运行页
|
||||||
|
├── static/
|
||||||
|
│ ├── css/style.css # 样式(深色主题)
|
||||||
|
│ └── js/
|
||||||
|
│ ├── app.js # 控制页逻辑
|
||||||
|
│ ├── setting.js # 设置页逻辑
|
||||||
|
│ ├── running.js # 运行页逻辑
|
||||||
|
│ └── vue3.global.prod.js # Vue 3 CDN
|
||||||
|
├── data/
|
||||||
|
│ ├── mission_config.json # 网格尺寸+点位坐标
|
||||||
|
│ ├── machines_config.json # 机器配置(正/背面)
|
||||||
|
│ ├── models_config.json # 机型配置(姿态组)
|
||||||
|
│ ├── qr_config.json # 二维码扫描姿态
|
||||||
|
│ └── map_config.json # 地图配置
|
||||||
|
├── utils/
|
||||||
|
│ ├── mission_executor.py # 任务执行器 V3 (1198行)
|
||||||
|
│ ├── agv_controller_ros2.py # AGV 运动控制 (216行)
|
||||||
|
│ ├── arm_client.py # 机械臂客户端 (170行)
|
||||||
|
│ ├── nav2_navigator.py # Nav2 导航器 (350行)
|
||||||
|
│ ├── qr_scanner.py # 二维码扫描 (170行)
|
||||||
|
│ └── image_uploader.py # 图片上传 (76行)
|
||||||
|
```
|
||||||
|
|
||||||
|
启动脚本位于仓库顶层 `scripts/`。LiDAR 时间戳修复脚本部署在 AGV 的
|
||||||
|
`/home/elephant/work/scan_fixer/`,由 `scripts/start_all.sh` 调用。
|
||||||
|
|
||||||
|
## 附录 B:关键依赖
|
||||||
|
|
||||||
|
```
|
||||||
|
pyproject.toml # Python 依赖声明
|
||||||
|
uv.lock # 锁定版本
|
||||||
|
.python-version # Python 3.10
|
||||||
|
ffmpeg # 系统依赖,机械臂视频流
|
||||||
|
libzbar0 # 系统依赖,pyzbar 动态库
|
||||||
|
ROS2 Humble # 系统环境,提供 rclpy/nav2_simple_commander/geometry_msgs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档维护**: 本文档随代码同步更新。关键变更请记录到 `memory/YYYY-MM-DD.md`。
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# AGV + 机械臂 移动拍摄平台 — 开发记录
|
||||||
|
|
||||||
|
> 汇总 2026年5-6月期间的所有修复记录和任务总结
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、running.html 显示修复 + 任务执行状态实时更新 (2026-05-29 13:10)
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
修复运行页面两个 bug:
|
||||||
|
1. 模板中 `{{ }}` 显示为原始文本(Vue 未挂载)
|
||||||
|
2. 任务执行过程中状态不更新(始终显示"⏳等待")
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
|
||||||
|
**问题1:`{{ }}` 原文显示**
|
||||||
|
- `running.js` 写有 `delimiters: ['[[', ']]']`,但 **Vue 3 已移除此选项**(被静默忽略)
|
||||||
|
- Vue 3 只认 `{{ }}`,但模板中混用了 `[[ ]]` 和 `{% raw %}{{ }}{% endraw %}`
|
||||||
|
- 残留的裸 `[[ ]]`(log、report、errorMsg 等)未被 Jinja2 处理,Vue 也因 delimiters 冲突不解析
|
||||||
|
- **修复**:删除 `delimiters` 行 → 全部改用 `{% raw %}{{ }}{% endraw %}` 包裹 Vue 表达式
|
||||||
|
|
||||||
|
**问题2:状态不更新**
|
||||||
|
- `api_mission_state()` 每次都从文件初始化 `point_status`/`machine_status` 为全 `"pending"`
|
||||||
|
- `mission_executor.py` 完全没有跟踪 `point_status` 和 `machine_status`
|
||||||
|
- **修复**:executor 添加状态跟踪 + app.py 从 executor.report 读取实时状态
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `running.js` | 删除 `delimiters: ['[[', ']]']` |
|
||||||
|
| `running.html` | 全部 `[[ ]]` → `{% raw %}{{ }}{% endraw %}`(14处) |
|
||||||
|
| `app.py` | `api_mission_state()` 从 `ex.report` 读取 `point_status`/`machine_status` |
|
||||||
|
| `mission_executor.py` | 初始化+实时更新 `point_status`(pending/active/done/skipped)和 `machine_status`(pending/active/completed) |
|
||||||
|
|
||||||
|
### 关键设计
|
||||||
|
|
||||||
|
**point_status 状态流转:**
|
||||||
|
- `pending` → `active`(开始导航到点位) → `done`(到达) → `skipped`(空位永不更新)
|
||||||
|
|
||||||
|
**machine_status 状态流转:**
|
||||||
|
- 初始化全 `pending`
|
||||||
|
- 正面扫码开始:`status=active, step=正面扫码`
|
||||||
|
- 扫码完成:`qr=done/skipped, qr_val=xxx, step=正面拍照`
|
||||||
|
- 正面拍照完成:`front=done/skipped, front_cnt++`
|
||||||
|
- 背面拍照开始:`step=背面拍照`
|
||||||
|
- 背面拍照完成:`back=done/skipped, back_cnt++, status=completed, step=完成`
|
||||||
|
|
||||||
|
### 部署状态
|
||||||
|
- 所有4个文件已通过 scp 部署到 `192.168.50.93`
|
||||||
|
- Flask 已重启(PID 3664)
|
||||||
|
- API 验证通过:`point_status` 和 `machine_status` 正常返回
|
||||||
|
- 本地文件已同步回 workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、AGV 蛇形路径关节反转逻辑 (2026-05-29 13:49)
|
||||||
|
|
||||||
|
### 需求理解
|
||||||
|
|
||||||
|
蛇形路径行走时,AGV 在不同行到达点位时朝向相反:
|
||||||
|
- 偶数行(0,2,4...)点位 → AGV 从出发方向来 → 正面/背面朝向 = 标定朝向 → **不反转**
|
||||||
|
- 奇数行(1,3,5...)点位 → AGV 从对面来 → 正面/背面朝向 = 标定朝向的反面 → **反转所有关节角度**
|
||||||
|
|
||||||
|
### 修复内容
|
||||||
|
|
||||||
|
修改 `mission_executor.py`:
|
||||||
|
|
||||||
|
**1. `_shoot()` 新增 `machine_row` 参数**
|
||||||
|
```python
|
||||||
|
def _shoot(self, model, side, row, col, qr_value, machine_row=0):
|
||||||
|
invert = (machine_row % 2 == 1) # 奇数行=反转
|
||||||
|
if invert:
|
||||||
|
angles = [-a for a in angles] # 6个关节全部取反
|
||||||
|
```
|
||||||
|
调用处传入 `machine_row`(正面=pr,背面=pr-1)
|
||||||
|
|
||||||
|
**2. `_scan_qr_with_poses()` 新增 `machine_row` 参数**
|
||||||
|
```python
|
||||||
|
def _scan_qr_with_poses(self, qr_configs, machine_row=0):
|
||||||
|
invert = (machine_row % 2 == 1)
|
||||||
|
if invert:
|
||||||
|
angles = [-a for a in angles] # 二维码扫描时也反转
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 调用处传递 `machine_row`**
|
||||||
|
- `_scan_qr_with_poses(qr_configs, machine_row=pr)` — 正面扫码
|
||||||
|
- `_shoot(model, "front", ..., pr)` — 正面拍照
|
||||||
|
- `_shoot(model, "back", ..., pr-1)` — 背面拍照
|
||||||
|
|
||||||
|
### 部署状态
|
||||||
|
- Flask PID 20577,AGV IP 192.168.50.93
|
||||||
|
- 已通过语法检查 ✅ 已部署 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修复删除机器姿态 404 错误 (2026-05-29)
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
删除机器姿态时出现 404 错误:
|
||||||
|
```
|
||||||
|
/api/mission/poses/m_1778767289/pose_1778767312/undefined
|
||||||
|
```
|
||||||
|
URL 末尾出现 `undefined`,说明 `poseId` 参数丢失。
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
JS 中存在两个同名方法 `deletePose`:
|
||||||
|
1. **机型姿态** (L457): `deletePose(modelId, poseId)` → 调用 `/api/models/...`
|
||||||
|
2. **机器姿态** (L776): `deletePose(machineId, side, poseId)` → 调用 `/api/mission/poses/...`
|
||||||
|
|
||||||
|
Vue 方法重载机制导致参数错位,`poseId` 变成 `undefined`。
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
将机器姿态方法重命名为 `deleteMachinePose`,避免命名冲突。
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
- `static/js/setting.js` L776: `deletePose` → `deleteMachinePose`
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- setting.js 已部署到 AGV
|
||||||
|
- setting.html 已部署到 AGV(版本号更新)
|
||||||
|
- 浏览器需刷新缓存 (Ctrl+F5)
|
||||||
|
|
||||||
|
### 待确认
|
||||||
|
- 模板中是否有调用 `deleteMachinePose` 的地方需同步修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术说明文档生成 (2026-06-17)
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
为 AGV + 机械臂移动拍摄平台项目生成详细的技术说明文档
|
||||||
|
|
||||||
|
### 产出
|
||||||
|
- **文件**: `AGV_机械臂_技术说明文档.md` (888行, 39.5KB)
|
||||||
|
- **内容覆盖**:
|
||||||
|
1. 项目概述(业务目标、核心能力、技术栈)
|
||||||
|
2. 系统架构(架构图、核心文件清单)
|
||||||
|
3. 硬件环境与网络拓扑(设备清单、参数)
|
||||||
|
4. 核心模块详解(GlobalState、MissionExecutorV3、AGVController、ArmClient、Nav2Navigator、QRScanner、ImageUploader)
|
||||||
|
5. 通信协议(Flask↔前端、ROS2、TCP Socket、Java后端)
|
||||||
|
6. 完整API接口文档(11个分组、98个端点)
|
||||||
|
7. 任务执行流程(生命周期、QR扫描流程、拍照流程、错误处理)
|
||||||
|
8. 数据配置格式(4种JSON schema)
|
||||||
|
9. 部署与运维(启动流程、部署命令、常见问题)
|
||||||
|
10. 关键决策与约束(10项架构决策 + 6项已知约束)
|
||||||
|
|
||||||
|
### 数据来源
|
||||||
|
- 逐文件阅读了全部7个Python源文件(共~4312行代码)
|
||||||
|
- 读取了4个数据配置文件
|
||||||
|
- 结合记忆条目中的经验教训和已知问题
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[project]
|
||||||
|
name = "smart-inspection"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AGV 智能巡检系统与机械臂服务端"
|
||||||
|
readme = "scripts/README.md"
|
||||||
|
requires-python = ">=3.10,<3.11"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=2.0,<2.3",
|
||||||
|
"flask-cors>=3.0",
|
||||||
|
"numpy>=1.20",
|
||||||
|
"opencv-python>=4.5",
|
||||||
|
"pillow>=10.0",
|
||||||
|
"pymycobot>=4.0.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"pyzbar>=0.1.8",
|
||||||
|
"requests>=2.25",
|
||||||
|
"werkzeug>=2.2,<3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /home/elephant/work/agv_app
|
|
||||||
|
|
||||||
# 语法检查
|
|
||||||
python3 -m py_compile app.py
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Syntax error!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
nohup python3 app.py > app.log 2>&1 &
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
if ss -tlnp | grep 5000; then
|
|
||||||
echo "✓ 端口5000 正常"
|
|
||||||
# 测试机械臂单帧
|
|
||||||
result=$(curl -s --max-time 5 http://127.0.0.1:5000/api/camera/arm_refresh | head -c 4)
|
|
||||||
echo -n "arm_refresh: "
|
|
||||||
if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then
|
|
||||||
echo "JPEG OK ✓"
|
|
||||||
else
|
|
||||||
echo "返回: $(echo $result | xxd | head -1)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "✗ 启动失败"
|
|
||||||
tail -10 app.log
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# AGV 智能巡检系统 — 脚本说明
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── start_all.sh ← 生产环境完整启动(ROS2 + Nav2 + Flask)
|
||||||
|
├── stop_all.sh ← 生产环境完整停止
|
||||||
|
├── start_flask.sh ← 仅重启 Flask(修改代码后快速部署)
|
||||||
|
├── restart_flask.sh ← 语法检查 + 清缓存 + 重启 Flask + 验证
|
||||||
|
└── dev_start.sh ← 本地开发用(前台运行,不启动 ROS2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
### 0. 初始化 Python 环境
|
||||||
|
|
||||||
|
项目使用 `uv` 统一管理 Python 虚拟环境,依赖声明在仓库根目录 `pyproject.toml`,锁定版本在 `uv.lock`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如系统尚未安装 uv,先安装 uv
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# 在仓库根目录执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv sync` 会按 `.python-version` 创建 Python 3.10 虚拟环境到 `.venv`。ROS2 Humble 仍使用系统环境 `/opt/ros/humble`,不要把 ROS2 系统包写入 `pyproject.toml`。
|
||||||
|
|
||||||
|
系统依赖仍需通过系统包管理器安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y ffmpeg libzbar0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 首次开机 / 完整重启
|
||||||
|
```bash
|
||||||
|
# 在 AGV 上执行
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
./scripts/stop_all.sh # 先彻底清理
|
||||||
|
./scripts/start_all.sh # 完整启动
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改代码后快速部署
|
||||||
|
```bash
|
||||||
|
# 部署文件到 AGV 后
|
||||||
|
ssh elephant@192.168.60.80 'bash -s' < scripts/restart_flask.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 本地开发调试(不连硬件)
|
||||||
|
```bash
|
||||||
|
# 在本机执行,仅启动 Flask
|
||||||
|
./scripts/dev_start.sh
|
||||||
|
# 访问 http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 远程轻量重启(ROS2 已运行)
|
||||||
|
```bash
|
||||||
|
ssh elephant@192.168.60.80 'bash -s' < scripts/start_flask.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
所有脚本支持通过环境变量覆盖默认路径:
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `AGV_PROJECT_DIR` | `/home/elephant/work/smart-inspection` | 仓库根目录 |
|
||||||
|
| `AGV_APP_DIR` | `$AGV_PROJECT_DIR/agv_app` | Flask 应用目录 |
|
||||||
|
| `AGV_ROS2_DIR` | `/home/elephant/agv_pro_ros2` | ROS2 工作空间 |
|
||||||
|
| `SCAN_FIXER_DIR` | `/home/elephant/work/scan_fixer` | 时间戳修正工具目录 |
|
||||||
|
| `FIXER_SCRIPT` | `fix_scan_timestamp_v6.py` | fixer 脚本名 |
|
||||||
|
|
||||||
|
## 日志位置(AGV 上)
|
||||||
|
|
||||||
|
| 组件 | 日志 |
|
||||||
|
|------|------|
|
||||||
|
| bringup (激光雷达) | `/tmp/ros2_bringup.log` |
|
||||||
|
| Nav2 导航 | `/tmp/ros2_nav2.log` |
|
||||||
|
| scan fixer | `/tmp/scan_fixer.log` |
|
||||||
|
| Flask | `/tmp/agv_flask.log` |
|
||||||
|
|
||||||
|
## 机械臂端
|
||||||
|
|
||||||
|
机械臂 (Pi) 的启动由 systemd 托管,在 Pi 上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/work/smart-inspection
|
||||||
|
uv sync
|
||||||
|
sudo systemctl start arm_server # 启动
|
||||||
|
sudo systemctl status arm_server # 查看状态
|
||||||
|
sudo systemctl enable arm_server # 开机自启
|
||||||
|
```
|
||||||
|
|
||||||
|
配置见 `arm_server/arm_server.service`。
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# dev_start.sh - 本地开发环境启动(不启动 ROS2/机械臂硬件)
|
||||||
|
# 用法: ./scripts/dev_start.sh
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
AGV_APP_DIR="$PROJECT_DIR/agv_app"
|
||||||
|
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 本地开发模式 - 仅启动 Flask"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 切换到项目目录
|
||||||
|
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
||||||
|
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
|
||||||
|
|
||||||
|
cd "$AGV_APP_DIR"
|
||||||
|
|
||||||
|
# 检查是否有运行的 Flask 进程
|
||||||
|
FLASK_PID=$(pgrep -f "python.*app.py" 2>/dev/null || true)
|
||||||
|
if [ -n "$FLASK_PID" ]; then
|
||||||
|
echo "Flask 已在运行 (PID: $FLASK_PID)"
|
||||||
|
read -p "是否重启? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
kill "$FLASK_PID" 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
else
|
||||||
|
echo "保持现有进程,退出"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 使用前台模式运行(方便看日志和 Ctrl+C 停止)
|
||||||
|
echo "启动 Flask (前台模式,Ctrl+C 停止)..."
|
||||||
|
echo "访问: http://127.0.0.1:5000"
|
||||||
|
echo ""
|
||||||
|
exec uv run --locked python app.py
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# restart_flask.sh - 语法检查 + 重启 Flask + 验证
|
||||||
|
# 用法: ssh elephant@<AGV_IP> 'bash -s' < scripts/restart_flask.sh
|
||||||
|
# 或在 AGV 上: cd ~/work/smart-inspection && ./scripts/restart_flask.sh
|
||||||
|
# ============================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
|
||||||
|
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
|
||||||
|
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
|
||||||
|
|
||||||
|
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
||||||
|
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
|
||||||
|
|
||||||
|
cd "$AGV_APP_DIR"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " 重启 Flask 服务"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. 语法检查
|
||||||
|
echo "[1/3] Python 语法检查..."
|
||||||
|
uv run --locked python -m py_compile app.py
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 语法错误,请先修复"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✅ 语法检查通过"
|
||||||
|
|
||||||
|
# 2. 清缓存 + 重启
|
||||||
|
echo "[2/3] 清理缓存并重启..."
|
||||||
|
find "$AGV_APP_DIR" -name '*.pyc' -delete 2>/dev/null
|
||||||
|
find "$AGV_APP_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
|
||||||
|
|
||||||
|
pkill -f "python.*app.py" 2>/dev/null || true
|
||||||
|
pkill -f "uv run .*python app.py" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
|
||||||
|
FLASK_PID=$!
|
||||||
|
echo " Flask PID: $FLASK_PID"
|
||||||
|
|
||||||
|
# 3. 验证
|
||||||
|
echo "[3/3] 验证服务..."
|
||||||
|
sleep 3
|
||||||
|
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
|
||||||
|
echo " ✅ 端口 5000 正常监听"
|
||||||
|
# 测试机械臂摄像头单帧
|
||||||
|
result=$(curl -s --max-time 5 http://127.0.0.1:5000/api/camera/arm_refresh 2>/dev/null | head -c 4)
|
||||||
|
if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then
|
||||||
|
echo " ✅ arm_refresh 返回 JPEG"
|
||||||
|
else
|
||||||
|
echo " ⚠️ arm_refresh 返回异常(机械臂可能未连接)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ❌ 端口 5000 未监听,查看日志:"
|
||||||
|
tail -10 /tmp/agv_flask.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " ✅ 重启完成"
|
||||||
|
echo "=========================================="
|
||||||
@@ -9,8 +9,12 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
AGV_APP_DIR="/home/elephant/work/agv_app"
|
# ---- 可配置项(环境变量覆盖默认值) ----
|
||||||
AGV_ROS2_DIR="/home/elephant/agv_pro_ros2"
|
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
|
||||||
|
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
|
||||||
|
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
|
||||||
|
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-/home/elephant/work/scan_fixer}"
|
||||||
|
FIXER_SCRIPT="${FIXER_SCRIPT:-fix_scan_timestamp_v6.py}"
|
||||||
ROS_DOMAIN_ID_VAL=1
|
ROS_DOMAIN_ID_VAL=1
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
@@ -31,6 +35,7 @@ pkill -f "robot_state_publisher" 2>/dev/null || true
|
|||||||
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
pkill -f "fix_scan_timestamp" 2>/dev/null || true
|
||||||
pkill -f "clock_publisher" 2>/dev/null || true
|
pkill -f "clock_publisher" 2>/dev/null || true
|
||||||
pkill -f "python.*app.py" 2>/dev/null || true
|
pkill -f "python.*app.py" 2>/dev/null || true
|
||||||
|
pkill -f "uv run .*python app.py" 2>/dev/null || true
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# 【关键】硬杀确保干净
|
# 【关键】硬杀确保干净
|
||||||
@@ -138,7 +143,7 @@ fi
|
|||||||
echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..."
|
echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..."
|
||||||
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/clock_publisher.py" \
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/clock_publisher.py" \
|
||||||
> /tmp/clock_publisher.log 2>&1 &
|
> /tmp/clock_publisher.log 2>&1 &
|
||||||
CLOCK_PID=$!
|
CLOCK_PID=$!
|
||||||
echo " clock_publisher PID: $CLOCK_PID"
|
echo " clock_publisher PID: $CLOCK_PID"
|
||||||
@@ -170,7 +175,7 @@ if [ "$SCAN_OK" -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp_v6.py" \
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
|
||||||
> /tmp/scan_fixer.log 2>&1 &
|
> /tmp/scan_fixer.log 2>&1 &
|
||||||
FIXER_PID=$!
|
FIXER_PID=$!
|
||||||
echo " fix_scan_timestamp PID: $FIXER_PID"
|
echo " fix_scan_timestamp PID: $FIXER_PID"
|
||||||
@@ -185,7 +190,7 @@ pkill -f "clock_publisher" 2>/dev/null || true
|
|||||||
sleep 2
|
sleep 2
|
||||||
rm -f /tmp/scan_fixer.lock
|
rm -f /tmp/scan_fixer.lock
|
||||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 /home/elephant/work/scan_fixer/fix_scan_timestamp_v6.py" \
|
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
|
||||||
> /tmp/scan_fixer.log 2>&1 &
|
> /tmp/scan_fixer.log 2>&1 &
|
||||||
FIXER_PID=$!
|
FIXER_PID=$!
|
||||||
sleep 3
|
sleep 3
|
||||||
@@ -249,7 +254,7 @@ echo " ✅ 精度参数已设置"
|
|||||||
echo "[7/8] 启动 Flask API..."
|
echo "[7/8] 启动 Flask API..."
|
||||||
export ROS_DOMAIN_ID=1
|
export ROS_DOMAIN_ID=1
|
||||||
cd "$AGV_APP_DIR"
|
cd "$AGV_APP_DIR"
|
||||||
nohup python3 app.py > /tmp/agv_flask.log 2>&1 &
|
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
|
||||||
FLASK_PID=$!
|
FLASK_PID=$!
|
||||||
echo " Flask PID: $FLASK_PID"
|
echo " Flask PID: $FLASK_PID"
|
||||||
sleep 4
|
sleep 4
|
||||||
@@ -305,7 +310,7 @@ echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)"
|
|||||||
# 8e. Flask API 测试
|
# 8e. Flask API 测试
|
||||||
echo ""
|
echo ""
|
||||||
echo "验证 Flask API..."
|
echo "验证 Flask API..."
|
||||||
FLASK_RUNNING=$(ps aux | grep "[p]ython3 app.py" | wc -l || echo 0)
|
FLASK_RUNNING=$(pgrep -f "app.py" | wc -l || echo 0)
|
||||||
if [ "$FLASK_RUNNING" -gt 0 ]; then
|
if [ "$FLASK_RUNNING" -gt 0 ]; then
|
||||||
echo " ✅ Flask 进程运行中"
|
echo " ✅ Flask 进程运行中"
|
||||||
else
|
else
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# start_flask.sh - 仅启动/重启 Flask 服务(不启动 ROS2)
|
||||||
|
# 适用于: 修改了前端/API 代码后快速重启
|
||||||
|
# ============================================================
|
||||||
|
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}"
|
||||||
|
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
|
||||||
|
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
|
||||||
|
|
||||||
|
pkill -f "python.*app.py" 2>/dev/null || true
|
||||||
|
pkill -f "uv run .*python app.py" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
||||||
|
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
|
||||||
|
|
||||||
|
cd "$AGV_APP_DIR"
|
||||||
|
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 &
|
||||||
|
echo "Flask started, PID: $!"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
|
||||||
|
echo "✅ 端口 5000 正常"
|
||||||
|
else
|
||||||
|
echo "⚠️ 端口 5000 未监听,检查 /tmp/agv_flask.log"
|
||||||
|
fi
|
||||||
@@ -14,7 +14,8 @@ echo ""
|
|||||||
|
|
||||||
# ---------- 1. 软杀所有相关进程 ----------
|
# ---------- 1. 软杀所有相关进程 ----------
|
||||||
echo "[1/5] 软杀所有相关进程..."
|
echo "[1/5] 软杀所有相关进程..."
|
||||||
pkill -f "python3 app.py" 2>/dev/null || true
|
pkill -f "python.*app.py" 2>/dev/null || true
|
||||||
|
pkill -f "uv run .*python app.py" 2>/dev/null || true
|
||||||
pkill -f "agv_pro_bringup" 2>/dev/null || true
|
pkill -f "agv_pro_bringup" 2>/dev/null || true
|
||||||
pkill -f "agv_pro_navigation2" 2>/dev/null || true
|
pkill -f "agv_pro_navigation2" 2>/dev/null || true
|
||||||
pkill -f "agv_pro_node" 2>/dev/null || true
|
pkill -f "agv_pro_node" 2>/dev/null || true
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"agv": {
|
|
||||||
"ip": "192.168.60.177",
|
|
||||||
"ssh_user": "elephant",
|
|
||||||
"ssh_password": "Elephant",
|
|
||||||
"map_file": "map.yaml",
|
|
||||||
"map_dir": "/home/elephant"
|
|
||||||
},
|
|
||||||
"arm": {
|
|
||||||
"ip": "192.168.60.88",
|
|
||||||
"ssh_user": "pi",
|
|
||||||
"ssh_password": "elephant",
|
|
||||||
"socket_port": 5001,
|
|
||||||
"roboflow_host": "127.0.0.1",
|
|
||||||
"roboflow_port": 5001
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"upload_url": "https://ts.timeddd.com/prod-api/file/uploadImage",
|
|
||||||
"agv_control_port": 5000,
|
|
||||||
"arm_server_port": 5002,
|
|
||||||
"secret_key": "agv630_secret_key_2024"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# AGV 服务启动脚本
|
|
||||||
cd /home/elephant/work/agv_app
|
|
||||||
|
|
||||||
# 确保没有旧进程
|
|
||||||
pkill -f "python.*app.py" 2>/dev/null
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# 启动服务
|
|
||||||
nohup python3 app.py > app.log 2>&1 &
|
|
||||||
PID=$!
|
|
||||||
echo "Started PID=$PID"
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
if ss -tlnp | grep 5000; then
|
|
||||||
echo "✓ 端口 5000 监听正常"
|
|
||||||
curl -s http://127.0.0.1:5000/api/mission/state
|
|
||||||
echo ""
|
|
||||||
else
|
|
||||||
echo "✗ 端口 5000 未监听,检查日志:"
|
|
||||||
cat app.log
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.10.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "2.2.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/76/a4d2c4436dda4b0a12c71e075c508ea7988a1066b06a575f6afe4fecc023/Flask-2.2.5.tar.gz", hash = "sha256:edee9b0a7ff26621bd5a8c10ff484ae28737a2410d99b0bb9a6850c7fb977aa0", size = 697814, upload-time = "2023-05-02T14:42:36.742Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/1a/8b6d48162861009d1e017a9740431c78d860809773b66cac220a11aa3310/Flask-2.2.5-py3-none-any.whl", hash = "sha256:58107ed83443e86067e41eff4631b058178191a355886f8e479e347fa1285fdf", size = 101817, upload-time = "2023-05-02T14:42:34.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-cors"
|
||||||
|
version = "6.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.2.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opencv-python"
|
||||||
|
version = "4.13.0.92"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymycobot"
|
||||||
|
version = "4.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "pyserial" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/5d/17f9b745e32c8058c8a6391eea2f81623955c13596c6c5434add051877f8/pymycobot-4.0.5.tar.gz", hash = "sha256:42f3ba85203130bf2ee7c122ede37e4d148538644bf1ae2c01663cfe0aa90266", size = 239622, upload-time = "2026-06-12T02:32:54.922Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/5f/ec6505555837d14f807cef4e9976f90807cac1c69b1ce8f1baad57ad89be/pymycobot-4.0.5-py3-none-any.whl", hash = "sha256:7ab6edef05d7ae4e17c543ba24ffbb4f4e504a1acde96f346d658b5aa0609690", size = 301487, upload-time = "2026-06-12T02:32:51.77Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyserial"
|
||||||
|
version = "3.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyzbar"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/24/81ebe6a1c00760471a3028a23cbe0b94e5fa2926e5ba47adc895920887bc/pyzbar-0.1.9-py2.py3-none-any.whl", hash = "sha256:4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d", size = 32560, upload-time = "2022-03-15T14:53:40.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/87/7b596730179ddf17857eea33ba820354dd4e1cf941e57f51ffccce26c409/pyzbar-0.1.9-py2.py3-none-win32.whl", hash = "sha256:8f4c5264c9c7c6b9f20d01efc52a4eba1ded47d9ba857a94130afe33703eb518", size = 810633, upload-time = "2022-03-15T14:53:43.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/e2/1c6a8e94197612dbdfc51eab8dfb674168829885fac2c4f50ac8366c25ca/pyzbar-0.1.9-py2.py3-none-win_amd64.whl", hash = "sha256:13e3ee5a2f3a545204a285f41814d5c0db571967e8d4af8699a03afc55182a9c", size = 817363, upload-time = "2022-03-15T14:53:46.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smart-inspection"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-cors" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "opencv-python" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pymycobot" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "pyzbar" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "flask", specifier = ">=2.0,<2.3" },
|
||||||
|
{ name = "flask-cors", specifier = ">=3.0" },
|
||||||
|
{ name = "numpy", specifier = ">=1.20" },
|
||||||
|
{ name = "opencv-python", specifier = ">=4.5" },
|
||||||
|
{ name = "pillow", specifier = ">=10.0" },
|
||||||
|
{ name = "pymycobot", specifier = ">=4.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
|
{ name = "pyzbar", specifier = ">=0.1.8" },
|
||||||
|
{ name = "requests", specifier = ">=2.25" },
|
||||||
|
{ name = "werkzeug", specifier = ">=2.2,<3.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "2.3.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/4b/d746f1000782c89d6c97df9df43ba8f4d126038608843d3560ae88d201b5/werkzeug-2.3.8.tar.gz", hash = "sha256:554b257c74bbeb7a0d254160a4f8ffe185243f52a52035060b761ca62d977f03", size = 819747, upload-time = "2023-11-08T18:37:03.303Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/21/0a674dfe66e9df9072c46269c882e9f901d36d987d8ea50ead033a9c1e01/werkzeug-2.3.8-py3-none-any.whl", hash = "sha256:bba1f19f8ec89d4d607a3bd62f1904bd2e609472d93cd85e9d4e178f472c3748", size = 242332, upload-time = "2023-11-08T18:37:01.088Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user