Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52f1930f9a | |||
| 3d0bcc8f6f | |||
| fede57e69a | |||
| 916b44bc3c | |||
| 62292edc70 | |||
| cbc88def27 | |||
| 48121b2a05 | |||
| a4f4be4c8e | |||
| 696bf2ef6e | |||
| 4126e01bba |
+538
-62
@@ -8,10 +8,11 @@ import time
|
||||
import logging
|
||||
import threading
|
||||
import subprocess
|
||||
import requests
|
||||
from flask import Flask, render_template, jsonify, request, Response, send_from_directory
|
||||
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.agv_controller_ros2 import AGVController
|
||||
from utils.qr_scanner import QRScanner
|
||||
@@ -53,6 +54,8 @@ class GlobalState:
|
||||
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
||||
self.qr_config = [] # 二维码配置(独立点位列表)
|
||||
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.lock = threading.Lock()
|
||||
|
||||
@@ -126,10 +129,11 @@ try:
|
||||
# Flask 2.3+ 方式
|
||||
with app.app_context():
|
||||
load_persisted_config()
|
||||
# 启动时自动重连 AGV(异步,不阻塞 Flask 启动)
|
||||
# 启动时自动连接所有设备(异步,不阻塞 Flask 启动)
|
||||
import threading
|
||||
def _auto_reconnect():
|
||||
def _auto_connect_all():
|
||||
time.sleep(2) # 等待 Flask 完全就绪
|
||||
# 连接 AGV
|
||||
try:
|
||||
from utils.agv_controller_ros2 import AGVController
|
||||
gs.agv_controller = AGVController()
|
||||
@@ -139,7 +143,19 @@ try:
|
||||
print("[启动] AGV 自动连接失败,请手动连接")
|
||||
except Exception as 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:
|
||||
# 兼容旧版 Flask
|
||||
@app.before_first_request
|
||||
@@ -175,15 +191,21 @@ def api_status():
|
||||
arm_connected = ok
|
||||
except:
|
||||
arm_connected = False
|
||||
# 连接已断开,清理 socket
|
||||
if gs.arm_client:
|
||||
gs.arm_client._sock = None
|
||||
|
||||
# 实际验证 AGV 连接
|
||||
agv_connected = False
|
||||
if gs.agv_controller:
|
||||
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({
|
||||
"state": gs.state,
|
||||
"agv_connected": agv_connected,
|
||||
@@ -747,11 +769,7 @@ def api_mission_config_set():
|
||||
gs.mission_config["cols"] = cols
|
||||
gs.mission_config["grid"] = grid
|
||||
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)
|
||||
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:
|
||||
return "camera not opened", 400
|
||||
|
||||
import time as _time
|
||||
def gen():
|
||||
_last_ok = _time.time()
|
||||
while True:
|
||||
frame = gs.qr_scanner.read_frame()
|
||||
if frame is None:
|
||||
break
|
||||
# 编码为 JPEG
|
||||
if _time.time() - _last_ok > 5:
|
||||
break
|
||||
_time.sleep(0.05)
|
||||
continue
|
||||
import cv2
|
||||
ret, buf = cv2.imencode(".jpg", frame)
|
||||
if ret:
|
||||
_last_ok = _time.time()
|
||||
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
||||
buf.tobytes() + b"\r\n")
|
||||
|
||||
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")
|
||||
def api_camera_capture():
|
||||
@@ -1114,27 +1124,98 @@ def api_camera_capture():
|
||||
cv2.imwrite(photo_path, frame)
|
||||
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")
|
||||
def api_arm_camera_refresh():
|
||||
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET)"""
|
||||
"""从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
|
||||
import requests
|
||||
try:
|
||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
||||
if r.status_code == 200 and r.content:
|
||||
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 不可用: {ex}")
|
||||
return "", 404
|
||||
import cv2
|
||||
import numpy as np
|
||||
url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
|
||||
max_retries = 3
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
r = requests.get(url, timeout=8)
|
||||
if r.status_code == 200 and r.content:
|
||||
corruption = _is_corrupted_jpeg(r.content)
|
||||
if corruption > 0.5:
|
||||
logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
|
||||
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")
|
||||
def api_arm_camera_preview():
|
||||
"""代理机械臂 MJPEG 视频流,供页面连续预览。"""
|
||||
"""代理机械臂 MJPEG 视频流,直透返回(不翻转)。"""
|
||||
import requests
|
||||
try:
|
||||
upstream = requests.get(
|
||||
@@ -1147,10 +1228,23 @@ def api_arm_camera_preview():
|
||||
return "", 404
|
||||
|
||||
def generate():
|
||||
buf = b""
|
||||
try:
|
||||
for chunk in upstream.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
if not 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:
|
||||
upstream.close()
|
||||
|
||||
@@ -1179,7 +1273,7 @@ def api_agv_move():
|
||||
"""控制 AGV 移动(前进/后退/左转/右转/停止)"""
|
||||
data = request.json
|
||||
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():
|
||||
return jsonify({"ok": False, "error": "AGV 未连接"}), 400
|
||||
try:
|
||||
@@ -1242,6 +1336,11 @@ def api_agv_reset():
|
||||
def api_mission_start():
|
||||
"""开始执行任务(V3: M×N Grid 蛇形路径)"""
|
||||
data = request.json or {}
|
||||
|
||||
# 必须先设置报关单(开始查验)
|
||||
if not gs.inspection:
|
||||
return jsonify({"ok": False, "error": "请先在「设置→报关单」中选择报关单并点击「开始查验」"}), 400
|
||||
|
||||
single_step = bool(data.get("single_step", False))
|
||||
# 任务步骤控制开关
|
||||
options = {
|
||||
@@ -1250,8 +1349,8 @@ def api_mission_start():
|
||||
"qr_scan": bool(data.get("qr_scan", True)),
|
||||
"front_photo": bool(data.get("front_photo", True)),
|
||||
"back_photo": bool(data.get("back_photo", True)),
|
||||
"agv_speed": float(data.get("agv_speed", 0.5)),
|
||||
"arm_speed": int(data.get("arm_speed", 500)),
|
||||
"agv_speed": float(data.get("agv_speed", 1.0)),
|
||||
"arm_speed": int(data.get("arm_speed", 1000)),
|
||||
}
|
||||
print(f"[Mission] options: {options}")
|
||||
|
||||
@@ -1436,6 +1535,18 @@ def api_mission_state():
|
||||
result["waiting_step"] = 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)
|
||||
|
||||
@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"])
|
||||
def api_qr_config_scan(qr_id):
|
||||
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项"""
|
||||
"""获取机械臂摄像头图像,识别二维码并保存到指定配置项(pyzbar 优先,OpenCV 兜底)"""
|
||||
import requests
|
||||
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 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)
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if frame is None:
|
||||
return jsonify({"ok": False, "error": "图像解码失败"}), 400
|
||||
# 使用 OpenCV QRCodeDetector 检测
|
||||
detector = cv2.QRCodeDetector()
|
||||
result, _, _ = detector.detectAndDecode(frame)
|
||||
if result and len(result.strip()) > 0:
|
||||
result = result.strip()
|
||||
|
||||
result = None
|
||||
|
||||
# 方法1: pyzbar(识别率更高)
|
||||
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:
|
||||
if entry["id"] == qr_id:
|
||||
entry["qr_value"] = result
|
||||
# 尝试匹配机型
|
||||
matched_model = None
|
||||
for model in gs.models_config:
|
||||
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 ""
|
||||
})
|
||||
return jsonify({"ok": False, "error": f"二维码 {qr_id} 不存在"}), 404
|
||||
else:
|
||||
return jsonify({"ok": False, "error": "未检测到二维码"})
|
||||
|
||||
return jsonify({"ok": False, "error": "未检测到二维码,请调整机械臂姿态或手动输入"})
|
||||
except Exception as ex:
|
||||
logger.error(f"QR 扫描机械臂摄像头失败: {ex}")
|
||||
logger.error(f"QR 扫描失败: {ex}")
|
||||
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>")
|
||||
def 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__":
|
||||
logger.info("=" * 50)
|
||||
@@ -1654,3 +2129,4 @@ if __name__ == "__main__":
|
||||
debug=SERVER_CONFIG["debug"],
|
||||
threaded=True
|
||||
)
|
||||
|
||||
|
||||
+28
-7
@@ -7,15 +7,15 @@ import os
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||
AGV_HOST = "192.168.60.177"
|
||||
ARM_HOST = "192.168.60.88"
|
||||
AGV_HOST = "192.168.60.80"
|
||||
ARM_HOST = "192.168.60.120"
|
||||
|
||||
# ========== AGV 参数 ==========
|
||||
AGV_CONFIG = {
|
||||
"device": "/dev/agvpro_controller",
|
||||
"baudrate": 10000000,
|
||||
"move_speed": 0.5,
|
||||
"turn_speed": 0.5,
|
||||
"move_speed": 1.0,
|
||||
"turn_speed": 1.0,
|
||||
}
|
||||
|
||||
# ========== 机械臂 TCP 客户端 ==========
|
||||
@@ -35,7 +35,7 @@ MAP_CONFIG = {
|
||||
|
||||
# ========== 摄像头 ==========
|
||||
CAMERA_CONFIG = {
|
||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
||||
"device_index": 4, # AGV 摄像头 video4(Orbbec Gemini 彩色流,YUYV 640x480)
|
||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||
"qr_detect_interval": 0.5,
|
||||
"capture_delay": 0.5,
|
||||
@@ -47,9 +47,30 @@ ARM_CAMERA_CONFIG = {
|
||||
"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 上传 ==========
|
||||
UPLOAD_CONFIG = {
|
||||
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage",
|
||||
"url": f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage",
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
}
|
||||
@@ -77,7 +98,7 @@ JOINT_LIMITS = {
|
||||
}
|
||||
|
||||
# ========== 机械臂默认速度 ==========
|
||||
DEFAULT_ARM_SPEED = 500
|
||||
DEFAULT_ARM_SPEED = 1000
|
||||
|
||||
# ========== 状态定义 ==========
|
||||
class State:
|
||||
|
||||
@@ -59,6 +59,49 @@ a:hover { text-decoration: underline; }
|
||||
.status-item.paused { background: #3a2a1a; color: #ff9800; }
|
||||
.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 {
|
||||
background: #1a2332;
|
||||
@@ -465,6 +508,10 @@ a:hover { text-decoration: underline; }
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-img.arm {
|
||||
/* no flip */
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
@@ -1057,10 +1104,10 @@ a:hover { text-decoration: underline; }
|
||||
@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-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; }
|
||||
|
||||
/* 步骤圆点 */
|
||||
@@ -1076,3 +1123,185 @@ a:hover { text-decoration: underline; }
|
||||
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
||||
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
||||
.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(),
|
||||
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
||||
agvCameraError: false,
|
||||
hasAgvCamera: false, // AGV 车体是否有可用相机
|
||||
armCameraError: false,
|
||||
reconnectingDevice: null
|
||||
reconnectingDevice: null,
|
||||
// 环境切换
|
||||
testMode: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -36,6 +39,8 @@ createApp({
|
||||
},
|
||||
mounted() {
|
||||
this.refresh()
|
||||
this.refreshCameraCapabilities()
|
||||
this.loadEnvMode()
|
||||
setInterval(this.refreshStatus, 3000)
|
||||
this.refreshCams()
|
||||
setInterval(() => this.refreshCams(), 2000)
|
||||
@@ -47,6 +52,17 @@ createApp({
|
||||
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() {
|
||||
await this.refreshStatus()
|
||||
await this.loadPoints()
|
||||
@@ -58,6 +74,10 @@ createApp({
|
||||
this.agvConnected = data.agv_connected
|
||||
this.armConnected = data.arm_connected
|
||||
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.mapLoaded = data.map_loaded
|
||||
this.currentState = data.state || 'idle'
|
||||
@@ -116,6 +136,36 @@ createApp({
|
||||
} else {
|
||||
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')
|
||||
|
||||
@@ -9,6 +9,7 @@ createApp({
|
||||
tasks: [],
|
||||
report: null,
|
||||
armCameraOpened: false,
|
||||
hasAgvCamera: false,
|
||||
agvPreviewUrl: API + '/api/camera/preview',
|
||||
armPreviewUrl: '',
|
||||
polling: null,
|
||||
@@ -27,14 +28,17 @@ createApp({
|
||||
errorMsg: '',
|
||||
waitingStep: false,
|
||||
stepLabel: '',
|
||||
qrMessage: '所有姿态均未识别到二维码,请手动输入:',
|
||||
// 任务步骤控制开关(机械臂初始化并入AGV移动)
|
||||
agvMoveEnabled: true,
|
||||
qrScanEnabled: true,
|
||||
frontPhotoEnabled: true,
|
||||
backPhotoEnabled: true,
|
||||
// 速度控制
|
||||
agvSpeed: 0.5,
|
||||
armSpeed: 500,
|
||||
agvSpeed: 1.0,
|
||||
armSpeed: 1000,
|
||||
// 查验
|
||||
inspection: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -50,6 +54,14 @@ createApp({
|
||||
}
|
||||
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() {
|
||||
this.poll()
|
||||
@@ -63,13 +75,20 @@ createApp({
|
||||
try {
|
||||
const res = await fetch(API + '/api/status')
|
||||
const data = await res.json()
|
||||
if (!this.armCameraOpened) {
|
||||
this.armPreviewUrl = ''
|
||||
} else if (!this.armPreviewUrl) {
|
||||
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
||||
const opened = data.arm_camera_opened
|
||||
if (opened !== this.armCameraOpened || (opened && !this.armPreviewUrl)) {
|
||||
this.armCameraOpened = opened
|
||||
this.armPreviewUrl = opened ? API + '/api/camera/arm_preview' : ''
|
||||
}
|
||||
} 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() {
|
||||
this.refresh()
|
||||
this.pollLogs()
|
||||
@@ -93,7 +112,11 @@ createApp({
|
||||
if (data.grid) this.missionGrid = data.grid
|
||||
if (data.point_status) this.pointStatus = data.point_status
|
||||
if (data.machine_status) this.machineStatus = data.machine_status
|
||||
if (data.inspection) this.inspection = data.inspection
|
||||
this.armCameraOpened = data.arm_camera_opened
|
||||
if (this.armCameraOpened && !this.armPreviewUrl) {
|
||||
this.armPreviewUrl = API + '/api/camera/arm_preview'
|
||||
}
|
||||
|
||||
// 错误弹窗
|
||||
if (data.waiting_error) {
|
||||
@@ -111,6 +134,11 @@ createApp({
|
||||
this.waitingStep = false
|
||||
}
|
||||
|
||||
// QR 弹窗消息
|
||||
if (data.qr_message) {
|
||||
this.qrMessage = data.qr_message
|
||||
}
|
||||
|
||||
// QR 弹窗(防止提交后重复弹出)
|
||||
if (this.missionState !== 'waiting_qr') {
|
||||
this.qrSubmitting = false
|
||||
@@ -118,6 +146,9 @@ createApp({
|
||||
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
|
||||
this.showQrModal = true
|
||||
this.qrValue = ''
|
||||
if (!this.qrMessage) {
|
||||
this.qrMessage = '所有姿态均未识别到二维码,请手动输入:'
|
||||
}
|
||||
}
|
||||
|
||||
// 完成后获取报告
|
||||
@@ -145,6 +176,11 @@ createApp({
|
||||
},
|
||||
async startMission() {
|
||||
if (this.missionState !== 'idle') return
|
||||
// 没有设置报关单时阻止启动(后端也会校验,这里提前友好提示)
|
||||
if (!this.inspection) {
|
||||
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||
return
|
||||
}
|
||||
this.logs = []
|
||||
this.progress = 0
|
||||
this.report = null
|
||||
@@ -175,6 +211,11 @@ createApp({
|
||||
},
|
||||
async startSingleStep() {
|
||||
if (this.missionState !== 'idle') return
|
||||
// 没有设置报关单时阻止启动(后端会校验,这里提前友好提示)
|
||||
if (!this.inspection) {
|
||||
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
|
||||
return
|
||||
}
|
||||
this.logs = []
|
||||
this.progress = 0
|
||||
this.report = null
|
||||
@@ -244,11 +285,28 @@ createApp({
|
||||
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) {
|
||||
e.target.style.display = 'none'
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -45,7 +45,7 @@ createApp({
|
||||
agvConnected: false,
|
||||
agvBattery: null,
|
||||
agvPosition: null,
|
||||
agvSpeed: 0.5,
|
||||
agvSpeed: 1.0,
|
||||
agvMoveInterval: null,
|
||||
agvCameraUrl: API + '/api/camera/refresh',
|
||||
agvCameraTimer: null,
|
||||
@@ -57,9 +57,24 @@ createApp({
|
||||
qrScanning: false,
|
||||
qrConfigs: [],
|
||||
qrScanningId: null,
|
||||
showQrInputDialog: false,
|
||||
qrInputId: null,
|
||||
qrInputValue: '',
|
||||
armCameraUrl: API + '/api/camera/arm_preview',
|
||||
armSnapshotUrl: '',
|
||||
showArmSnapshot: false,
|
||||
armSnapshotLoading: false,
|
||||
newQrName: '',
|
||||
armInitialPose: [0, 0, 0, 0, 0, 0],
|
||||
// 报关单
|
||||
customsList: [],
|
||||
customsLoading: false,
|
||||
customsPage: 1,
|
||||
customsPageSize: 15,
|
||||
customsTotal: 0,
|
||||
selectedCustomsId: '',
|
||||
selectedCustomsName: '',
|
||||
customsMachines: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -67,9 +82,17 @@ createApp({
|
||||
this.refreshAngles()
|
||||
this.loadQrConfigs()
|
||||
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: {
|
||||
customsTotalPages() {
|
||||
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
|
||||
},
|
||||
customsPageData() {
|
||||
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
|
||||
return this.customsList
|
||||
},
|
||||
hasQr() {
|
||||
return !!(this.selectedMachine && this.selectedMachine.qr)
|
||||
},
|
||||
@@ -1104,10 +1127,39 @@ createApp({
|
||||
if (data.model_name) msg += ' 匹配机型: ' + data.model_name
|
||||
else msg += ' 未匹配到机型'
|
||||
alert(msg)
|
||||
} else { alert(data.error || '扫描失败') }
|
||||
} catch (e) { alert('扫描失败: ' + e.message) }
|
||||
} else {
|
||||
// 自动扫描失败,弹出手动输入框
|
||||
this.qrInputId = qrId
|
||||
this.qrInputValue = ''
|
||||
this.showQrInputDialog = true
|
||||
}
|
||||
} catch (e) {
|
||||
this.qrInputId = qrId
|
||||
this.qrInputValue = ''
|
||||
this.showQrInputDialog = true
|
||||
}
|
||||
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) {
|
||||
if (!this.armConnected) { alert('机械臂未连接'); return }
|
||||
const q = this.qrConfigs.find(x => x.id === qrId)
|
||||
@@ -1126,6 +1178,12 @@ createApp({
|
||||
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() {
|
||||
if (!this.agvConnected) {
|
||||
alert('AGV 未连接')
|
||||
@@ -1146,5 +1204,121 @@ createApp({
|
||||
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')
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
<a href="/setting" class="nav-link">⚙️ 设置</a>
|
||||
<a href="/running" class="nav-link">▶️ 运行</a>
|
||||
</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">
|
||||
{% raw %}{{ statusText }}{% endraw %}
|
||||
</span>
|
||||
@@ -93,9 +99,10 @@
|
||||
<h2>📷 摄像头预览</h2>
|
||||
<div class="camera-row">
|
||||
<div class="camera-box">
|
||||
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div>
|
||||
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
|
||||
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
|
||||
<img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||
<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>
|
||||
<div class="camera-box">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>运行监控 - AGV 拍摄系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529a">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260616a">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -48,6 +48,30 @@
|
||||
</div>
|
||||
</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">
|
||||
<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,'back')" title="背面照">📸背</span>
|
||||
</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>
|
||||
<div v-else class="empty-cell">空</div>
|
||||
</div>
|
||||
@@ -173,8 +197,9 @@
|
||||
<h2>📷 摄像头预览</h2>
|
||||
<div class="camera-dual">
|
||||
<div class="camera-box">
|
||||
<div class="camera-label">🎥 AGV 摄像头</div>
|
||||
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
||||
<div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
|
||||
<img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
||||
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||
</div>
|
||||
<div class="camera-box" v-if="armCameraOpened">
|
||||
<div class="camera-label">🦾 机械臂摄像头</div>
|
||||
@@ -201,9 +226,10 @@
|
||||
<div class="modal-overlay" v-if="showQrModal">
|
||||
<div class="modal">
|
||||
<h3>⌨️ 手动输入二维码</h3>
|
||||
<p>所有姿态均未识别到二维码,请手动输入:</p>
|
||||
<p>{% raw %}{{ qrMessage }}{% endraw %}</p>
|
||||
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
|
||||
<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" @click="cancelQr">跳过</button>
|
||||
</div>
|
||||
@@ -239,6 +265,6 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
+145
-12
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设置 - AGV 拍摄系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529b">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260612b">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -25,6 +25,7 @@
|
||||
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
||||
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
||||
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
||||
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
@@ -109,7 +110,7 @@
|
||||
<thead>
|
||||
<tr style="background:#1a2332;text-align:left">
|
||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
|
||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型名称</th>
|
||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型申请时间</th>
|
||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
|
||||
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</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="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<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">
|
||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])">➕ 添加正面姿态(当前角度)</button>
|
||||
</div>
|
||||
@@ -207,7 +208,7 @@
|
||||
<div style="margin-top:8px">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="text" v-model="newPoseForm[m.id + '_back']"
|
||||
placeholder="姿态名称(如:放料)"
|
||||
placeholder="姿态申请时间(如:放料)"
|
||||
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
|
||||
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])">➕ 添加背面姿态(当前角度)</button>
|
||||
</div>
|
||||
@@ -224,7 +225,7 @@
|
||||
<button class="btn-icon" @click="showAddModelModal = false">✕</button>
|
||||
</div>
|
||||
<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">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:12px">
|
||||
@@ -433,13 +434,18 @@
|
||||
<h2>📷 二维码配置</h2>
|
||||
<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">
|
||||
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
|
||||
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
<input type="text" v-model="newQrName" placeholder="输入名称..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
|
||||
<button class="btn btn-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>
|
||||
</div>
|
||||
<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">
|
||||
<thead>
|
||||
<tr style="background:#1a2332;text-align:left">
|
||||
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">名称</th>
|
||||
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">申请时间</th>
|
||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
|
||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
|
||||
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
|
||||
@@ -476,6 +482,7 @@
|
||||
<button class="btn btn-secondary btn-small" @click="readQrAngles(q.id)" :disabled="!armConnected" title="读取当前机械臂关节角度">📋 加载姿态</button>
|
||||
<button class="btn btn-primary btn-small" @click="applyQrAngles(q.id)" :disabled="!armConnected" style="margin-left:3px" title="将姿态应用到机械臂">🤖 应用姿态</button>
|
||||
<button class="btn btn-success btn-small" @click="scanQrEntry(q.id)" :disabled="qrScanningId === q.id" style="margin-left:3px" title="扫描二维码">📷</button>
|
||||
<button class="btn btn-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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -493,7 +500,7 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="camera-preview">
|
||||
<img :src="armCameraUrl" @error="onArmPreviewError">
|
||||
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||
</div>
|
||||
<div class="joints-panel">
|
||||
<h3>关节角度控制</h3>
|
||||
@@ -581,10 +588,136 @@
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</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/setting.js?v=20260529b"></script>
|
||||
<script src="/static/js/setting.js?v=20260616f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -76,7 +76,7 @@ class AGVController:
|
||||
if rc != 0:
|
||||
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():
|
||||
return
|
||||
@@ -85,7 +85,7 @@ class AGVController:
|
||||
time.sleep(duration)
|
||||
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():
|
||||
return
|
||||
@@ -94,7 +94,7 @@ class AGVController:
|
||||
time.sleep(duration)
|
||||
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():
|
||||
return
|
||||
@@ -103,7 +103,7 @@ class AGVController:
|
||||
time.sleep(duration)
|
||||
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():
|
||||
return
|
||||
@@ -112,7 +112,7 @@ class AGVController:
|
||||
time.sleep(duration)
|
||||
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():
|
||||
return
|
||||
@@ -121,7 +121,7 @@ class AGVController:
|
||||
time.sleep(duration)
|
||||
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():
|
||||
return
|
||||
@@ -176,7 +176,7 @@ class AGVController:
|
||||
return self._position
|
||||
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 导航栈)"""
|
||||
logger.warning("go_to_point 需要 ROS2 Nav2 支持,当前仅记录目标")
|
||||
return True
|
||||
|
||||
+23
-14
@@ -61,14 +61,18 @@ class ArmClient:
|
||||
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||
"""获取所有关节角度"""
|
||||
ok, resp = self.send_command("get_angles()")
|
||||
if ok and resp.startswith("get_angles:["):
|
||||
if ok:
|
||||
try:
|
||||
# get_angles:[0.174, 0.520, ...] → list
|
||||
nums = resp.split("[")[1].split("]")[0]
|
||||
angles = [float(x) for x in nums.split(",")]
|
||||
return True, angles
|
||||
# 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
|
||||
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||
text = text.strip()
|
||||
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:
|
||||
return False, []
|
||||
pass
|
||||
return False, []
|
||||
|
||||
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]]:
|
||||
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
||||
ok, resp = self.send_command("get_coords()")
|
||||
if ok and "get_coords:" in resp:
|
||||
if ok:
|
||||
try:
|
||||
nums = resp.split("[")[1].split("]")[0]
|
||||
coords = [float(x) for x in nums.split(",")]
|
||||
return True, coords
|
||||
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||
text = text.strip()
|
||||
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:
|
||||
return False, []
|
||||
pass
|
||||
return False, []
|
||||
|
||||
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||
@@ -132,19 +140,20 @@ class ArmClient:
|
||||
def state_check(self) -> bool:
|
||||
"""检查机械臂状态是否正常"""
|
||||
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:
|
||||
"""检查机械臂是否在运行"""
|
||||
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:
|
||||
"""等待上一条命令执行完成"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
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
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
@@ -7,15 +7,15 @@ import os
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||
AGV_HOST = "192.168.60.177"
|
||||
ARM_HOST = "192.168.60.88"
|
||||
AGV_HOST = "192.168.60.80"
|
||||
ARM_HOST = "192.168.60.120"
|
||||
|
||||
# ========== AGV 参数 ==========
|
||||
AGV_CONFIG = {
|
||||
"device": "/dev/agvpro_controller",
|
||||
"baudrate": 10000000,
|
||||
"move_speed": 0.5,
|
||||
"turn_speed": 0.5,
|
||||
"move_speed": 1.0,
|
||||
"turn_speed": 1.0,
|
||||
}
|
||||
|
||||
# ========== 机械臂 TCP 客户端 ==========
|
||||
@@ -77,7 +77,7 @@ JOINT_LIMITS = {
|
||||
}
|
||||
|
||||
# ========== 机械臂默认速度 ==========
|
||||
DEFAULT_ARM_SPEED = 500
|
||||
DEFAULT_ARM_SPEED = 1000
|
||||
|
||||
# ========== 状态定义 ==========
|
||||
class State:
|
||||
|
||||
@@ -28,10 +28,10 @@ from utils.nav2_navigator import Nav2Navigator, Nav2Status
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
|
||||
from config import ARM_CAMERA_CONFIG
|
||||
from config import ARM_CAMERA_CONFIG, UPLOAD_CONFIG, ZHIJIAN_AUTH_TOKEN
|
||||
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
|
||||
PHOTOS_DIR = "/home/elephant/photos"
|
||||
UPLOAD_URL = "https://ts.zhijian168.com/prod-api/file/uploadImage"
|
||||
# UPLOAD_CONFIG["url"] 随环境切换动态变化,每次使用时直接读取
|
||||
|
||||
# 二维码扫描重试参数
|
||||
QR_SCAN_TIMEOUT = 5 # 单次扫描超时
|
||||
@@ -101,11 +101,10 @@ class MissionExecutorV3:
|
||||
self._nav = Nav2Navigator()
|
||||
|
||||
# 速度控制(默认值,可在 execute_mission 时覆写)
|
||||
self.arm_speed = 500
|
||||
self.agv_speed = 0.5
|
||||
self.arm_speed = 1000
|
||||
self.agv_speed = 1.0
|
||||
|
||||
# 照片上传序号计数器(连续递增,从1开始)
|
||||
self.next_upload_index = 1
|
||||
|
||||
# ==================== 连接 ====================
|
||||
|
||||
@@ -239,7 +238,6 @@ class MissionExecutorV3:
|
||||
self._log(f"📍 点位蛇形路径: {len(path)} 个点位, {total_machines} 台机器")
|
||||
|
||||
# 重置照片上传序号(每次任务开始时重置,从1开始)
|
||||
self.next_upload_index = 1
|
||||
|
||||
# 任务步骤控制开关
|
||||
if options is None:
|
||||
@@ -258,8 +256,8 @@ class MissionExecutorV3:
|
||||
has_arm_pose = self.arm_client and any(abs(a) > 0.01 for a in arm_initial_pose)
|
||||
|
||||
# 速度控制(从前端传入)
|
||||
self.arm_speed = int(options.get("arm_speed", 500))
|
||||
self.agv_speed = float(options.get("agv_speed", 0.5))
|
||||
self.arm_speed = int(options.get("arm_speed", 1000))
|
||||
self.agv_speed = float(options.get("agv_speed", 1.0))
|
||||
self._log(f"🚀 AGV速度={self.agv_speed:.1f}m/s, 机械臂速度={self.arm_speed}")
|
||||
|
||||
# 设置 Nav2 导航速度(仅在任务开始时设一次)
|
||||
@@ -721,6 +719,7 @@ class MissionExecutorV3:
|
||||
try:
|
||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
|
||||
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
|
||||
|
||||
arr = np.frombuffer(resp.content, dtype=np.uint8)
|
||||
@@ -737,23 +736,41 @@ class MissionExecutorV3:
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
|
||||
def _request_manual_qr(self) -> Optional[str]:
|
||||
"""暂停任务,等待手动输入(不超时,必须输入才能继续;stop 时解除)"""
|
||||
self.status = MissionStatus.WAITING_QR
|
||||
self.report["status"] = "waiting_qr"
|
||||
self.report["step"] = "等待手动输入二维码"
|
||||
self._log(" ⌨️ 弹窗等待手动输入二维码(不可跳过)...")
|
||||
def _request_manual_qr(self, message: str = None) -> Optional[str]:
|
||||
"""暂停任务,等待手动输入(支持重新扫描)
|
||||
message: 自定义弹窗消息(None 则使用默认消息)"""
|
||||
while True:
|
||||
self.status = MissionStatus.WAITING_QR
|
||||
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.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
||||
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
|
||||
self._qr_event.clear()
|
||||
self._qr_event.wait() # 无限等待,直到 set_manual_qr 或 stop() 触发
|
||||
|
||||
if self._qr_value == 'RESCAN':
|
||||
self.status = MissionStatus.RUNNING
|
||||
self.report["status"] = "running"
|
||||
self._log(" 🔄 用户点击重新扫描,重试...")
|
||||
qr = self._decode_qr_from_arm()
|
||||
if qr:
|
||||
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):
|
||||
self._qr_value = value.strip()
|
||||
@@ -762,8 +779,73 @@ class MissionExecutorV3:
|
||||
# ==================== 机型查询 ====================
|
||||
|
||||
def _lookup_model(self, qr_value: Optional[str]) -> str:
|
||||
"""TODO: 后续通过 HTTP 接口查询机型"""
|
||||
return "机器1"
|
||||
"""通过 /api/customs/printer 接口查询机型,同时更新查验计数
|
||||
如果机型不在当前报关单中/超量/查询失败,均弹窗要求重新扫码/输入(不可跳过)"""
|
||||
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
|
||||
def _find_model(models: list, name: str) -> Optional[dict]:
|
||||
@@ -795,6 +877,13 @@ class MissionExecutorV3:
|
||||
self._log(f" ⚠️ 机型无{side_label}姿态配置")
|
||||
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)} 个姿态)")
|
||||
for pi, pose in enumerate(poses):
|
||||
if self._stop.is_set():
|
||||
@@ -818,9 +907,9 @@ class MissionExecutorV3:
|
||||
self.arm_client.set_angles(angles, speed=self.arm_speed)
|
||||
self._wait_arm_ready(angles)
|
||||
|
||||
# 拍照(upload_index 连续递增)
|
||||
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=self.next_upload_index)
|
||||
self.next_upload_index += 1
|
||||
# 拍照:正面从1开始,背面接着正面数量继续编号
|
||||
upload_index = base_index + pi + 1
|
||||
path = self._capture_arm_photo(row, col, side, pi + 1, qr_value, upload_index=upload_index)
|
||||
if path:
|
||||
self._log(f" 💾 {os.path.basename(path)}")
|
||||
|
||||
@@ -829,12 +918,12 @@ class MissionExecutorV3:
|
||||
upload_index: int = 0) -> Optional[str]:
|
||||
"""从机械臂摄像头拍照,直接上传到服务器(不保存本地)
|
||||
|
||||
upload_index: 从1开始,先正面后背面,由调用方维护
|
||||
upload_index: 每台机器独立,从0开始
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
|
||||
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
|
||||
|
||||
# 生成文件名(用于上传)
|
||||
@@ -843,13 +932,14 @@ class MissionExecutorV3:
|
||||
|
||||
# 直接上传到服务器(不保存本地)
|
||||
if qr_value:
|
||||
self._log(f" 📷 拍照成功 {len(resp.content)} bytes → {fname}")
|
||||
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
|
||||
else:
|
||||
self._log(" ⚠️ 无二维码,跳过上传")
|
||||
|
||||
return fname # 返回文件名(用于日志)
|
||||
except Exception as e:
|
||||
logger.error(f"拍照异常: {e}")
|
||||
self._log(f" ❌ 拍照异常: {e}")
|
||||
return None
|
||||
|
||||
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
|
||||
@@ -862,13 +952,14 @@ class MissionExecutorV3:
|
||||
"""
|
||||
try:
|
||||
filename = os.path.basename(filepath)
|
||||
headers = {
|
||||
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
||||
}
|
||||
headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
|
||||
upload_url = UPLOAD_CONFIG["url"]
|
||||
self._log(f" 📤 上传请求 → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
|
||||
with open(filepath, "rb") as f:
|
||||
files = {"file": (filename, f, "image/jpeg")}
|
||||
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:
|
||||
self._log(f" ☁️ 上传成功 [{index}]: {filename}")
|
||||
return True
|
||||
@@ -879,6 +970,33 @@ class MissionExecutorV3:
|
||||
self._log(f" ❌ 上传异常 [{index}]: {e}")
|
||||
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):
|
||||
|
||||
+85
-14
@@ -29,19 +29,22 @@ class QRScanner:
|
||||
def open(self) -> bool:
|
||||
"""打开摄像头"""
|
||||
try:
|
||||
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
|
||||
# 强制 V4L2 后端
|
||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||
if self._cap.isOpened():
|
||||
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
|
||||
return True
|
||||
else:
|
||||
# fallback: 不指定后端
|
||||
if not self._cap.isOpened():
|
||||
self._cap = cv2.VideoCapture(self.device_index)
|
||||
if self._cap.isOpened():
|
||||
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
|
||||
return True
|
||||
|
||||
if not self._cap.isOpened():
|
||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||
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:
|
||||
logger.error(f"摄像头打开失败: {e}")
|
||||
return False
|
||||
@@ -51,14 +54,82 @@ class QRScanner:
|
||||
self._cap.release()
|
||||
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():
|
||||
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 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]:
|
||||
"""从图像帧中检测二维码"""
|
||||
|
||||
+53
-17
@@ -105,11 +105,11 @@ def _ensure_ffmpeg():
|
||||
"ffmpeg",
|
||||
"-f", "v4l2",
|
||||
"-input_format", "mjpeg",
|
||||
"-framerate", "15",
|
||||
"-video_size", "640x480",
|
||||
"-framerate", "12",
|
||||
"-video_size", "1280x720",
|
||||
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
||||
"-vf", "rotate=PI",
|
||||
"-q:v", "8",
|
||||
"-q:v", "4",
|
||||
"-f", "mjpeg",
|
||||
"-"
|
||||
],
|
||||
@@ -254,19 +254,7 @@ class AGVCommandServer:
|
||||
def _connect_roboflow(self):
|
||||
self.roboflow = RoboFlowClient()
|
||||
if self.roboflow.connect():
|
||||
logger.info("RoboFlow 连接成功")
|
||||
# 连接成功后自动上电并激活机械臂
|
||||
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}")
|
||||
logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)")
|
||||
else:
|
||||
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
|
||||
|
||||
@@ -342,13 +330,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():
|
||||
import signal
|
||||
|
||||
# 先通过 ElephantRobot 给机械臂上电并激活
|
||||
power_on_arm()
|
||||
|
||||
server = AGVCommandServer(port=5002)
|
||||
|
||||
# 启动 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.start()
|
||||
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"agv": {
|
||||
"ip": "192.168.60.177",
|
||||
"ip": "192.168.60.80",
|
||||
"ssh_user": "elephant",
|
||||
"ssh_password": "Elephant",
|
||||
"map_file": "map.yaml",
|
||||
"map_dir": "/home/elephant"
|
||||
},
|
||||
"arm": {
|
||||
"ip": "192.168.60.88",
|
||||
"ip": "192.168.60.120",
|
||||
"ssh_user": "pi",
|
||||
"ssh_password": "elephant",
|
||||
"socket_port": 5001,
|
||||
|
||||
Reference in New Issue
Block a user