-
This commit is contained in:
+212
-34
@@ -53,6 +53,7 @@ class GlobalState:
|
|||||||
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
self.machines_config = [] # 机器配置(每台机器的正面/背面点位+姿态)
|
||||||
self.qr_config = [] # 二维码配置(独立点位列表)
|
self.qr_config = [] # 二维码配置(独立点位列表)
|
||||||
self.navigator = None # Nav2Navigator 实例
|
self.navigator = None # Nav2Navigator 实例
|
||||||
|
self.current_customs = None # 当前设定的报关单信息
|
||||||
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
@@ -188,9 +189,6 @@ def api_status():
|
|||||||
arm_connected = ok
|
arm_connected = ok
|
||||||
except:
|
except:
|
||||||
arm_connected = False
|
arm_connected = False
|
||||||
# 连接已断开,清理 socket
|
|
||||||
if gs.arm_client:
|
|
||||||
gs.arm_client._sock = None
|
|
||||||
|
|
||||||
# 实际验证 AGV 连接
|
# 实际验证 AGV 连接
|
||||||
agv_connected = False
|
agv_connected = False
|
||||||
@@ -1088,33 +1086,25 @@ def api_camera_preview():
|
|||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
||||||
return "camera not opened", 400
|
return "camera not opened", 400
|
||||||
|
|
||||||
|
import time as _time
|
||||||
def gen():
|
def gen():
|
||||||
|
_last_ok = _time.time()
|
||||||
while True:
|
while True:
|
||||||
frame = gs.qr_scanner.read_frame()
|
frame = gs.qr_scanner.read_frame()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
break
|
if _time.time() - _last_ok > 5:
|
||||||
# 编码为 JPEG
|
break
|
||||||
|
_time.sleep(0.05)
|
||||||
|
continue
|
||||||
import cv2
|
import cv2
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
if ret:
|
if ret:
|
||||||
|
_last_ok = _time.time()
|
||||||
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" +
|
||||||
buf.tobytes() + b"\r\n")
|
buf.tobytes() + b"\r\n")
|
||||||
|
|
||||||
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
@app.route("/api/camera/refresh")
|
|
||||||
def api_camera_refresh():
|
|
||||||
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
|
||||||
if not gs.qr_scanner or not gs.qr_scanner._cap:
|
|
||||||
return "camera not opened", 400
|
|
||||||
import cv2
|
|
||||||
frame = gs.qr_scanner.read_frame()
|
|
||||||
if frame is None:
|
|
||||||
return "", 400
|
|
||||||
ret, buf = cv2.imencode(".jpg", frame)
|
|
||||||
if ret:
|
|
||||||
return Response(buf.tobytes(), mimetype="image/jpeg")
|
|
||||||
return "encode failed", 500
|
|
||||||
|
|
||||||
@app.route("/api/camera/capture")
|
@app.route("/api/camera/capture")
|
||||||
def api_camera_capture():
|
def api_camera_capture():
|
||||||
@@ -1132,28 +1122,101 @@ def api_camera_capture():
|
|||||||
cv2.imwrite(photo_path, frame)
|
cv2.imwrite(photo_path, frame)
|
||||||
return jsonify({"ok": True, "path": photo_path})
|
return jsonify({"ok": True, "path": photo_path})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_corrupted_jpeg(jpeg_bytes: bytes) -> float:
|
||||||
|
"""检测 JPEG 是否为花屏帧。返回 0~1 的置信度 (1=确定花屏)。"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
arr = np.frombuffer(jpeg_bytes, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||||
|
if img is None:
|
||||||
|
return 1.0
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if h < 10 or w < 10:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||||
|
|
||||||
|
# 绿色条纹检测:HSV 中绿色 H=40~80
|
||||||
|
green_mask = cv2.inRange(hsv, (40, 30, 40), (80, 255, 255))
|
||||||
|
green_ratio = cv2.countNonZero(green_mask) / (h * w)
|
||||||
|
|
||||||
|
# 紫色/品红条纹
|
||||||
|
purple_mask = cv2.inRange(hsv, (130, 30, 40), (170, 255, 255))
|
||||||
|
purple_ratio = cv2.countNonZero(purple_mask) / (h * w)
|
||||||
|
|
||||||
|
if green_ratio > 0.80 or purple_ratio > 0.80:
|
||||||
|
return 0.95
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
row_stds = np.std(gray, axis=1)
|
||||||
|
col_stds = np.std(gray, axis=0)
|
||||||
|
|
||||||
|
row_std_of_stds = float(np.std(row_stds))
|
||||||
|
col_std_of_stds = float(np.std(col_stds))
|
||||||
|
|
||||||
|
if row_std_of_stds > 70 and col_std_of_stds > 30:
|
||||||
|
return 0.85
|
||||||
|
|
||||||
|
unique_colors = len(np.unique(img.reshape(-1, 3), axis=0))
|
||||||
|
if unique_colors < 200:
|
||||||
|
return 0.75
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
except ImportError:
|
||||||
|
return 0.0
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_refresh")
|
@app.route("/api/camera/arm_refresh")
|
||||||
def api_arm_camera_refresh():
|
def api_arm_camera_refresh():
|
||||||
"""从机械臂拉一张 JPEG(请求 snapshot 端点,简单 HTTP GET)"""
|
"""从机械臂拉一张 JPEG,翻转后返回(机械臂摄像头物理倒装)。"""
|
||||||
import requests
|
import requests
|
||||||
try:
|
import cv2
|
||||||
r = requests.get(ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"]), timeout=8)
|
import numpy as np
|
||||||
if r.status_code == 200 and r.content:
|
url = ARM_CAMERA_CONFIG.get("snapshot_url", ARM_CAMERA_CONFIG["url"])
|
||||||
resp = Response(r.content, mimetype="image/jpeg")
|
max_retries = 3
|
||||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
for attempt in range(1, max_retries + 1):
|
||||||
resp.headers["Pragma"] = "no-cache"
|
try:
|
||||||
resp.headers["Expires"] = "0"
|
r = requests.get(url, timeout=8)
|
||||||
return resp
|
if r.status_code == 200 and r.content:
|
||||||
return "", 404
|
corruption = _is_corrupted_jpeg(r.content)
|
||||||
except Exception as ex:
|
if corruption > 0.5:
|
||||||
logger.info(f"arm_refresh 不可用: {ex}")
|
logger.warning(f"arm_refresh 第{attempt}次尝试检测到花屏 (置信度{corruption:.2f}),重试...")
|
||||||
return "", 404
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
# 解码 → 上下翻转 → 编码
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is not None:
|
||||||
|
img = cv2.flip(img, 0) # 0 = 上下翻转
|
||||||
|
ret, jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if ret:
|
||||||
|
resp = Response(jpg.tobytes(), mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
resp = Response(r.content, mimetype="image/jpeg")
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
resp.headers["Expires"] = "0"
|
||||||
|
return resp
|
||||||
|
return "", 404
|
||||||
|
except Exception as ex:
|
||||||
|
logger.info(f"arm_refresh 尝试{attempt}/{max_retries} 失败: {ex}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
logger.warning(f"arm_refresh 在 {max_retries} 次尝试后仍失败")
|
||||||
|
return "", 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/camera/arm_preview")
|
@app.route("/api/camera/arm_preview")
|
||||||
def api_arm_camera_preview():
|
def api_arm_camera_preview():
|
||||||
"""代理机械臂 MJPEG 视频流,供页面连续预览。"""
|
"""代理机械臂 MJPEG 视频流,逐帧上下翻转后返回。"""
|
||||||
import requests
|
import requests
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
try:
|
try:
|
||||||
upstream = requests.get(
|
upstream = requests.get(
|
||||||
ARM_CAMERA_CONFIG["url"],
|
ARM_CAMERA_CONFIG["url"],
|
||||||
@@ -1165,10 +1228,31 @@ def api_arm_camera_preview():
|
|||||||
return "", 404
|
return "", 404
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
buf = b""
|
||||||
try:
|
try:
|
||||||
for chunk in upstream.iter_content(chunk_size=8192):
|
for chunk in upstream.iter_content(chunk_size=8192):
|
||||||
if chunk:
|
if not chunk:
|
||||||
yield chunk
|
continue
|
||||||
|
buf += chunk
|
||||||
|
# 查找 MJPEG 帧边界
|
||||||
|
while True:
|
||||||
|
start = buf.find(b"\xff\xd8")
|
||||||
|
end = buf.find(b"\xff\xd9")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
jpg_data = buf[start:end+2]
|
||||||
|
buf = buf[end+2:]
|
||||||
|
# 解码 → 上下翻转 → 编码
|
||||||
|
img = cv2.imdecode(np.frombuffer(jpg_data, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if img is not None:
|
||||||
|
img = cv2.flip(img, 0)
|
||||||
|
ret, flipped_jpg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if ret:
|
||||||
|
yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + flipped_jpg.tobytes() + b"\r\n"
|
||||||
|
else:
|
||||||
|
# 解码失败,直透原始帧(不应发生)
|
||||||
|
yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpg_data + b"\r\n"
|
||||||
|
else:
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
upstream.close()
|
upstream.close()
|
||||||
|
|
||||||
@@ -1670,12 +1754,105 @@ def api_qr_config_scan(qr_id):
|
|||||||
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
return jsonify({"ok": False, "error": f"扫描失败: {str(ex)}"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 报关单接口(代理外部 API) ==========
|
||||||
|
|
||||||
|
_ZHIJIAN_BASE = "https://ts.zhijian168.com/prod-api/zhijian/integration"
|
||||||
|
_ZHIJIAN_AUTH = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
|
||||||
|
|
||||||
|
|
||||||
|
@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"{_ZHIJIAN_BASE}/customsListPage?pageNum={page}&pageSize={size}"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return jsonify({"ok": False, "error": f"报关单API返回 {r.status_code}"}), 502
|
||||||
|
data = r.json()
|
||||||
|
return jsonify({"ok": True, "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取报关单列表失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customs/machines")
|
||||||
|
def api_customs_machines():
|
||||||
|
"""根据报关单 ID 获取机器列表(代理外部 API)
|
||||||
|
查询参数: customsId=xxx
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
customs_id = request.args.get("customsId", "")
|
||||||
|
if not customs_id:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400
|
||||||
|
url = f"{_ZHIJIAN_BASE}/customsMachines?customsId={customs_id}"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return jsonify({"ok": False, "error": f"机器列表API返回 {r.status_code}"}), 502
|
||||||
|
data = r.json()
|
||||||
|
return jsonify({"ok": True, "data": data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取报关单机器列表失败: {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})
|
||||||
|
|
||||||
|
|
||||||
# ========== 静态资源 ==========
|
# ========== 静态资源 ==========
|
||||||
@app.route("/photos/<name>")
|
@app.route("/photos/<name>")
|
||||||
def photos(name):
|
def photos(name):
|
||||||
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
return send_from_directory(os.path.join(DATA_DIR, "photos"), name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/refresh")
|
||||||
|
def api_camera_refresh():
|
||||||
|
"""AGV 摄像头单帧 JPEG(polling 模式)"""
|
||||||
|
if not gs.qr_scanner:
|
||||||
|
return jsonify({"error": "scanner not initialized"}), 400
|
||||||
|
if not gs.qr_scanner._cap or not gs.qr_scanner._cap.isOpened():
|
||||||
|
return jsonify({"error": "camera not opened"}), 400
|
||||||
|
import cv2
|
||||||
|
frame = gs.qr_scanner.read_frame()
|
||||||
|
if frame is None:
|
||||||
|
return jsonify({"error": "read frame failed"}), 400
|
||||||
|
# 检查是否为全黑/无内容的帧(Orbbec 深度/IR 帧可能无内容)
|
||||||
|
if frame.mean() < 5:
|
||||||
|
return jsonify({"error": "camera sensor not ready"}), 400
|
||||||
|
ret, buf = cv2.imencode(".jpg", frame)
|
||||||
|
if ret:
|
||||||
|
return Response(buf.tobytes(), mimetype="image/jpeg")
|
||||||
|
return jsonify({"error": "encode failed"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/camera/capabilities")
|
||||||
|
def api_camera_capabilities():
|
||||||
|
"""返回摄像头能力信息,前端据此决定如何展示"""
|
||||||
|
return jsonify({
|
||||||
|
"has_agv_camera": False, # Orbbec 深度相机不提供可用的彩色画面
|
||||||
|
"has_arm_camera": True,
|
||||||
|
})
|
||||||
# ========== 启动 ==========
|
# ========== 启动 ==========
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
@@ -1702,3 +1879,4 @@ if __name__ == "__main__":
|
|||||||
debug=SERVER_CONFIG["debug"],
|
debug=SERVER_CONFIG["debug"],
|
||||||
threaded=True
|
threaded=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -7,8 +7,8 @@ import os
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||||
AGV_HOST = "192.168.60.177"
|
AGV_HOST = "192.168.60.80"
|
||||||
ARM_HOST = "192.168.60.88"
|
ARM_HOST = "192.168.60.120"
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
# ========== AGV 参数 ==========
|
||||||
AGV_CONFIG = {
|
AGV_CONFIG = {
|
||||||
@@ -35,7 +35,7 @@ MAP_CONFIG = {
|
|||||||
|
|
||||||
# ========== 摄像头 ==========
|
# ========== 摄像头 ==========
|
||||||
CAMERA_CONFIG = {
|
CAMERA_CONFIG = {
|
||||||
"device_index": 4, # AGV 摄像头 video4(标准彩色摄像头,V4L2后端)
|
"device_index": 3, # AGV 摄像头 video3(Orbbec Gemini 彩色流,V4L2后端)
|
||||||
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
"backend": "v4l2", # 使用 V4L2 后端获取标准彩色格式(640x480)
|
||||||
"qr_detect_interval": 0.5,
|
"qr_detect_interval": 0.5,
|
||||||
"capture_delay": 0.5,
|
"capture_delay": 0.5,
|
||||||
|
|||||||
@@ -465,6 +465,10 @@ a:hover { text-decoration: underline; }
|
|||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.camera-img.arm {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
@@ -1076,3 +1080,114 @@ a:hover { text-decoration: underline; }
|
|||||||
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
.machine-cell.mstatus-pending { background: #141e28; border-color: #2a3a4a; }
|
||||||
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
.machine-cell.mstatus-active { background: #1a2535; border-color: #4fc3f7; }
|
||||||
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
.machine-cell.mstatus-completed { background: #152522; border-color: #2e7d32; }
|
||||||
|
|
||||||
|
/* ===== 报关单选择 ===== */
|
||||||
|
.customs-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.customs-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.customs-select {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1a2535;
|
||||||
|
border: 1px solid #2a3a4a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.customs-select:focus {
|
||||||
|
border-color: #4fc3f7;
|
||||||
|
}
|
||||||
|
.customs-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.customs-select option {
|
||||||
|
background: #1a2535;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.customs-select:disabled option {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.customs-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8899aa;
|
||||||
|
}
|
||||||
|
.customs-badge {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.customs-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 数据表格 ===== */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f1923;
|
||||||
|
color: #8899aa;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid #2a3a4a;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a2a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: #1a2a3a;
|
||||||
|
}
|
||||||
|
.clickable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.clickable-row:hover {
|
||||||
|
background: #1a2535 !important;
|
||||||
|
}
|
||||||
|
.row-selected {
|
||||||
|
background: #142a3a !important;
|
||||||
|
border-left: 3px solid #4fc3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Badge 状态标签 ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-unknown { background: #2a3441; color: #8899aa; }
|
||||||
|
.badge-normal { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-active { background: #1a3050; color: #4fc3f7; }
|
||||||
|
.badge-finished { background: #1a3a2a; color: #4caf50; }
|
||||||
|
.badge-waiting { background: #3a3020; color: #ffc107; }
|
||||||
|
.badge-error { background: #3a1a1a; color: #f44336; }
|
||||||
|
|
||||||
|
/* ===== 分页控件 ===== */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ createApp({
|
|||||||
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
agvCameraSrc: '/api/camera/refresh?t=' + Date.now(),
|
||||||
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
armCameraSrc: '/api/camera/arm_preview?t=' + Date.now(),
|
||||||
agvCameraError: false,
|
agvCameraError: false,
|
||||||
|
hasAgvCamera: false, // AGV 车体是否有可用相机
|
||||||
armCameraError: false,
|
armCameraError: false,
|
||||||
reconnectingDevice: null
|
reconnectingDevice: null
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ createApp({
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
|
this.refreshCameraCapabilities()
|
||||||
setInterval(this.refreshStatus, 3000)
|
setInterval(this.refreshStatus, 3000)
|
||||||
this.refreshCams()
|
this.refreshCams()
|
||||||
setInterval(() => this.refreshCams(), 2000)
|
setInterval(() => this.refreshCams(), 2000)
|
||||||
@@ -47,6 +49,17 @@ createApp({
|
|||||||
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
this.armCameraSrc = '/api/camera/arm_preview?t=' + Date.now()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async refreshCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
|
refreshAgvCamera() {
|
||||||
|
this.agvCameraSrc = '/api/camera/refresh?t=' + Date.now()
|
||||||
|
this.agvCameraError = false
|
||||||
|
},
|
||||||
async refresh() {
|
async refresh() {
|
||||||
await this.refreshStatus()
|
await this.refreshStatus()
|
||||||
await this.loadPoints()
|
await this.loadPoints()
|
||||||
@@ -58,6 +71,10 @@ createApp({
|
|||||||
this.agvConnected = data.agv_connected
|
this.agvConnected = data.agv_connected
|
||||||
this.armConnected = data.arm_connected
|
this.armConnected = data.arm_connected
|
||||||
this.cameraOpened = data.camera_opened
|
this.cameraOpened = data.camera_opened
|
||||||
|
// 尝试从后端获取摄像头能力,若无字段则保持默认 false
|
||||||
|
if (data.has_agv_camera !== undefined) {
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
}
|
||||||
this.armCameraOpened = data.arm_camera_opened
|
this.armCameraOpened = data.arm_camera_opened
|
||||||
this.mapLoaded = data.map_loaded
|
this.mapLoaded = data.map_loaded
|
||||||
this.currentState = data.state || 'idle'
|
this.currentState = data.state || 'idle'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ createApp({
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
report: null,
|
report: null,
|
||||||
armCameraOpened: false,
|
armCameraOpened: false,
|
||||||
|
hasAgvCamera: false,
|
||||||
agvPreviewUrl: API + '/api/camera/preview',
|
agvPreviewUrl: API + '/api/camera/preview',
|
||||||
armPreviewUrl: '',
|
armPreviewUrl: '',
|
||||||
polling: null,
|
polling: null,
|
||||||
@@ -70,6 +71,13 @@ createApp({
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
},
|
},
|
||||||
|
async checkAgvCameraCapabilities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/camera/capabilities')
|
||||||
|
const data = await res.json()
|
||||||
|
this.hasAgvCamera = data.has_agv_camera
|
||||||
|
} catch (e) { this.hasAgvCamera = false }
|
||||||
|
},
|
||||||
poll() {
|
poll() {
|
||||||
this.refresh()
|
this.refresh()
|
||||||
this.pollLogs()
|
this.pollLogs()
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ createApp({
|
|||||||
armSnapshotLoading: false,
|
armSnapshotLoading: false,
|
||||||
newQrName: '',
|
newQrName: '',
|
||||||
armInitialPose: [0, 0, 0, 0, 0, 0],
|
armInitialPose: [0, 0, 0, 0, 0, 0],
|
||||||
|
// 报关单
|
||||||
|
customsList: [],
|
||||||
|
customsLoading: false,
|
||||||
|
customsPage: 1,
|
||||||
|
customsPageSize: 15,
|
||||||
|
customsTotal: 0,
|
||||||
|
selectedCustomsId: '',
|
||||||
|
selectedCustomsName: '',
|
||||||
|
customsMachines: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -76,6 +85,13 @@ createApp({
|
|||||||
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
customsTotalPages() {
|
||||||
|
return Math.max(1, Math.ceil(this.customsTotal / this.customsPageSize))
|
||||||
|
},
|
||||||
|
customsPageData() {
|
||||||
|
// 前端显示 pagination data — 但我们在 API 后端做分页,所以这里只是引用
|
||||||
|
return this.customsList
|
||||||
|
},
|
||||||
hasQr() {
|
hasQr() {
|
||||||
return !!(this.selectedMachine && this.selectedMachine.qr)
|
return !!(this.selectedMachine && this.selectedMachine.qr)
|
||||||
},
|
},
|
||||||
@@ -1187,5 +1203,64 @@ createApp({
|
|||||||
alert('❌ 复位请求失败: ' + e.message)
|
alert('❌ 复位请求失败: ' + e.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
// ===== 报关单方法 =====
|
||||||
|
async loadCustomsList() {
|
||||||
|
this.customsLoading = true
|
||||||
|
try {
|
||||||
|
const url = API + '/api/customs/list?pageNum=' + this.customsPage + '&pageSize=' + this.customsPageSize
|
||||||
|
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) {
|
||||||
|
const id = item.id || item.customsId || item.customs_id || ''
|
||||||
|
if (!id) return
|
||||||
|
this.selectedCustomsId = id
|
||||||
|
this.selectedCustomsName = item.customsNo || item.customs_no || item.name || item.customsName || item.customs_name || id
|
||||||
|
this.customsMachines = []
|
||||||
|
try {
|
||||||
|
const res = await fetch(API + '/api/customs/machines?customsId=' + encodeURIComponent(id))
|
||||||
|
const d = await res.json()
|
||||||
|
if (d.ok && d.data) {
|
||||||
|
const raw = d.data
|
||||||
|
let machines = []
|
||||||
|
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 = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
@@ -93,9 +93,10 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-row">
|
<div class="camera-row">
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="agvCameraSrc='/api/camera/refresh?t='+Date.now(); agvCameraError=false">刷新</button></div>
|
<div class="camera-label">AGV 摄像头 <button class="btn btn-small" @click="refreshAgvCamera()">刷新</button></div>
|
||||||
<img v-if="cameraOpened && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
<img v-if="cameraOpened && hasAgvCamera && !agvCameraError" :src="agvCameraSrc" class="camera-img" @error="agvCameraError=true">
|
||||||
<div v-if="cameraOpened && agvCameraError" class="camera-placeholder">AGV 摄像头异常</div>
|
<div v-if="cameraOpened && agvCameraError && hasAgvCamera" class="camera-placeholder">AGV 摄像头异常</div>
|
||||||
|
<div v-if="cameraOpened && !hasAgvCamera" class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
<div v-else-if="!cameraOpened" class="camera-placeholder">未打开(先点击连接设备)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
|
|||||||
@@ -173,8 +173,9 @@
|
|||||||
<h2>📷 摄像头预览</h2>
|
<h2>📷 摄像头预览</h2>
|
||||||
<div class="camera-dual">
|
<div class="camera-dual">
|
||||||
<div class="camera-box">
|
<div class="camera-box">
|
||||||
<div class="camera-label">🎥 AGV 摄像头</div>
|
<div class="camera-label">🎥 AGV 摄像头 <span v-if="!hasAgvCamera" style="font-size:0.8em;color:#999">(不可用)</span></div>
|
||||||
<img :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
<img v-if="hasAgvCamera" :src="agvPreviewUrl" @error="onAgvPreviewError" class="camera-img">
|
||||||
|
<div v-else class="camera-placeholder">AGV 无可用彩色摄像头</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="camera-box" v-if="armCameraOpened">
|
<div class="camera-box" v-if="armCameraOpened">
|
||||||
<div class="camera-label">🦾 机械臂摄像头</div>
|
<div class="camera-label">🦾 机械臂摄像头</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>设置 - AGV 拍摄系统</title>
|
<title>设置 - AGV 拍摄系统</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260529b">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260612b">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
<button class="tab" :class="{active: tab === 'model'}" @click="tab = 'model'">📦 机型配置</button>
|
||||||
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
<button class="tab" :class="{active: tab === 'arm'}" @click="tab = 'arm'">🤖 机械臂</button>
|
||||||
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
<button class="tab" :class="{active: tab === 'agv'}" @click="tab = 'agv'">🚗 AGV控制</button>
|
||||||
|
<button class="tab" :class="{active: tab === 'customs'}" @click="tab = 'customs'">📋 报关单</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -435,7 +436,7 @@
|
|||||||
<!-- 机械臂摄像头画面 -->
|
<!-- 机械臂摄像头画面 -->
|
||||||
<div style="margin-bottom:8px">
|
<div style="margin-bottom:8px">
|
||||||
<div class="camera-preview" style="max-width:640px">
|
<div class="camera-preview" style="max-width:640px">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError" style="width:100%;border-radius:8px">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||||
@@ -499,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="camera-preview">
|
<div class="camera-preview">
|
||||||
<img :src="armCameraUrl" @error="onArmPreviewError">
|
<img :src="armCameraUrl" @error="onArmPreviewError" class="camera-img arm">
|
||||||
</div>
|
</div>
|
||||||
<div class="joints-panel">
|
<div class="joints-panel">
|
||||||
<h3>关节角度控制</h3>
|
<h3>关节角度控制</h3>
|
||||||
@@ -587,6 +588,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ========== 报关单 Tab ========== -->
|
||||||
|
<div v-if="tab === 'customs'">
|
||||||
|
<section class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h2>📋 报关单列表</h2>
|
||||||
|
<button class="btn btn-secondary" @click="loadCustomsList" :disabled="customsLoading">
|
||||||
|
<span v-if="customsLoading">⏳</span><span v-else>🔄</span> 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-bottom:12px">选择报关单查看其中的机器列表,点击报关单 ID 展开机器信息</p>
|
||||||
|
|
||||||
|
<!-- 分页表格 -->
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">序号</th>
|
||||||
|
<th>报关单号</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>机器数</th>
|
||||||
|
<th style="width:80px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, idx) in customsPageData" :key="item.id || idx"
|
||||||
|
class="clickable-row"
|
||||||
|
:class="{ 'row-selected': selectedCustomsId === (item.id || item.customsId || item.customs_id) }"
|
||||||
|
@click="selectCustomsRow(item, idx)">
|
||||||
|
<td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ item.customsNo || item.customs_no || item.id || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ item.name || item.customsName || item.customs_name || '-' }}{% endraw %}</td>
|
||||||
|
<td><span class="badge" :class="'badge-' + (item.status || 'unknown')">{% raw %}{{ item.status || '未知' }}{% endraw %}</span></td>
|
||||||
|
<td>{% raw %}{{ item.machineCount || item.machine_count || item.machineNum || item.machine_num || '?' }}{% endraw %}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item, idx)">
|
||||||
|
📦 查看机器
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!customsPageData.length && !customsLoading">
|
||||||
|
<td colspan="6" 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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(m, mi) in customsMachines" :key="mi">
|
||||||
|
<td>{% raw %}{{ mi + 1 }}{% endraw %}</td>
|
||||||
|
<td><strong>{% raw %}{{ m.serialNumber || m.serial_number || m.serialNo || m.serial_no || m.machineNo || m.machine_no || m.id || '-' }}{% endraw %}</strong></td>
|
||||||
|
<td>{% raw %}{{ m.name || m.machineName || m.machine_name || m.model || '-' }}{% endraw %}</td>
|
||||||
|
<td>{% raw %}{{ m.modelName || m.model_name || m.model || '-' }}{% endraw %}</td>
|
||||||
|
<td style="font-family:monospace;color:#4fc3f7">{% raw %}{{ m.qrValue || m.qr_value || m.qr || m.qrCode || m.qr_code || m.serialNumber || m.serial_number || '-' }}{% endraw %}</td>
|
||||||
|
<td><span class="badge" :class="'badge-' + (m.status || 'normal')">{% raw %}{{ m.status || '正常' }}{% endraw %}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 手动输入二维码弹窗 -->
|
<!-- 手动输入二维码弹窗 -->
|
||||||
@@ -621,6 +713,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
<script src="/static/js/vue3.global.prod.js?v=20260526a"></script>
|
||||||
<script src="/static/js/setting.js?v=20260605a"></script>
|
<script src="/static/js/setting.js?v=20260612a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+23
-14
@@ -61,14 +61,18 @@ class ArmClient:
|
|||||||
def get_angles(self) -> Tuple[bool, List[float]]:
|
def get_angles(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取所有关节角度"""
|
"""获取所有关节角度"""
|
||||||
ok, resp = self.send_command("get_angles()")
|
ok, resp = self.send_command("get_angles()")
|
||||||
if ok and resp.startswith("get_angles:["):
|
if ok:
|
||||||
try:
|
try:
|
||||||
# get_angles:[0.174, 0.520, ...] → list
|
# 兼容 "get_angles:[-260.2,...]" 和 "[-260.2,...]" 两种格式
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
angles = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, angles
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
angles = [float(x) for x in nums]
|
||||||
|
if len(angles) == 6:
|
||||||
|
return True, angles
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
def set_angles(self, angles: List[float], speed: int = 500) -> bool:
|
||||||
@@ -94,13 +98,17 @@ class ArmClient:
|
|||||||
def get_coords(self) -> Tuple[bool, List[float]]:
|
def get_coords(self) -> Tuple[bool, List[float]]:
|
||||||
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
"""获取当前坐标和姿态 [x, y, z, rx, ry, rz]"""
|
||||||
ok, resp = self.send_command("get_coords()")
|
ok, resp = self.send_command("get_coords()")
|
||||||
if ok and "get_coords:" in resp:
|
if ok:
|
||||||
try:
|
try:
|
||||||
nums = resp.split("[")[1].split("]")[0]
|
text = resp.split(":", 1)[-1] if ":" in resp else resp
|
||||||
coords = [float(x) for x in nums.split(",")]
|
text = text.strip()
|
||||||
return True, coords
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
nums = text[1:-1].split(",")
|
||||||
|
coords = [float(x) for x in nums]
|
||||||
|
if len(coords) == 6:
|
||||||
|
return True, coords
|
||||||
except:
|
except:
|
||||||
return False, []
|
pass
|
||||||
return False, []
|
return False, []
|
||||||
|
|
||||||
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
def set_coords(self, coords: List[float], speed: int = 500) -> bool:
|
||||||
@@ -132,19 +140,20 @@ class ArmClient:
|
|||||||
def state_check(self) -> bool:
|
def state_check(self) -> bool:
|
||||||
"""检查机械臂状态是否正常"""
|
"""检查机械臂状态是否正常"""
|
||||||
ok, resp = self.send_command("state_check()")
|
ok, resp = self.send_command("state_check()")
|
||||||
return ok and resp == "state_check:1"
|
# 兼容 "state_check:1" 和 "1" 两种格式
|
||||||
|
return ok and resp.strip().lstrip("state_check:") == "1"
|
||||||
|
|
||||||
def check_running(self) -> bool:
|
def check_running(self) -> bool:
|
||||||
"""检查机械臂是否在运行"""
|
"""检查机械臂是否在运行"""
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
return ok and resp == "check_running:1"
|
return ok and resp.strip().lstrip("check_running:") == "1"
|
||||||
|
|
||||||
def wait_done(self, timeout: float = 30) -> bool:
|
def wait_done(self, timeout: float = 30) -> bool:
|
||||||
"""等待上一条命令执行完成"""
|
"""等待上一条命令执行完成"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
ok, resp = self.send_command("check_running()")
|
ok, resp = self.send_command("check_running()")
|
||||||
if ok and resp == "check_running:0":
|
if ok and resp.strip().lstrip("check_running:") == "0":
|
||||||
return True
|
return True
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import os
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
# ========== 网络配置(集中管理所有 IP 地址 — 修改此处即可全局生效)==========
|
||||||
AGV_HOST = "192.168.60.177"
|
AGV_HOST = "192.168.60.80"
|
||||||
ARM_HOST = "192.168.60.88"
|
ARM_HOST = "192.168.60.120"
|
||||||
|
|
||||||
# ========== AGV 参数 ==========
|
# ========== AGV 参数 ==========
|
||||||
AGV_CONFIG = {
|
AGV_CONFIG = {
|
||||||
|
|||||||
+62
-12
@@ -29,19 +29,22 @@ class QRScanner:
|
|||||||
def open(self) -> bool:
|
def open(self) -> bool:
|
||||||
"""打开摄像头"""
|
"""打开摄像头"""
|
||||||
try:
|
try:
|
||||||
# 强制 V4L2 后端,获取标准彩色格式(与 test/server.py 一致)
|
# 强制 V4L2 后端
|
||||||
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
|
||||||
if self._cap.isOpened():
|
if not self._cap.isOpened():
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (V4L2)")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# fallback: 不指定后端
|
|
||||||
self._cap = cv2.VideoCapture(self.device_index)
|
self._cap = cv2.VideoCapture(self.device_index)
|
||||||
if self._cap.isOpened():
|
|
||||||
logger.info(f"摄像头 {self.device_index} 已打开 (默认后端)")
|
if not self._cap.isOpened():
|
||||||
return True
|
|
||||||
logger.error(f"无法打开摄像头 {self.device_index}")
|
logger.error(f"无法打开摄像头 {self.device_index}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 确保 OpenCV 做 BGR 转换(部分 V4L2 后端默认不做 YUYV→BGR 转换)
|
||||||
|
self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
|
||||||
|
# 设置分辨率(使用默认分辨率,不强制 MJPG)
|
||||||
|
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
logger.info(f"摄像头 {self.device_index} 已打开,分辨率 {w}x{h}")
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"摄像头打开失败: {e}")
|
logger.error(f"摄像头打开失败: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -51,14 +54,61 @@ class QRScanner:
|
|||||||
self._cap.release()
|
self._cap.release()
|
||||||
self._cap = None
|
self._cap = None
|
||||||
|
|
||||||
|
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 > 220 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, 1)
|
||||||
|
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) -> Optional[np.ndarray]:
|
def read_frame(self) -> Optional[np.ndarray]:
|
||||||
"""读取一帧"""
|
"""读取一帧"""
|
||||||
if not self._cap or not self._cap.isOpened():
|
if not self._cap or not self._cap.isOpened():
|
||||||
return None
|
return None
|
||||||
ret, frame = self._cap.read()
|
ret, frame = self._cap.read()
|
||||||
if not ret:
|
if not ret or frame is None:
|
||||||
return None
|
return None
|
||||||
return frame
|
return self._fix_frame(frame)
|
||||||
|
|
||||||
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
def detect_qr(self, frame: np.ndarray) -> Optional[str]:
|
||||||
"""从图像帧中检测二维码"""
|
"""从图像帧中检测二维码"""
|
||||||
@@ -96,4 +146,4 @@ class QRScanner:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
self.close()
|
self.close()
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ def _ensure_ffmpeg():
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-f", "v4l2",
|
"-f", "v4l2",
|
||||||
"-input_format", "mjpeg",
|
"-input_format", "mjpeg",
|
||||||
"-framerate", "15",
|
"-framerate", "12",
|
||||||
"-video_size", "640x480",
|
"-video_size", "1280x720",
|
||||||
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
|
||||||
"-vf", "rotate=PI",
|
"-vf", "rotate=PI",
|
||||||
"-q:v", "8",
|
"-q:v", "4",
|
||||||
"-f", "mjpeg",
|
"-f", "mjpeg",
|
||||||
"-"
|
"-"
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"agv": {
|
"agv": {
|
||||||
"ip": "192.168.60.177",
|
"ip": "192.168.60.80",
|
||||||
"ssh_user": "elephant",
|
"ssh_user": "elephant",
|
||||||
"ssh_password": "Elephant",
|
"ssh_password": "Elephant",
|
||||||
"map_file": "map.yaml",
|
"map_file": "map.yaml",
|
||||||
"map_dir": "/home/elephant"
|
"map_dir": "/home/elephant"
|
||||||
},
|
},
|
||||||
"arm": {
|
"arm": {
|
||||||
"ip": "192.168.60.88",
|
"ip": "192.168.60.120",
|
||||||
"ssh_user": "pi",
|
"ssh_user": "pi",
|
||||||
"ssh_password": "elephant",
|
"ssh_password": "elephant",
|
||||||
"socket_port": 5001,
|
"socket_port": 5001,
|
||||||
|
|||||||
Reference in New Issue
Block a user