This commit is contained in:
ywb
2026-06-16 14:17:05 +08:00
parent 62292edc70
commit 916b44bc3c
10 changed files with 725 additions and 133 deletions
+262 -23
View File
@@ -8,10 +8,11 @@ import time
import logging import logging
import threading import threading
import subprocess import subprocess
import requests
from flask import Flask, render_template, jsonify, request, Response, send_from_directory from flask import Flask, render_template, jsonify, request, Response, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from config import SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State from config import SERVER_CONFIG, ARM_CONFIG, AGV_CONFIG, UPLOAD_CONFIG, MAP_CONFIG, ARM_CAMERA_CONFIG, CAMERA_CONFIG, DATA_DIR, State, ZHIJIAN_BASE_URL, ZHIJIAN_AUTH_TOKEN, set_api_mode
from utils.arm_client import ArmClient from utils.arm_client import ArmClient
from utils.agv_controller_ros2 import AGVController from utils.agv_controller_ros2 import AGVController
from utils.qr_scanner import QRScanner from utils.qr_scanner import QRScanner
@@ -54,6 +55,7 @@ class GlobalState:
self.qr_config = [] # 二维码配置(独立点位列表) self.qr_config = [] # 二维码配置(独立点位列表)
self.navigator = None # Nav2Navigator 实例 self.navigator = None # Nav2Navigator 实例
self.current_customs = None # 当前设定的报关单信息 self.current_customs = None # 当前设定的报关单信息
self.inspection = None # 查验状态 {customs_id, customs_name, items: [{inventoryCode, inventoryName, spec, quantify, inspected}]}
self.error_msg = "" # 错误弹窗消息(waiting_error 状态时) self.error_msg = "" # 错误弹窗消息(waiting_error 状态时)
self.lock = threading.Lock() self.lock = threading.Lock()
@@ -1213,10 +1215,8 @@ def api_arm_camera_refresh():
@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"],
@@ -1241,15 +1241,7 @@ def api_arm_camera_preview():
if start != -1 and end != -1 and end > start: if start != -1 and end != -1 and end > start:
jpg_data = buf[start:end+2] jpg_data = buf[start:end+2]
buf = buf[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" yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + jpg_data + b"\r\n"
else: else:
break break
@@ -1344,6 +1336,11 @@ def api_agv_reset():
def api_mission_start(): def api_mission_start():
"""开始执行任务(V3: M×N Grid 蛇形路径)""" """开始执行任务(V3: M×N Grid 蛇形路径)"""
data = request.json or {} data = request.json or {}
# 必须先设置报关单(开始查验)
if not gs.inspection:
return jsonify({"ok": False, "error": "请先在「设置→报关单」中选择报关单并点击「开始查验」"}), 400
single_step = bool(data.get("single_step", False)) single_step = bool(data.get("single_step", False))
# 任务步骤控制开关 # 任务步骤控制开关
options = { options = {
@@ -1538,6 +1535,18 @@ def api_mission_state():
result["waiting_step"] = False result["waiting_step"] = False
result["waiting_error"] = False result["waiting_error"] = False
# 查验状态
result["inspection"] = gs.inspection
# QR 输入弹窗消息
if hasattr(MissionExecutorV3, "_instance") and MissionExecutorV3._instance:
rpt = MissionExecutorV3._instance.report
result["qr_message"] = rpt.get("qr_message", "")
result["step_label"] = rpt.get("step_label", "")
else:
result["qr_message"] = ""
result["step_label"] = ""
return jsonify(result) return jsonify(result)
@app.route("/api/mission/log", methods=["GET"]) @app.route("/api/mission/log", methods=["GET"])
@@ -1755,10 +1764,45 @@ def api_qr_config_scan(qr_id):
# ========== 环境切换 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 ========== # ========== 报关单接口(代理外部 API ==========
_ZHIJIAN_BASE = "https://ts.zhijian168.com/prod-api/zhijian/integration" def _get_zhijian_base():
_ZHIJIAN_AUTH = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA" """动态获取报关单 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") @app.route("/api/customs/list")
@@ -1767,36 +1811,49 @@ def api_customs_list():
import requests import requests
page = request.args.get("pageNum", 1) page = request.args.get("pageNum", 1)
size = request.args.get("pageSize", 50) size = request.args.get("pageSize", 50)
url = f"{_ZHIJIAN_BASE}/customsListPage?pageNum={page}&pageSize={size}" url = f"{_get_zhijian_base()}/customsListPage?pageNum={page}&pageSize={size}"
logger.info(f"[customs/list] 🔍 请求 → {url}")
try: try:
r = requests.get(url, headers={"Authorization": _ZHIJIAN_AUTH}, timeout=15) 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: 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 return jsonify({"ok": False, "error": f"报关单API返回 {r.status_code}"}), 502
data = r.json() data = r.json()
rows = data.get("rows", [])
logger.info(f"[customs/list] ✅ 获取到 {len(rows)} 条报关单")
return jsonify({"ok": True, "data": data}) return jsonify({"ok": True, "data": data})
except Exception as e: except Exception as e:
logger.error(f"获取报关单列表失败: {e}") logger.error(f"[customs/list] ❌ 失败: {e}")
return jsonify({"ok": False, "error": str(e)}), 502 return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/customs/machines") @app.route("/api/customs/machines")
def api_customs_machines(): def api_customs_machines():
"""根据报关单 ID 获取机器列表(代理外部 API) """根据报关单 ID 获取机器列表(代理外部 API)
查询参数: customsId=xxx 数据源:cjt_customs_item 表 → Java customsMachines 接口
""" """
import requests import requests
customs_id = request.args.get("customsId", "") customs_id = request.args.get("customsId", "")
logger.info(f"[customs/machines] 📥 customsId={customs_id}")
if not customs_id: if not customs_id:
logger.warning("[customs/machines] ⚠️ 缺少 customsId")
return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400 return jsonify({"ok": False, "error": "缺少 customsId 参数"}), 400
url = f"{_ZHIJIAN_BASE}/customsMachines?customsId={customs_id}"
try: 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) 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: if r.status_code != 200:
return jsonify({"ok": False, "error": f"机器列表API返回 {r.status_code}"}), 502 logger.warning(f"[customs/machines] ⚠️ 返回非200: {r.status_code}, body={r.text[:300]}")
data = r.json() return jsonify({"ok": False, "error": "机器列表API返回非200"}), 502
return jsonify({"ok": True, "data": data}) 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: except Exception as e:
logger.error(f"获取报关单机器列表失败: {e}") logger.error(f"[customs/machines] ❌ 失败: {e}")
return jsonify({"ok": False, "error": str(e)}), 502 return jsonify({"ok": False, "error": str(e)}), 502
@@ -1820,6 +1877,188 @@ def api_customs_selected_get():
return jsonify({"ok": True, "customs": c}) 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"]:
item["inspected"] += 1
result["matchedItem"] = item
logger.info(f"[printer] ✅ 查验计数更新: {item['inventoryName']}{item['inspected']}/{item['quantify']}")
break
else:
logger.warning(f"[printer] ⚠️ inventoryCode={result['inventoryCode']} 不在查验清单中")
logger.info(f"[printer] 📤 返回结果: modelName={result['modelName']}, inventoryCode={result['inventoryCode']}, hasInspection={result['hasInspection']}, matched={'yes' if result['matchedItem'] else 'no'}")
return jsonify(result)
@app.route("/api/customs/inspection/update", methods=["POST"])
def api_customs_inspection_update():
"""直接更新查验计数(由执行器调用)"""
data = request.json or {}
code = data.get("inventoryCode", "")
if not gs.inspection or not code:
return jsonify({"ok": False})
for item in gs.inspection["items"]:
if item["inventoryCode"] == code:
item["inspected"] += 1
return jsonify({"ok": True, "item": item})
return jsonify({"ok": False, "error": "未匹配"})
# ========== 静态资源 ========== # ========== 静态资源 ==========
@app.route("/photos/<name>") @app.route("/photos/<name>")
def photos(name): def photos(name):
+22 -1
View File
@@ -47,9 +47,30 @@ ARM_CAMERA_CONFIG = {
"snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot", "snapshot_url": f"http://{ARM_HOST}:5003/api/camera/snapshot",
} }
# ========== 外部 API 环境 ==========
# 切换测试/正式环境只需改 TEST_MODE 一个变量
TEST_MODE = False # True=测试环境(192.168.60.159), False=正式环境(ts.zhijian168.com)
PROD_BASE_URL = "https://ts.zhijian168.com"
TEST_BASE_URL = "http://192.168.60.159:8080"
PROD_API_PREFIX = "/prod-api"
TEST_API_PREFIX = "" # 测试服务器无 /prod-api 网关前缀
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
ZHIJIAN_AUTH_TOKEN = "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA"
def set_api_mode(test_mode):
"""运行时切换 API 环境 — 无需重启 Flask"""
global TEST_MODE, ZHIJIAN_BASE_URL, API_PREFIX, UPLOAD_CONFIG
TEST_MODE = bool(test_mode)
ZHIJIAN_BASE_URL = TEST_BASE_URL if TEST_MODE else PROD_BASE_URL
API_PREFIX = TEST_API_PREFIX if TEST_MODE else PROD_API_PREFIX
UPLOAD_CONFIG["url"] = f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage"
# ========== HTTP 上传 ========== # ========== HTTP 上传 ==========
UPLOAD_CONFIG = { UPLOAD_CONFIG = {
"url": "https://ts.zhijian168.com/prod-api/file/uploadImage", "url": f"{ZHIJIAN_BASE_URL}{API_PREFIX}/file/uploadImage",
"timeout": 30, "timeout": 30,
"max_retries": 3, "max_retries": 3,
} }
+115 -1
View File
@@ -59,6 +59,49 @@ a:hover { text-decoration: underline; }
.status-item.paused { background: #3a2a1a; color: #ff9800; } .status-item.paused { background: #3a2a1a; color: #ff9800; }
.status-item.idle { background: #2a2a2a; color: #9aa0a6; } .status-item.idle { background: #2a2a2a; color: #9aa0a6; }
/* ========== 环境切换开关 ========== */
.env-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.env-label {
font-size: 12px;
font-weight: 500;
min-width: 48px;
text-align: right;
transition: color 0.2s;
}
.env-label.test { color: #ff9800; }
.env-label.prod { color: #4fc3f7; }
.toggle-switch {
width: 40px;
height: 22px;
background: #3a3a3a;
border-radius: 11px;
position: relative;
transition: background 0.25s;
flex-shrink: 0;
}
.toggle-switch.active {
background: #ff9800;
}
.toggle-knob {
width: 18px;
height: 18px;
background: #fff;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.25s;
}
.toggle-switch.active .toggle-knob {
left: 20px;
}
/* ========== 卡片 ========== */ /* ========== 卡片 ========== */
.card { .card {
background: #1a2332; background: #1a2332;
@@ -466,7 +509,7 @@ a:hover { text-decoration: underline; }
object-fit: cover; object-fit: cover;
} }
.camera-img.arm { .camera-img.arm {
transform: rotate(180deg); /* no flip */
} }
.camera-placeholder { .camera-placeholder {
@@ -1191,3 +1234,74 @@ a:hover { text-decoration: underline; }
justify-content: center; justify-content: center;
padding: 16px 0 8px; 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;
}
+34 -1
View File
@@ -20,7 +20,9 @@ createApp({
agvCameraError: false, agvCameraError: false,
hasAgvCamera: false, // AGV 车体是否有可用相机 hasAgvCamera: false, // AGV 车体是否有可用相机
armCameraError: false, armCameraError: false,
reconnectingDevice: null reconnectingDevice: null,
// 环境切换
testMode: true,
} }
}, },
computed: { computed: {
@@ -38,6 +40,7 @@ createApp({
mounted() { mounted() {
this.refresh() this.refresh()
this.refreshCameraCapabilities() this.refreshCameraCapabilities()
this.loadEnvMode()
setInterval(this.refreshStatus, 3000) setInterval(this.refreshStatus, 3000)
this.refreshCams() this.refreshCams()
setInterval(() => this.refreshCams(), 2000) setInterval(() => this.refreshCams(), 2000)
@@ -133,6 +136,36 @@ createApp({
} else { } else {
window.location.href = '/running' window.location.href = '/running'
} }
},
async loadEnvMode() {
try {
const res = await fetch(API + '/api/config/mode')
const data = await res.json()
if (data.ok) {
this.testMode = data.test_mode
} }
} catch (e) {
console.error('加载环境配置失败:', e)
}
},
async toggleEnvMode() {
const newMode = !this.testMode
try {
const res = await fetch(API + '/api/config/mode', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({test_mode: newMode})
})
const data = await res.json()
if (data.ok) {
this.testMode = data.test_mode
alert('已切换至: ' + data.label)
} else {
alert('切换失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('切换请求失败: ' + e.message)
}
},
} }
}).mount('#app') }).mount('#app')
+30
View File
@@ -28,6 +28,7 @@ createApp({
errorMsg: '', errorMsg: '',
waitingStep: false, waitingStep: false,
stepLabel: '', stepLabel: '',
qrMessage: '所有姿态均未识别到二维码,请手动输入:',
// 任务步骤控制开关(机械臂初始化并入AGV移动) // 任务步骤控制开关(机械臂初始化并入AGV移动)
agvMoveEnabled: true, agvMoveEnabled: true,
qrScanEnabled: true, qrScanEnabled: true,
@@ -36,6 +37,8 @@ createApp({
// 速度控制 // 速度控制
agvSpeed: 1.0, agvSpeed: 1.0,
armSpeed: 1000, armSpeed: 1000,
// 查验
inspection: null,
} }
}, },
computed: { computed: {
@@ -51,6 +54,14 @@ createApp({
} }
return map[this.missionState] || '未知' return map[this.missionState] || '未知'
}, },
inspectionTotal() {
if (!this.inspection || !this.inspection.items) return 0
return this.inspection.items.reduce((s, i) => s + (i.inspected || 0), 0)
},
inspectionTarget() {
if (!this.inspection || !this.inspection.items) return 0
return this.inspection.items.reduce((s, i) => s + (i.quantify || 0), 0)
},
}, },
mounted() { mounted() {
this.poll() this.poll()
@@ -101,6 +112,7 @@ createApp({
if (data.grid) this.missionGrid = data.grid if (data.grid) this.missionGrid = data.grid
if (data.point_status) this.pointStatus = data.point_status if (data.point_status) this.pointStatus = data.point_status
if (data.machine_status) this.machineStatus = data.machine_status if (data.machine_status) this.machineStatus = data.machine_status
if (data.inspection) this.inspection = data.inspection
this.armCameraOpened = data.arm_camera_opened this.armCameraOpened = data.arm_camera_opened
if (this.armCameraOpened && !this.armPreviewUrl) { if (this.armCameraOpened && !this.armPreviewUrl) {
this.armPreviewUrl = API + '/api/camera/arm_preview' this.armPreviewUrl = API + '/api/camera/arm_preview'
@@ -122,6 +134,11 @@ createApp({
this.waitingStep = false this.waitingStep = false
} }
// QR 弹窗消息
if (data.qr_message) {
this.qrMessage = data.qr_message
}
// QR 弹窗(防止提交后重复弹出) // QR 弹窗(防止提交后重复弹出)
if (this.missionState !== 'waiting_qr') { if (this.missionState !== 'waiting_qr') {
this.qrSubmitting = false this.qrSubmitting = false
@@ -129,6 +146,9 @@ createApp({
if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) { if (this.missionState === 'waiting_qr' && !this.showQrModal && !this.qrSubmitting) {
this.showQrModal = true this.showQrModal = true
this.qrValue = '' this.qrValue = ''
if (!this.qrMessage) {
this.qrMessage = '所有姿态均未识别到二维码,请手动输入:'
}
} }
// 完成后获取报告 // 完成后获取报告
@@ -156,6 +176,11 @@ createApp({
}, },
async startMission() { async startMission() {
if (this.missionState !== 'idle') return if (this.missionState !== 'idle') return
// 没有设置报关单时阻止启动(后端也会校验,这里提前友好提示)
if (!this.inspection) {
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
return
}
this.logs = [] this.logs = []
this.progress = 0 this.progress = 0
this.report = null this.report = null
@@ -186,6 +211,11 @@ createApp({
}, },
async startSingleStep() { async startSingleStep() {
if (this.missionState !== 'idle') return if (this.missionState !== 'idle') return
// 没有设置报关单时阻止启动(后端会校验,这里提前友好提示)
if (!this.inspection) {
alert('⚠️ 请先在「设置→报关单」中选择报关单并点击「开始查验」')
return
}
this.logs = [] this.logs = []
this.progress = 0 this.progress = 0
this.report = null this.report = null
+65 -7
View File
@@ -82,7 +82,8 @@ createApp({
this.refreshAngles() this.refreshAngles()
this.loadQrConfigs() this.loadQrConfigs()
this.nav2Timer = setInterval(this.refreshNavStatus, 3000) this.nav2Timer = setInterval(this.refreshNavStatus, 3000)
this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now() this.armSnapshotUrl = ""; this.armCameraUrl = API + '/api/camera/arm_preview?t=' + Date.now()
this.armSnapshotUrl = ""; this.armCameraUrl = API + "/api/camera/arm_preview?t=" + Date.now()
}, },
computed: { computed: {
customsTotalPages() { customsTotalPages() {
@@ -1203,12 +1204,11 @@ createApp({
alert('❌ 复位请求失败: ' + e.message) alert('❌ 复位请求失败: ' + e.message)
} }
}, },
},
// ===== 报关单方法 ===== // ===== 报关单方法 =====
async loadCustomsList() { async loadCustomsList() {
this.customsLoading = true this.customsLoading = true
try { try {
const url = API + '/api/customs/list?pageNum=' + this.customsPage + '&pageSize=' + this.customsPageSize 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 res = await fetch(url)
const d = await res.json() const d = await res.json()
if (d.ok && d.data) { if (d.ok && d.data) {
@@ -1236,17 +1236,20 @@ createApp({
} }
}, },
async selectCustomsRow(item) { async selectCustomsRow(item) {
const id = item.id || item.customsId || item.customs_id || '' // 新数据结构: { customs:{id,orderId,..}, orderCode, drawCode }
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
if (!id) return if (!id) return
this.selectedCustomsId = id this.selectedCustomsId = id
this.selectedCustomsName = item.customsNo || item.customs_no || item.name || item.customsName || item.customs_name || id this.selectedCustomsName = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
this.customsMachines = [] this.customsMachines = []
try { try {
const res = await fetch(API + '/api/customs/machines?customsId=' + encodeURIComponent(id)) const url = API + '/api/customs/machines?customsId=' + encodeURIComponent(id)
const res = await fetch(url)
const d = await res.json() const d = await res.json()
if (d.ok && d.data) { if (d.ok && d.data) {
const raw = d.data const raw = d.data
let machines = [] let machines = []
// customsMachines 返回格式: {"code":"0","data":[{serialNumber,inventoryName,...}]}
if (raw.rows) { machines = raw.rows } if (raw.rows) { machines = raw.rows }
else if (raw.records) { machines = raw.records } else if (raw.records) { machines = raw.records }
else if (raw.data && Array.isArray(raw.data)) { machines = raw.data } else if (raw.data && Array.isArray(raw.data)) { machines = raw.data }
@@ -1261,6 +1264,61 @@ createApp({
this.customsMachines = [] this.customsMachines = []
} }
}, },
async startInspection(item) {
const id = (item.customs && item.customs.id) || item.id || item.customsId || item.customs_id || ''
const name = (item.customs && item.customs.customsCode) || item.orderCode || item.drawCode || id
if (!id) return
if (!confirm(`确定要对报关单「${name}」开始查验吗?\n点击确定后,运行页将以该报关单的机器进行查验。`)) return
try {
const res = await fetch(API + '/api/customs/inspection/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customsId: id, customsName: name })
})
const d = await res.json()
if (d.ok) {
alert(`✅ 查验已开始!\n报关单: ${name}\n机型: ${d.inspection.items.length}\n总数: ${d.inspection.items.reduce((s,i)=>s+i.quantify,0)}\n\n请前往「运行」页执行任务。`)
// 同时选中该报关单,显示机器列表
this.selectedCustomsId = id
this.selectedCustomsName = name
// 用 inspection items 填充 customsMachines 显示(聚合后)
this.customsMachines = d.inspection.items.map(it => ({
inventoryCode: it.inventoryCode,
inventoryName: it.inventoryName,
inventorySpecification: it.spec,
serialNumber: '',
quantify: it.quantify,
inspectionCount: it.inspected,
}))
} else {
alert('❌ 开始查验失败: ' + (d.error || '未知错误'))
} }
} catch (e) {
alert('❌ 请求失败: ' + e.message)
}
},
async loadInspectionCounts() {
// 轮询查验计数,更新 customsMachines 的 inspectionCount
try {
const res = await fetch(API + '/api/customs/inspection')
const d = await res.json()
if (d.ok && d.inspection && this.customsMachines.length) {
for (const item of d.inspection.items) {
const match = this.customsMachines.find(m => m.inventoryCode === item.inventoryCode)
if (match) {
match.inspectionCount = item.inspected
}
}
}
} catch (e) {}
},
},
watch: {
tab(newVal) {
if (newVal === 'customs' && this.customsMachines.length > 0) {
// 切换到报关单 tab 时刷新查验计数
this.loadInspectionCounts()
}
}
},
}).mount('#app') }).mount('#app')
+7 -1
View File
@@ -16,7 +16,13 @@
<a href="/setting" class="nav-link">⚙️ 设置</a> <a href="/setting" class="nav-link">⚙️ 设置</a>
<a href="/running" class="nav-link">▶️ 运行</a> <a href="/running" class="nav-link">▶️ 运行</a>
</nav> </nav>
<div class="status-bar"> <div class="status-bar" style="display:flex;align-items:center;gap:12px">
<label class="env-toggle" title="切换测试/正式环境">
<span class="env-label" :class="testMode ? 'test' : 'prod'">{% raw %}{{ testMode ? '🧪 测试' : '🏭 正式' }}{% endraw %}</span>
<div class="toggle-switch" @click="toggleEnvMode" :class="{active: testMode}">
<div class="toggle-knob"></div>
</div>
</label>
<span class="status-item" :class="statusClass"> <span class="status-item" :class="statusClass">
{% raw %}{{ statusText }}{% endraw %} {% raw %}{{ statusText }}{% endraw %}
</span> </span>
+27 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>运行监控 - AGV 拍摄系统</title> <title>运行监控 - AGV 拍摄系统</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260529a"> <link rel="stylesheet" href="/static/css/style.css?v=20260616a">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -48,6 +48,30 @@
</div> </div>
</section> </section>
<!-- 查验进度 -->
<section class="card" v-if="inspection">
<h2>🔍 查验进度 — {% raw %}{{ inspection.customsName }}{% endraw %}</h2>
<p class="hint" style="margin-bottom:12px">
总进度: {% raw %}{{ inspectionTotal }}{% endraw %} / {% raw %}{{ inspectionTarget }}{% endraw %} 台
<span v-if="inspectionTotal >= inspectionTarget && inspectionTarget > 0" style="color:#4caf50;font-weight:bold"> ✅ 已完成</span>
</p>
<div class="inspection-grid">
<div v-for="(item, ii) in inspection.items" :key="ii" class="inspection-item" :class="{ 'insp-done': item.inspected >= item.quantify, 'insp-active': item.inspected > 0 && item.inspected < item.quantify }">
<div class="insp-name">{% raw %}{{ item.inventoryName }}{% endraw %}</div>
<div class="insp-code">{% raw %}{{ item.inventoryCode }}{% endraw %}</div>
<div class="insp-spec">{% raw %}{{ item.spec }}{% endraw %}</div>
<div class="insp-count">
<span class="insp-num">{% raw %}{{ item.inspected }}{% endraw %}</span>
<span class="insp-sep">/</span>
<span class="insp-total">{% raw %}{{ item.quantify }}{% endraw %}</span>
</div>
<div class="insp-bar">
<div class="insp-fill" :style="{width: (item.quantify > 0 ? (item.inspected / item.quantify * 100) : 0) + '%'}"></div>
</div>
</div>
</div>
</section>
<!-- 任务步骤控制开关 --> <!-- 任务步骤控制开关 -->
<section class="card"> <section class="card">
<h2>🎛️ 任务步骤控制</h2> <h2>🎛️ 任务步骤控制</h2>
@@ -202,7 +226,7 @@
<div class="modal-overlay" v-if="showQrModal"> <div class="modal-overlay" v-if="showQrModal">
<div class="modal"> <div class="modal">
<h3>⌨️ 手动输入二维码</h3> <h3>⌨️ 手动输入二维码</h3>
<p>所有姿态均未识别到二维码,请手动输入:</p> <p>{% raw %}{{ qrMessage }}{% endraw %}</p>
<input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr"> <input type="text" v-model="qrValue" placeholder="输入二维码内容" autofocus @keyup.enter="submitQr">
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-success" @click="rescanQr" style="margin-right:auto">🔄 重新扫描</button> <button class="btn btn-success" @click="rescanQr" style="margin-right:auto">🔄 重新扫描</button>
@@ -241,6 +265,6 @@
</div> </div>
<script src="/static/js/vue3.global.prod.js"></script> <script src="/static/js/vue3.global.prod.js"></script>
<script src="/static/js/running.js?v=20260605b"></script> <script src="/static/js/running.js?v=20260616c"></script>
</body> </body>
</html> </html>
+33 -28
View File
@@ -110,7 +110,7 @@
<thead> <thead>
<tr style="background:#1a2332;text-align:left"> <tr style="background:#1a2332;text-align:left">
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th> <th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">ID</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型名称</th> <th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">机型申请时间</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th> <th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">描述</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th> <th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px">备注</th>
<th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th> <th style="padding:10px 12px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:13px;text-align:center">操作</th>
@@ -161,7 +161,7 @@
<div style="margin-top:8px"> <div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_front']" <input type="text" v-model="newPoseForm[m.id + '_front']"
placeholder="姿态名称(如:取料)" placeholder="姿态申请时间(如:取料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px"> style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])"> 添加正面姿态(当前角度)</button> <button class="btn btn-secondary btn-small" @click="addPose(m.id, 'front', newPoseForm[m.id + '_front'])"> 添加正面姿态(当前角度)</button>
</div> </div>
@@ -208,7 +208,7 @@
<div style="margin-top:8px"> <div style="margin-top:8px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" v-model="newPoseForm[m.id + '_back']" <input type="text" v-model="newPoseForm[m.id + '_back']"
placeholder="姿态名称(如:放料)" placeholder="姿态申请时间(如:放料)"
style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px"> style="flex:1;min-width:120px;padding:6px;border:1px solid #2a3441;border-radius:4px">
<button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])"> 添加背面姿态(当前角度)</button> <button class="btn btn-secondary btn-small" @click="addPose(m.id, 'back', newPoseForm[m.id + '_back'])"> 添加背面姿态(当前角度)</button>
</div> </div>
@@ -225,7 +225,7 @@
<button class="btn-icon" @click="showAddModelModal = false"></button> <button class="btn-icon" @click="showAddModelModal = false"></button>
</div> </div>
<div class="form-group" style="margin-bottom:12px"> <div class="form-group" style="margin-bottom:12px">
<label>机型名称</label> <label>机型申请时间</label>
<input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px"> <input type="text" v-model="newModelName" placeholder="例如:SMT-A" style="width:100%;padding:8px;border:1px solid #2a3441;border-radius:4px">
</div> </div>
<div class="form-group" style="margin-bottom:12px"> <div class="form-group" style="margin-bottom:12px">
@@ -445,7 +445,7 @@
</button> </button>
</div> </div>
<div style="display:flex;justify-content:flex-end;margin-bottom:16px"> <div style="display:flex;justify-content:flex-end;margin-bottom:16px">
<input type="text" v-model="newQrName" placeholder="输入名称..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px"> <input type="text" v-model="newQrName" placeholder="输入申请时间..." style="background:#0f1923;border:1px solid #2a3441;color:#fff;padding:8px 12px;border-radius:6px;margin-right:8px;width:180px">
<button class="btn btn-primary" @click="addQrConfig()"> 添加</button> <button class="btn btn-primary" @click="addQrConfig()"> 添加</button>
</div> </div>
<div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px"> <div v-if="qrConfigs.length === 0" style="text-align:center;color:#9aa0a6;padding:40px">
@@ -454,7 +454,7 @@
<table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px"> <table v-else style="width:100%;border-collapse:collapse;margin-bottom:16px">
<thead> <thead>
<tr style="background:#1a2332;text-align:left"> <tr style="background:#1a2332;text-align:left">
<th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">名称</th> <th style="padding:10px 8px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">申请时间</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th> <th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J1</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th> <th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J2</th>
<th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th> <th style="padding:10px 4px;border-bottom:1px solid #2a3441;color:#9aa0a6;font-size:12px">J3</th>
@@ -606,30 +606,33 @@
<tr> <tr>
<th style="width:60px">序号</th> <th style="width:60px">序号</th>
<th>报关单号</th> <th>报关单号</th>
<th>名称</th> <th>申请时间</th>
<th>状态</th> <th>状态</th>
<th>机器数</th> <th>机器数</th>
<th style="width:80px">操作</th> <th style="width:80px">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, idx) in customsPageData" :key="item.id || idx" <tr v-for="(item, idx) in customsPageData" :key="(item.customs && item.customs.id) || item.id || idx"
class="clickable-row" class="clickable-row"
:class="{ 'row-selected': selectedCustomsId === (item.id || item.customsId || item.customs_id) }" :class="{ 'row-selected': selectedCustomsId === ((item.customs && item.customs.id) || item.id || item.customsId || item.customs_id) }"
@click="selectCustomsRow(item, idx)"> @click="selectCustomsRow(item)">
<td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td> <td>{% raw %}{{ (customsPage - 1) * customsPageSize + idx + 1 }}{% endraw %}</td>
<td><strong>{% raw %}{{ item.customsNo || item.customs_no || item.id || '-' }}{% endraw %}</strong></td> <td><strong>{% raw %}{{ (item.customs && item.customs.customsCode) || item.orderCode || (item.customs && item.customs.id) || '-' }}{% endraw %}</strong></td>
<td>{% raw %}{{ item.name || item.customsName || item.customs_name || '-' }}{% endraw %}</td> <td>{% raw %}{{ item.orderCode || item.drawCode || '-' }}{% endraw %}</td>
<td><span class="badge" :class="'badge-' + (item.status || 'unknown')">{% raw %}{{ item.status || '未知' }}{% endraw %}</span></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.machineCount || item.machine_count || item.machineNum || item.machine_num || '?' }}{% endraw %}</td> <td>{% raw %}{{ (item.customs && item.customs.orderId) ? item.customs.orderId.split(',').length : '?' }}{% endraw %}</td>
<td> <td>
<button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item, idx)"> <button class="btn btn-small btn-primary" @click.stop="selectCustomsRow(item)">
📦 查看机器 📦 查看机器
</button> </button>
<button class="btn btn-small btn-success" style="margin-left:4px" @click.stop="startInspection(item)">
🔍 开始查验
</button>
</td> </td>
</tr> </tr>
<tr v-if="!customsPageData.length && !customsLoading"> <tr v-if="!customsPageData.length && !customsLoading">
<td colspan="6" style="text-align:center;color:#8899aa;padding:24px">暂无报关单数据</td> <td colspan="7" style="text-align:center;color:#8899aa;padding:24px">暂无报关单数据</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -657,21 +660,23 @@
<thead> <thead>
<tr> <tr>
<th style="width:50px">序号</th> <th style="width:50px">序号</th>
<th>机器编号</th> <th>物料编码</th>
<th>机器名称</th> <th>物料名称</th>
<th>型号</th> <th>规格</th>
<th>二维码值</th> <th>序列号</th>
<th>状态</th> <th>数量</th>
<th>查验数量</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(m, mi) in customsMachines" :key="mi"> <tr v-for="(m, mi) in customsMachines" :key="mi">
<td>{% raw %}{{ mi + 1 }}{% endraw %}</td> <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><strong>{% raw %}{{ m.inventoryCode || m.machineCode || '-' }}{% endraw %}</strong></td>
<td>{% raw %}{{ m.name || m.machineName || m.machine_name || m.model || '-' }}{% endraw %}</td> <td>{% raw %}{{ m.inventoryName || m.machineName || m.name || '-' }}{% endraw %}</td>
<td>{% raw %}{{ m.modelName || m.model_name || m.model || '-' }}{% endraw %}</td> <td>{% raw %}{{ m.inventorySpecification || m.spec || '-' }}{% 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 style="font-family:monospace;color:#4fc3f7">{% raw %}{{ m.serialNumber || m.serialNumbers || m.qrValue || '-' }}{% endraw %}</td>
<td><span class="badge" :class="'badge-' + (m.status || 'normal')">{% raw %}{{ m.status || '正常' }}{% endraw %}</span></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> </tr>
</tbody> </tbody>
</table> </table>
@@ -697,7 +702,7 @@
</div> </div>
<!-- 机械臂截图弹窗 --> <!-- 机械臂截图弹窗 -->
<div class="modal-overlay" v-if="showArmSnapshot" @click.self="showArmSnapshot = false"> <div class="modal-overlay" v-if="showArmSnapshot && armSnapshotUrl" @click.self="showArmSnapshot = false">
<div class="modal" style="max-width:800px"> <div class="modal" style="max-width:800px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3>📸 机械臂摄像头截图</h3> <h3>📸 机械臂摄像头截图</h3>
@@ -713,6 +718,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=20260612a"></script> <script src="/static/js/setting.js?v=20260616f"></script>
</body> </body>
</html> </html>
+78 -16
View File
@@ -28,10 +28,10 @@ from utils.nav2_navigator import Nav2Navigator, Nav2Status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash" ROS2_SETUP_CMD = "source /opt/ros/humble/setup.bash && source ~/agv_pro_ros2/install/setup.bash"
from config import ARM_CAMERA_CONFIG from config import ARM_CAMERA_CONFIG, UPLOAD_CONFIG, ZHIJIAN_AUTH_TOKEN
ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"] ARM_CAMERA_SNAPSHOT = ARM_CAMERA_CONFIG["snapshot_url"]
PHOTOS_DIR = "/home/elephant/photos" PHOTOS_DIR = "/home/elephant/photos"
UPLOAD_URL = "https://ts.zhijian168.com/prod-api/file/uploadImage" # UPLOAD_CONFIG["url"] 随环境切换动态变化,每次使用时直接读取
# 二维码扫描重试参数 # 二维码扫描重试参数
QR_SCAN_TIMEOUT = 5 # 单次扫描超时 QR_SCAN_TIMEOUT = 5 # 单次扫描超时
@@ -719,6 +719,7 @@ class MissionExecutorV3:
try: try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT) resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=QR_SCAN_TIMEOUT)
if resp.status_code != 200 or not resp.content: if resp.status_code != 200 or not resp.content:
self._log(f" 📷 arm snapshot attempt {attempt+1}: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
continue continue
arr = np.frombuffer(resp.content, dtype=np.uint8) arr = np.frombuffer(resp.content, dtype=np.uint8)
@@ -735,12 +736,17 @@ class MissionExecutorV3:
time.sleep(0.5) time.sleep(0.5)
return None return None
def _request_manual_qr(self) -> Optional[str]: def _request_manual_qr(self, message: str = None) -> Optional[str]:
"""暂停任务,等待手动输入(支持重新扫描)""" """暂停任务,等待手动输入(支持重新扫描)
message: 自定义弹窗消息(None 则使用默认消息)"""
while True: while True:
self.status = MissionStatus.WAITING_QR self.status = MissionStatus.WAITING_QR
self.report["status"] = "waiting_qr" self.report["status"] = "waiting_qr"
self.report["step"] = "等待手动输入二维码" self.report["step"] = message or "等待手动输入二维码"
self.report["qr_message"] = message or "所有姿态均未识别到二维码,请手动输入:"
if message:
self._log(f" ⌨️ {message}")
else:
self._log(" ⌨️ 弹窗等待手动输入二维码...") self._log(" ⌨️ 弹窗等待手动输入二维码...")
self._qr_event.clear() self._qr_event.clear()
@@ -773,8 +779,61 @@ class MissionExecutorV3:
# ==================== 机型查询 ==================== # ==================== 机型查询 ====================
def _lookup_model(self, qr_value: Optional[str]) -> str: def _lookup_model(self, qr_value: Optional[str]) -> str:
"""TODO: 后续通过 HTTP 接口查询机型""" """通过 /api/customs/printer 接口查询机型,同时更新查验计数
如果机型不在当前报关单中,弹窗要求重新扫码/输入"""
if not qr_value:
return "机器1" 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:
self._log(f" 🏷️ 机型: {model} (物料:{inv_code}) — 查验 {matched['inspected']}/{matched['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 new_qr is None or 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 失败也弹窗
self._log(f" ⚠️ 查询机型失败, HTTP {resp.status_code}")
new_qr = self._request_manual_qr(
f"无法查询二维码「{qr_value}」对应的机型,\n请重新扫描或手动输入正确的二维码:"
)
if new_qr is None or 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 new_qr is None or self._stop.is_set():
return "机器1"
qr_value = new_qr
@staticmethod @staticmethod
def _find_model(models: list, name: str) -> Optional[dict]: def _find_model(models: list, name: str) -> Optional[dict]:
@@ -852,7 +911,7 @@ class MissionExecutorV3:
try: try:
resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10) resp = requests.get(ARM_CAMERA_SNAPSHOT, timeout=10)
if resp.status_code != 200 or not resp.content: if resp.status_code != 200 or not resp.content:
logger.error("arm snapshot 请求失败") self._log(f" 📷 拍照 arm snapshot 失败: HTTP {resp.status_code}, size={len(resp.content) if resp.content else 0}")
return None return None
# 生成文件名(用于上传) # 生成文件名(用于上传)
@@ -861,13 +920,14 @@ class MissionExecutorV3:
# 直接上传到服务器(不保存本地) # 直接上传到服务器(不保存本地)
if qr_value: if qr_value:
self._log(f" 📷 拍照成功 {len(resp.content)} bytes → {fname}")
self._upload_photo_bytes(fname, resp.content, qr_value, upload_index) self._upload_photo_bytes(fname, resp.content, qr_value, upload_index)
else: else:
self._log(" ⚠️ 无二维码,跳过上传") self._log(" ⚠️ 无二维码,跳过上传")
return fname # 返回文件名(用于日志) return fname # 返回文件名(用于日志)
except Exception as e: except Exception as e:
logger.error(f"拍照异常: {e}") self._log(f"拍照异常: {e}")
return None return None
def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool: def _upload_photo(self, filepath: str, serial_number: str, index: int) -> bool:
@@ -880,13 +940,14 @@ class MissionExecutorV3:
""" """
try: try:
filename = os.path.basename(filepath) filename = os.path.basename(filepath)
headers = { headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA" upload_url = UPLOAD_CONFIG["url"]
} self._log(f" 📤 上传请求 → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
files = {"file": (filename, f, "image/jpeg")} files = {"file": (filename, f, "image/jpeg")}
data = {"serialNumber": serial_number, "index": str(index)} data = {"serialNumber": serial_number, "index": str(index)}
resp = requests.post(UPLOAD_URL, files=files, data=data, headers=headers, timeout=30) resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 200: if resp.status_code == 200:
self._log(f" ☁️ 上传成功 [{index}]: {filename}") self._log(f" ☁️ 上传成功 [{index}]: {filename}")
return True return True
@@ -907,12 +968,13 @@ class MissionExecutorV3:
index: 上传序号(从1开始递增) index: 上传序号(从1开始递增)
""" """
try: try:
headers = { headers = {"Authorization": ZHIJIAN_AUTH_TOKEN}
"Authorization": "Bearer eyJhbGciOiJIUzUxMiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX2tleSI6ImZhNTNkZTZiLWE3NjYtNDZmNC05MDUyLTQ2MjUzZTAyNjdmNSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.lC4vKThZo4aAOLsekm2kPgaEJRqRx-YDQWKfHFqxdPNESCKy57l3eIqaKTj2ZjAMaoYAwYlMrv5M1zAOJsO_PA" upload_url = UPLOAD_CONFIG["url"]
} self._log(f" 📤 上传请求(内存) → {upload_url} | serialNumber={serial_number} | index={index} | file={filename}")
files = {"file": (filename, image_data, "image/jpeg")} files = {"file": (filename, image_data, "image/jpeg")}
data = {"serialNumber": serial_number, "index": str(index)} data = {"serialNumber": serial_number, "index": str(index)}
resp = requests.post(UPLOAD_URL, files=files, data=data, headers=headers, timeout=30) resp = requests.post(upload_url, files=files, data=data, headers=headers, timeout=30)
self._log(f" 📡 上传响应 HTTP {resp.status_code}: {resp.text[:300]}")
if resp.status_code == 200: if resp.status_code == 200:
self._log(f" ☁️ 上传成功 [{index}]: {filename}") self._log(f" ☁️ 上传成功 [{index}]: {filename}")
return True return True