Rename customs-tablet-frontend to public-frontend and add new features

- Rename customs-tablet-frontend/ to public-frontend/ for broader scope
- Add new pages: customs, inspection with camera integration
- Add new services: apiClient.ts, backendApi.ts, normalizers.ts
- Add CameraFrame component for real-time video streaming
- Add scan_fixer module with clock_publisher and timestamp fix utilities
- Update startup scripts to support new frontend structure
- Update arm_server configuration and service files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 10:18:20 +08:00
parent 083d12016a
commit 1429442dbd
49 changed files with 2758 additions and 2141 deletions
+143 -16
View File
@@ -1,15 +1,20 @@
""" """
二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber 二维码识别模块 - 使用 AGV 摄像头识别二维码获取 serialNumber
优先通过 v4l2-ctl 读取 MJPG 帧,避开部分 Jetson/arm64 环境中 OpenCV
直接读取 MJPG 花屏或解码失败的问题;v4l2-ctl 不可用时回退到 OpenCV。
""" """
import cv2 import cv2
import time import time
import logging import logging
import os
import subprocess
import numpy as np import numpy as np
from typing import Optional, Tuple from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 尝试导入二维码识别库
try: try:
from pyzbar.pyzbar import decode as qr_decode from pyzbar.pyzbar import decode as qr_decode
PYZBAR_AVAILABLE = True PYZBAR_AVAILABLE = True
@@ -19,17 +24,76 @@ except ImportError:
class QRScanner: class QRScanner:
"""二维码扫描器""" """二维码扫描器"""
def __init__(self, device_index: int = 0): V4L2_CTL = "/usr/bin/v4l2-ctl"
DEFAULT_WIDTH = 640
DEFAULT_HEIGHT = 400
V4L2_STREAM_COUNT = 3
V4L2_READ_TIMEOUT = 5.0
OPENCV_READ_TIMEOUT = 2.0
def __init__(
self,
device_index: int = 0,
width: int = DEFAULT_WIDTH,
height: int = DEFAULT_HEIGHT,
prefer_v4l2_ctl: bool = True,
):
self.device_index = device_index self.device_index = device_index
self.width = width
self.height = height
self.prefer_v4l2_ctl = prefer_v4l2_ctl
self._cap: Optional[cv2.VideoCapture] = None self._cap: Optional[cv2.VideoCapture] = None
self._qr_detector = cv2.QRCodeDetector() # OpenCV 内置二维码检测器 self._v4l2_ctl_ready = False
self._qr_detector = cv2.QRCodeDetector()
def _device_path(self) -> str:
return f"/dev/video{self.device_index}"
def _build_v4l2_cmd(self, stream_count: int = V4L2_STREAM_COUNT) -> list:
return [
self.V4L2_CTL,
"-d", self._device_path(),
"--set-fmt-video",
f"width={self.width},height={self.height},pixelformat=MJPG",
"--stream-mmap",
"--stream-to=-",
f"--stream-count={stream_count}",
]
def _check_v4l2_ctl(self) -> bool:
if not self.prefer_v4l2_ctl:
return False
if not os.path.exists(self._device_path()):
logger.warning(f"摄像头设备 {self._device_path()} 不存在,回退到 OpenCV")
return False
try:
subprocess.run(
[self.V4L2_CTL, "--version"],
capture_output=True,
timeout=2,
check=False,
)
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
logger.warning(f"v4l2-ctl 不可用: {self.V4L2_CTL},回退到 OpenCV")
return False
def open(self) -> bool: def open(self) -> bool:
"""打开摄像头""" """打开摄像头"""
self.close()
self._v4l2_ctl_ready = self._check_v4l2_ctl()
if self._v4l2_ctl_ready:
logger.info(
f"摄像头 {self._device_path()} 使用 v4l2-ctl MJPG 读取,"
f"分辨率 {self.width}x{self.height}"
)
return True
return self._open_opencv_capture()
def _open_opencv_capture(self) -> bool:
try: try:
# 强制 V4L2 后端
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2) self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2)
if not self._cap.isOpened(): if not self._cap.isOpened():
self._cap = cv2.VideoCapture(self.device_index) self._cap = cv2.VideoCapture(self.device_index)
@@ -38,12 +102,10 @@ class QRScanner:
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) self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
# 设置分辨率(使用默认分辨率,不强制 MJPG)
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
logger.info(f"摄像头 {self.device_index} 已打开,分辨率 {w}x{h}") logger.info(f"摄像头 {self.device_index} 使用 OpenCV 读取,分辨率 {w}x{h}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"摄像头打开失败: {e}") logger.error(f"摄像头打开失败: {e}")
@@ -53,6 +115,47 @@ class QRScanner:
if self._cap: if self._cap:
self._cap.release() self._cap.release()
self._cap = None self._cap = None
self._v4l2_ctl_ready = False
@staticmethod
def _extract_first_jpeg(data: bytes) -> Optional[bytes]:
"""从 v4l2-ctl 输出流中提取第一帧完整 JPEG。"""
soi = data.find(b"\xff\xd8")
if soi == -1:
return None
eoi = data.find(b"\xff\xd9", soi + 2)
if eoi == -1 or eoi <= soi:
return None
return data[soi:eoi + 2]
def _read_frame_with_v4l2_ctl(self) -> Optional[np.ndarray]:
try:
proc = subprocess.run(
self._build_v4l2_cmd(),
capture_output=True,
timeout=self.V4L2_READ_TIMEOUT,
check=False,
)
jpeg_data = self._extract_first_jpeg(proc.stdout)
if jpeg_data is None or len(jpeg_data) < 100:
return None
frame = cv2.imdecode(
np.frombuffer(jpeg_data, dtype=np.uint8),
cv2.IMREAD_COLOR,
)
if frame is None:
return None
if frame.mean() < 3 or frame.mean() > 250:
return None
return frame
except subprocess.TimeoutExpired:
logger.warning("v4l2-ctl 读取超时")
return None
except Exception as e:
logger.warning(f"v4l2-ctl 读取异常: {e}")
return None
def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]: def _fix_frame(self, frame: np.ndarray) -> Optional[np.ndarray]:
"""修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None""" """修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None"""
@@ -101,13 +204,10 @@ class QRScanner:
# 正常 BGR 帧 # 正常 BGR 帧
return frame return frame
def read_frame(self, timeout: float = 2.0) -> Optional[np.ndarray]: def _read_frame_with_opencv(self, timeout: float) -> Optional[np.ndarray]:
"""读取一帧(带超时保护,避免 V4L2 select() 永久阻塞)"""
if not self._cap or not self._cap.isOpened(): if not self._cap or not self._cap.isOpened():
return None return None
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
pool = ThreadPoolExecutor(max_workers=1) pool = ThreadPoolExecutor(max_workers=1)
try: try:
fut = pool.submit(self._cap.read) fut = pool.submit(self._cap.read)
@@ -131,17 +231,43 @@ class QRScanner:
finally: finally:
pool.shutdown(wait=False) pool.shutdown(wait=False)
def read_frame(self, timeout: float = OPENCV_READ_TIMEOUT) -> Optional[np.ndarray]:
"""读取一帧。"""
if self._v4l2_ctl_ready:
frame = self._read_frame_with_v4l2_ctl()
if frame is not None:
return frame
logger.debug("v4l2-ctl 未读到有效帧,尝试 OpenCV 兜底")
if not self._cap or not self._cap.isOpened():
self._open_opencv_capture()
return self._read_frame_with_opencv(timeout)
def detect_qr(self, frame: np.ndarray) -> Optional[str]: def detect_qr(self, frame: np.ndarray) -> Optional[str]:
"""从图像帧中检测二维码""" """从图像帧中检测二维码"""
if frame is None: if frame is None:
return None return None
if len(frame.shape) == 2:
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
try: try:
# OpenCV 内置二维码检测
data, vertices, _ = self._qr_detector.detectAndDecode(frame) data, vertices, _ = self._qr_detector.detectAndDecode(frame)
if data and len(data) > 0: if data and len(data) > 0:
return data.strip() return data.strip()
except Exception as e: except Exception as e:
logger.debug(f"二维码检测失败: {e}") logger.debug(f"OpenCV QR 检测失败: {e}")
if PYZBAR_AVAILABLE:
try:
results = qr_decode(frame)
for res in results:
data = res.data.decode("utf-8").strip()
if data:
return data
except Exception as e:
logger.debug(f"pyzbar 检测失败: {e}")
return None return None
def scan_once(self) -> Optional[str]: def scan_once(self) -> Optional[str]:
@@ -152,6 +278,7 @@ class QRScanner:
def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]: def scan_with_retry(self, max_attempts: int = 5, interval: float = 0.5) -> Optional[str]:
"""多次扫描直到成功或达到最大次数""" """多次扫描直到成功或达到最大次数"""
for i in range(max_attempts): for i in range(max_attempts):
logger.debug(f"QR 扫描尝试 {i + 1}/{max_attempts}")
result = self.scan_once() result = self.scan_once()
if result: if result:
return result return result
+2 -2
View File
@@ -211,7 +211,7 @@ local_costmap:
mark_threshold: 0 mark_threshold: 0
observation_sources: scan observation_sources: scan
scan: scan:
topic: /scan_corrected_corrected topic: /scan_corrected
max_obstacle_height: 2.0 max_obstacle_height: 2.0
clearing: True clearing: True
marking: True marking: True
@@ -247,7 +247,7 @@ global_costmap:
enabled: True enabled: True
observation_sources: scan observation_sources: scan
scan: scan:
topic: /scan_corrected_corrected topic: /scan_corrected
max_obstacle_height: 2.0 max_obstacle_height: 2.0
clearing: True clearing: True
marking: True marking: True
-408
View File
@@ -1,408 +0,0 @@
"""
机械臂服务端 - 机械臂端主程序
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot)
同时通过 ffmpeg 提供 HTTP 视频流
"""
import socket
import threading
import time
import logging
import os
import sys
import subprocess
import io
from PIL import Image
from flask import Flask, Response, jsonify
from werkzeug.serving import make_server
# 添加当前目录到路径
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, BASE_DIR)
LOG_FILE = os.environ.get("ARM_SERVER_LOG_FILE", os.path.join(BASE_DIR, "server.log"))
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_FILE)
]
)
logger = logging.getLogger("arm_server")
# ========== Flask HTTP 服务器 - 视频流 (ffmpeg) ==========
arm_video_app = Flask(__name__)
ARM_CAMERA_INDEX = 0 # 机械臂端摄像头设备号
_ffmpeg_proc = None
_ffmpeg_thread = None
_ffmpeg_lock = threading.Lock()
_frame_cond = threading.Condition()
_latest_frame = None
_latest_frame_ts = 0.0
_stop_ffmpeg_reader = threading.Event()
_invalid_count = 0 # 连续无效帧计数
_MAX_INVALID = 30 # 连续 30 帧无效 → 重启 ffmpeg
_MAX_BUF_SIZE = 2 * 1024 * 1024 # 2MB buffer 上限
def _validate_jpeg(data):
"""验证 JPEG 数据是否有效,返回 True/False"""
try:
Image.open(io.BytesIO(data)).verify()
return True
except Exception:
return False
def _stop_ffmpeg():
"""停止 ffmpeg 采集进程和读帧线程。"""
global _ffmpeg_proc
_stop_ffmpeg_reader.set()
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate()
try:
_ffmpeg_proc.wait(timeout=2)
except subprocess.TimeoutExpired:
_ffmpeg_proc.kill()
_ffmpeg_proc = None
def _frame_reader():
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,校验有效性并缓存最新一帧。
当摄像头 USB 掉线重连时,ffmpeg 会从失效 fd 读取垃圾数据,
产生假 JPEG 帧(花屏)。这里通过 PIL 校验帧有效性,
连续无效帧过多时自动重启 ffmpeg 恢复。
"""
global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
buf = b""
while not _stop_ffmpeg_reader.is_set():
proc = _ffmpeg_proc
if proc is None or proc.poll() is not None or proc.stdout is None:
time.sleep(0.1)
continue
chunk = proc.stdout.read(8192)
if not chunk:
if proc.poll() is not None:
break
time.sleep(0.02)
continue
buf += chunk
# 防止垃圾数据撑爆内存
if len(buf) > _MAX_BUF_SIZE:
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
buf = b""
_stop_ffmpeg()
continue
while True:
start = buf.find(b"\xff\xd8")
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
if start < 0:
buf = buf[-2:]
break
if end < 0:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
# JPEG 校验:摄像头掉线时帧数据会损坏
if _validate_jpeg(frame):
with _frame_cond:
_latest_frame = frame
_latest_frame_ts = time.time()
_frame_cond.notify_all()
_invalid_count = 0
else:
_invalid_count += 1
if _invalid_count >= _MAX_INVALID:
logger.error(f"连续 {_MAX_INVALID} 帧无效,摄像头可能掉线,重启 ffmpeg")
_stop_ffmpeg()
_invalid_count = 0
break # 跳出循环让 _ensure_ffmpeg 重建
def _ensure_ffmpeg():
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
with _ffmpeg_lock:
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return
_stop_ffmpeg_reader.set()
if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate()
_stop_ffmpeg_reader.clear()
_invalid_count = 0 # 重置错误计数
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen(
[
"ffmpeg",
"-f", "v4l2",
"-input_format", "mjpeg",
"-framerate", "8",
"-video_size", "640x480",
"-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-fflags", "nobuffer",
"-analyzeduration", "0",
"-f", "mjpeg",
"-"
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
_ffmpeg_thread = threading.Thread(target=_frame_reader, daemon=True)
_ffmpeg_thread.start()
def _get_latest_frame(timeout: float = 3.0):
"""返回缓存的最新 JPEG 帧;必要时等待首帧。"""
_ensure_ffmpeg()
deadline = time.time() + timeout
with _frame_cond:
while _latest_frame is None and time.time() < deadline:
_frame_cond.wait(timeout=0.2)
return _latest_frame
@arm_video_app.route("/api/camera/preview")
def arm_camera_preview():
"""机械臂摄像头 MJPEG 流,共用后台 ffmpeg 采集进程。"""
_ensure_ffmpeg()
def generate():
last_ts = 0.0
try:
while True:
frame = _get_latest_frame(timeout=3.0)
if frame is None:
logger.warning("等待摄像头帧超时,重启 ffmpeg")
_stop_ffmpeg()
continue
with _frame_cond:
if _latest_frame_ts <= last_ts:
_frame_cond.wait(timeout=1.0)
frame = _latest_frame
last_ts = _latest_frame_ts
if frame:
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
except Exception as e:
logger.error(f"视频流异常: {e}")
finally:
logger.info("视频流连接关闭")
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
@arm_video_app.route("/api/camera/status")
def arm_camera_status():
"""摄像头状态"""
global _ffmpeg_proc
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
return jsonify({
"opened": running,
"frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None,
"invalid_count": _invalid_count
})
@arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart():
"""重启视频流"""
global _latest_frame, _latest_frame_ts, _invalid_count
_stop_ffmpeg()
with _frame_cond:
_latest_frame = None
_latest_frame_ts = 0.0
_invalid_count = 0
_ensure_ffmpeg()
return jsonify({"ok": True})
@arm_video_app.route("/api/camera/snapshot")
def arm_camera_snapshot():
"""机械臂摄像头单帧 JPEG,从常驻视频流缓存读取最新帧。"""
frame = _get_latest_frame(timeout=3.0)
if frame:
r = Response(frame, mimetype="image/jpeg")
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
return r
logger.warning("snapshot failed: no cached frame")
return "", 500
# ========== TCP 服务器 - 接收 AGV 指令 ==========
class AGVCommandServer:
"""TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
self.host = host
self.port = port
self._sock: socket.socket = None
self._running = False
# 直接从外部注入已激活的 ElephantRobot 实例
if elephant is None:
logger.warning("ElephantRobot 实例为空,命令将返回错误")
self._el = elephant
def start(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._sock.bind((self.host, self.port))
self._sock.listen(5)
self._running = True
logger.info(f"=" * 50)
logger.info(f"机械臂服务端已启动,监听 {self.host}:{self.port}")
logger.info(f"等待 AGV 连接...")
logger.info(f"=" * 50)
while self._running:
try:
self._sock.settimeout(1.0)
try:
client_sock, addr = self._sock.accept()
logger.info(f"AGV 已连接: {addr}")
threading.Thread(target=self._handle_client, args=(client_sock,), daemon=True).start()
except socket.timeout:
continue
except Exception as e:
if self._running:
logger.error(f"服务器异常: {e}")
break
def _handle_client(self, client_sock: socket.socket):
try:
client_sock.settimeout(30)
buffer = ""
while self._running:
try:
data = client_sock.recv(4096)
if not data:
break
buffer += data.decode("utf-8")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
response = self._execute_command(line)
client_sock.sendall((response + "\n").encode("utf-8"))
logger.info(f"CMD: {line}{response}")
except socket.timeout:
continue
except Exception as e:
logger.error(f"客户端处理异常: {e}")
finally:
client_sock.close()
logger.info("AGV 客户端已断开")
def _execute_command(self, cmd: str) -> str:
"""通过 ElephantRobot.send_command 转发给 RoboFlow"""
if self._el is None:
return "ERROR: Robot not initialized"
try:
return self._el.send_command(cmd)
except Exception as e:
return f"ERROR: {e}"
def stop(self):
self._running = False
if self._sock:
try:
self._sock.close()
except:
pass
logger.info("机械臂服务端已停止")
# ========== 入口 ==========
_elephant = None # 全局 ElephantRobot 实例
def power_on_arm(max_retries: int = 5) -> bool:
"""通过 ElephantRobot 给机械臂上电并激活(带重试),返回 ElephantRobot 实例"""
global _elephant
from pymycobot import ElephantRobot
for attempt in range(1, max_retries + 1):
try:
logger.info(f"正在通过 ElephantRobot 连接 RoboFlow (尝试 {attempt}/{max_retries})...")
el = ElephantRobot("127.0.0.1", 5001)
el.start_client()
logger.info("ElephantRobot start_client 成功,等待2秒...")
time.sleep(2)
el._power_on()
logger.info("power_on 指令已发送,等待2秒...")
time.sleep(2)
el.start_robot()
logger.info("start_robot 指令已发送,等待5秒...")
time.sleep(5)
logger.info("✅ 机械臂上电+激活 全部完成")
# 保存到全局,确保后续复用
_elephant = el
return True
except Exception as e:
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
if attempt < max_retries:
logger.info(f"等待 3 秒后重试...")
time.sleep(3)
else:
logger.error(f"❌ 所有 {max_retries} 次尝试均失败,将以 limited 模式运行")
return False
return False
def main():
import signal
# 先通过 ElephantRobot 给机械臂上电并激活
power_on_arm()
# 将全局 _elephant 传给指令服务器
server = AGVCommandServer(_elephant, port=5002)
# 启动 Flask 视频流服务(端口 5003)
arm_server_http = None
for attempt in range(5):
try:
arm_server_http = make_server("0.0.0.0", 5003, arm_video_app, threaded=True)
break
except OSError as e:
if attempt < 4 and "Address already in use" in str(e):
logger.warning(f"端口 5003 被占用(第{attempt+1}次),等待...")
time.sleep(3)
else:
raise
http_thread = threading.Thread(target=arm_server_http.serve_forever, daemon=True)
http_thread.start()
logger.info("机械臂视频流服务已启动: http://0.0.0.0:5003")
def signal_handler(sig, frame):
logger.info("收到停止信号...")
global _ffmpeg_proc, _elephant
if _ffmpeg_proc:
_ffmpeg_proc.terminate()
server.stop()
arm_server_http.shutdown()
if _elephant:
try:
_elephant.stop_client()
except:
pass
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
server.start()
if __name__ == "__main__":
main()
+62 -79
View File
@@ -1,7 +1,7 @@
""" """
机械臂服务端 - 机械臂端主程序 机械臂服务端 - 机械臂端主程序
运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流) 运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流)
通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (630 Socket API) 通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot)
同时通过 ffmpeg 提供 HTTP 视频流 同时通过 ffmpeg 提供 HTTP 视频流
""" """
import socket import socket
@@ -11,6 +11,8 @@ import logging
import os import os
import sys import sys
import subprocess import subprocess
import io
from PIL import Image
from flask import Flask, Response, jsonify from flask import Flask, Response, jsonify
from werkzeug.serving import make_server from werkzeug.serving import make_server
@@ -41,6 +43,19 @@ _frame_cond = threading.Condition()
_latest_frame = None _latest_frame = None
_latest_frame_ts = 0.0 _latest_frame_ts = 0.0
_stop_ffmpeg_reader = threading.Event() _stop_ffmpeg_reader = threading.Event()
_invalid_count = 0
_MAX_INVALID = 30
_MAX_BUF_SIZE = 2 * 1024 * 1024
_elephant = None
def _validate_jpeg(data):
"""验证 JPEG 数据是否有效。"""
try:
Image.open(io.BytesIO(data)).verify()
return True
except Exception:
return False
def _stop_ffmpeg(): def _stop_ffmpeg():
@@ -57,8 +72,8 @@ def _stop_ffmpeg():
def _frame_reader(): def _frame_reader():
"""从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,并缓存最新一帧。""" """从 ffmpeg 的连续 MJPEG 输出中解析、校验并缓存最新一帧。"""
global _ffmpeg_proc, _latest_frame, _latest_frame_ts global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count
buf = b"" buf = b""
while not _stop_ffmpeg_reader.is_set(): while not _stop_ffmpeg_reader.is_set():
proc = _ffmpeg_proc proc = _ffmpeg_proc
@@ -72,6 +87,11 @@ def _frame_reader():
time.sleep(0.02) time.sleep(0.02)
continue continue
buf += chunk buf += chunk
if len(buf) > _MAX_BUF_SIZE:
logger.warning(f"frame buffer 超过 {_MAX_BUF_SIZE} 字节,丢弃并重启 ffmpeg")
buf = b""
_stop_ffmpeg()
continue
while True: while True:
start = buf.find(b"\xff\xd8") start = buf.find(b"\xff\xd8")
end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1 end = buf.find(b"\xff\xd9", start + 2) if start >= 0 else -1
@@ -83,15 +103,24 @@ def _frame_reader():
break break
frame = buf[start:end + 2] frame = buf[start:end + 2]
buf = buf[end + 2:] buf = buf[end + 2:]
if _validate_jpeg(frame):
with _frame_cond: with _frame_cond:
_latest_frame = frame _latest_frame = frame
_latest_frame_ts = time.time() _latest_frame_ts = time.time()
_frame_cond.notify_all() _frame_cond.notify_all()
_invalid_count = 0
else:
_invalid_count += 1
if _invalid_count >= _MAX_INVALID:
logger.error(f"连续 {_MAX_INVALID} 帧无效,摄像头可能掉线,重启 ffmpeg")
_stop_ffmpeg()
_invalid_count = 0
break
def _ensure_ffmpeg(): def _ensure_ffmpeg():
"""确保 ffmpeg 进程在运行,自动重启崩溃的进程""" """确保 ffmpeg 进程在运行,自动重启崩溃的进程"""
global _ffmpeg_proc, _ffmpeg_thread global _ffmpeg_proc, _ffmpeg_thread, _invalid_count
with _ffmpeg_lock: with _ffmpeg_lock:
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
return return
@@ -100,6 +129,7 @@ def _ensure_ffmpeg():
if _ffmpeg_proc and _ffmpeg_proc.poll() is None: if _ffmpeg_proc and _ffmpeg_proc.poll() is None:
_ffmpeg_proc.terminate() _ffmpeg_proc.terminate()
_stop_ffmpeg_reader.clear() _stop_ffmpeg_reader.clear()
_invalid_count = 0
logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})") logger.info(f"启动 ffmpeg 视频流 (Video{ARM_CAMERA_INDEX})")
_ffmpeg_proc = subprocess.Popen( _ffmpeg_proc = subprocess.Popen(
@@ -107,11 +137,11 @@ def _ensure_ffmpeg():
"ffmpeg", "ffmpeg",
"-f", "v4l2", "-f", "v4l2",
"-input_format", "mjpeg", "-input_format", "mjpeg",
"-framerate", "12", "-framerate", "8",
"-video_size", "1280x720", "-video_size", "640x480",
"-i", f"/dev/video{ARM_CAMERA_INDEX}", "-i", f"/dev/video{ARM_CAMERA_INDEX}",
"-vf", "rotate=PI", "-fflags", "nobuffer",
"-q:v", "4", "-analyzeduration", "0",
"-f", "mjpeg", "-f", "mjpeg",
"-" "-"
], ],
@@ -166,17 +196,22 @@ def arm_camera_status():
"""摄像头状态""" """摄像头状态"""
global _ffmpeg_proc global _ffmpeg_proc
running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None running = _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None
return jsonify({"opened": running, "frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None}) return jsonify({
"opened": running,
"frame_age": time.time() - _latest_frame_ts if _latest_frame_ts else None,
"invalid_count": _invalid_count,
})
@arm_video_app.route("/api/camera/restart", methods=["POST"]) @arm_video_app.route("/api/camera/restart", methods=["POST"])
def arm_camera_restart(): def arm_camera_restart():
"""重启视频流""" """重启视频流"""
global _latest_frame, _latest_frame_ts global _latest_frame, _latest_frame_ts, _invalid_count
_stop_ffmpeg() _stop_ffmpeg()
with _frame_cond: with _frame_cond:
_latest_frame = None _latest_frame = None
_latest_frame_ts = 0.0 _latest_frame_ts = 0.0
_invalid_count = 0
_ensure_ffmpeg() _ensure_ffmpeg()
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -193,72 +228,18 @@ def arm_camera_snapshot():
logger.warning("snapshot failed: no cached frame") logger.warning("snapshot failed: no cached frame")
return "", 500 return "", 500
# ========== RoboFlow 630 Socket API 客户端 ==========
class RoboFlowClient:
"""通过 Socket 连接 RoboFlow 630 机械臂控制盒"""
def __init__(self, host: str = "127.0.0.1", port: int = 5001, timeout: float = 10):
self.host = host
self.port = port
self.timeout = timeout
self._sock: socket.socket = None
def connect(self) -> bool:
try:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.settimeout(self.timeout)
self._sock.connect((self.host, self.port))
logger.info(f"已连接到 RoboFlow {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"连接 RoboFlow 失败: {e}")
return False
def send_recv(self, cmd: str) -> str:
"""发送命令并等待响应"""
if not self._sock:
raise ConnectionError("未连接到 RoboFlow")
try:
self._sock.sendall((cmd + "\n").encode("utf-8"))
resp = self._sock.recv(4096).decode("utf-8").strip()
return resp
except socket.timeout:
return "ERROR: timeout"
except Exception as e:
return f"ERROR: {e}"
def close(self):
if self._sock:
self._sock.close()
self._sock = None
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.close()
# ========== TCP 服务器 - 接收 AGV 指令 ========== # ========== TCP 服务器 - 接收 AGV 指令 ==========
class AGVCommandServer: class AGVCommandServer:
"""TCP 服务器,接收 AGV 发来的指令""" """TCP 服务器,接收 AGV 发来的指令,通过 ElephantRobot 转发给 RoboFlow"""
def __init__(self, host: str = "0.0.0.0", port: int = 5002): def __init__(self, elephant, host: str = "0.0.0.0", port: int = 5002):
self.host = host self.host = host
self.port = port self.port = port
self._sock: socket.socket = None self._sock: socket.socket = None
self._running = False self._running = False
self.roboflow: RoboFlowClient = None self._elephant = elephant
self._connect_roboflow() if self._elephant is None:
logger.warning("ElephantRobot 实例为空,命令将返回错误")
def _connect_roboflow(self):
self.roboflow = RoboFlowClient()
if self.roboflow.connect():
logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)")
else:
logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行")
def start(self): def start(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -312,10 +293,10 @@ class AGVCommandServer:
logger.info("AGV 客户端已断开") logger.info("AGV 客户端已断开")
def _execute_command(self, cmd: str) -> str: def _execute_command(self, cmd: str) -> str:
if not self.roboflow or not self.roboflow._sock: if self._elephant is None:
return f"ERROR: RoboFlow not connected" return "ERROR: Robot not initialized"
try: try:
return self.roboflow.send_recv(cmd) return self._elephant.send_command(cmd)
except Exception as e: except Exception as e:
return f"ERROR: {e}" return f"ERROR: {e}"
@@ -326,16 +307,13 @@ class AGVCommandServer:
self._sock.close() self._sock.close()
except: except:
pass pass
if self.roboflow:
self.roboflow.close()
logger.info("机械臂服务端已停止") logger.info("机械臂服务端已停止")
# ========== 入口 ========== # ========== 入口 ==========
import time
def power_on_arm(max_retries: int = 5) -> bool: def power_on_arm(max_retries: int = 5) -> bool:
"""通过 ElephantRobot 给机械臂上电并激活(带重试)""" """通过 ElephantRobot 给机械臂上电并激活(带重试)"""
global _elephant
from pymycobot import ElephantRobot from pymycobot import ElephantRobot
for attempt in range(1, max_retries + 1): for attempt in range(1, max_retries + 1):
@@ -354,6 +332,7 @@ def power_on_arm(max_retries: int = 5) -> bool:
logger.info("start_robot 指令已发送,等待5秒...") logger.info("start_robot 指令已发送,等待5秒...")
time.sleep(5) time.sleep(5)
logger.info("✅ 机械臂上电+激活 全部完成") logger.info("✅ 机械臂上电+激活 全部完成")
_elephant = el
return True return True
except Exception as e: except Exception as e:
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}") logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
@@ -372,10 +351,9 @@ def main():
# 先通过 ElephantRobot 给机械臂上电并激活 # 先通过 ElephantRobot 给机械臂上电并激活
power_on_arm() power_on_arm()
server = AGVCommandServer(port=5002) server = AGVCommandServer(_elephant, port=5002)
# 启动 Flask 视频流服务(端口 5003) # 启动 Flask 视频流服务(端口 5003)
from werkzeug.serving import make_server
arm_server_http = None arm_server_http = None
for attempt in range(5): for attempt in range(5):
try: try:
@@ -393,11 +371,16 @@ def main():
def signal_handler(sig, frame): def signal_handler(sig, frame):
logger.info("收到停止信号...") logger.info("收到停止信号...")
global _ffmpeg_proc global _ffmpeg_proc, _elephant
if _ffmpeg_proc: if _ffmpeg_proc:
_ffmpeg_proc.terminate() _ffmpeg_proc.terminate()
server.stop() server.stop()
arm_server_http.shutdown() arm_server_http.shutdown()
if _elephant:
try:
_elephant.stop_client()
except Exception:
pass
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
+4 -5
View File
@@ -6,14 +6,13 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=pi User=pi
WorkingDirectory=/home/pi/work/smart-inspection/arm_server EnvironmentFile=-/etc/default/arm_server
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStartPre=/bin/sleep 5 ExecStartPre=/bin/sleep 5
ExecStart=/usr/bin/env uv run --locked python arm_server.py ExecStart=/bin/bash -lc 'export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"; cd "${ARM_SERVER_DIR:-$HOME/work/smart-inspection/arm_server}" && exec uv run --locked python arm_server.py'
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=append:/home/pi/work/smart-inspection/arm_server/stdout.log StandardOutput=journal
StandardError=append:/home/pi/work/smart-inspection/arm_server/stderr.log StandardError=journal
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
-36
View File
@@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
@@ -1,175 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Table, Form, Button, DatePicker, Select, Space, Row, Col, Input } from 'antd';
import { SearchOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../components/Breadcrumb';
import { MockApi } from '../../services/mockApi';
import { CustomsDeclaration } from '../../types';
import { StatusBadge } from '../../components/StatusBadge';
import { useAppStore } from '../../store/useAppStore';
const { RangePicker } = DatePicker;
export default function CustomsPage() {
const router = useRouter();
const [data, setData] = useState<CustomsDeclaration[]>([]);
const [filteredData, setFilteredData] = useState<CustomsDeclaration[]>([]);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const { setSelectedCustoms } = useAppStore();
useEffect(() => {
let isMounted = true;
const loadCustomsList = async () => {
try {
setLoading(true);
setErrorMessage('');
const res = await MockApi.getCustomsList();
if (!isMounted) return;
setData(res);
setFilteredData(res);
} catch {
if (!isMounted) return;
setErrorMessage('报关单列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCustomsList();
return () => {
isMounted = false;
};
}, []);
const handleStartInspection = (record: CustomsDeclaration) => {
setSelectedCustoms(record);
router.push(`/inspection?customsId=${encodeURIComponent(record.customsId)}`);
};
const [form] = Form.useForm();
const handleSearch = () => {
const values = form.getFieldsValue();
const keyword = values.searchText?.trim().toLowerCase() || '';
const status = values.statusFilter || 'all';
const dateRange = values.dateRange;
const nextData = data.filter(item => {
const matchesStatus = status === 'all' || item.status === status;
const matchesKeyword = !keyword || item.customsId.toLowerCase().includes(keyword);
const createdAt = new Date(item.createdAt);
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1]
|| (createdAt >= dateRange[0].startOf('day').toDate() && createdAt <= dateRange[1].endOf('day').toDate());
return matchesStatus && matchesKeyword && matchesDateRange;
});
setFilteredData(nextData);
};
const handleReset = () => {
form.resetFields();
setFilteredData(data);
};
const expandedRowRender = (record: CustomsDeclaration) => {
const columns = [
{ title: '料号', dataIndex: 'inventoryCode', key: 'inventoryCode' },
{ title: '品名', dataIndex: 'inventoryName', key: 'inventoryName' },
{ title: '规格', dataIndex: 'spec', key: 'spec' },
{ title: '总数', dataIndex: 'quantify', key: 'quantify' },
{ title: '已查验', dataIndex: 'inspected', key: 'inspected' },
];
return <Table columns={columns} dataSource={record.items} pagination={false} size="small" rowKey="inventoryCode" />;
};
return (
<div>
<Breadcrumb />
{errorMessage && (
<Alert
type="error"
message={errorMessage}
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Card title="筛选条件" style={{ marginBottom: 24 }}>
<Form form={form} layout="inline">
<Row gutter={24} align="middle">
<Col>
<Form.Item label="状态" name="statusFilter" initialValue="all">
<Select style={{ width: 120 }}>
<Select.Option value="all"></Select.Option>
<Select.Option value="pending"></Select.Option>
<Select.Option value="inspecting"></Select.Option>
<Select.Option value="released"></Select.Option>
<Select.Option value="abnormal"></Select.Option>
</Select>
</Form.Item>
</Col>
<Col>
<Form.Item label="日期范围" name="dateRange">
<RangePicker />
</Form.Item>
</Col>
<Col>
<Form.Item name="searchText">
<Input
placeholder="搜索报关单号..."
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
style={{ width: 250 }}
/>
</Form.Item>
</Col>
<Col>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
</Card>
<Card title="报关单列表" styles={{ body: { padding: 0 } }}>
<Table
dataSource={filteredData}
loading={loading}
rowKey="id"
expandable={{ expandedRowRender }}
>
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text: string) => <b>{text}</b>} />
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
<Table.Column title="机器总数" dataIndex="machineCount" key="machineCount" render={(count: number) => `${count}`} />
<Table.Column title="创建时间" dataIndex="createdAt" key="createdAt" />
<Table.Column
title="操作"
key="action"
render={(_, record: CustomsDeclaration) => (
<Space size="middle">
{record.status === 'pending' || record.status === 'inspecting' ? (
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleStartInspection(record)}>
</Button>
) : (
<Button type="link"></Button>
)}
</Space>
)}
/>
</Table>
</Card>
</div>
);
}
@@ -1,529 +0,0 @@
'use client';
import React, { Suspense, useEffect, useState, useRef } from 'react';
import { Row, Col, Card, Button, Progress, List, Typography, Space, Modal, Input, Empty, Badge, Spin, Flex, Select, theme, Timeline, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlayCircleOutlined,
PauseCircleOutlined,
StopOutlined,
ReloadOutlined,
CaretRightOutlined,
PauseCircleFilled
} from '@ant-design/icons';
import { Breadcrumb } from '../../components/Breadcrumb';
import { MockApi } from '../../services/mockApi';
import { CustomsDeclaration, InspectionItem, InspectionIssue, CameraInfo } from '../../types';
import { useAppStore } from '../../store/useAppStore';
import { useRouter, useSearchParams } from 'next/navigation';
const { Text } = Typography;
const { TextArea } = Input;
type InspectionStatus = 'idle' | 'running' | 'paused' | 'completed';
interface ProgressItem extends InspectionItem {
currentInspected: number;
}
export default function InspectionPage() {
return (
<Suspense fallback={
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
}>
<InspectionContent />
</Suspense>
);
}
function InspectionContent() {
const router = useRouter();
const searchParams = useSearchParams();
const customsId = searchParams.get('customsId');
const { selectedCustoms, setSelectedCustoms } = useAppStore();
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
const [loadingCustoms, setLoadingCustoms] = useState(true);
const [status, setStatus] = useState<InspectionStatus>('idle');
const [logs, setLogs] = useState<{time: string, msg: string, type: 'info'|'warning'|'success'}[]>([]);
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
const [pauseReason, setPauseReason] = useState('');
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
const [loadingList, setLoadingList] = useState(false);
// Issues and cameras
const [issues, setIssues] = useState<InspectionIssue[]>([]);
const [cameras, setCameras] = useState<CameraInfo[]>([]);
// The segmented control options based on overview cameras
const [currentOverviewCamera, setCurrentOverviewCamera] = useState<string>('');
const { token } = theme.useToken();
const logsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLoadingList(true);
MockApi.getCustomsList().then(list => {
setCustomsList(list);
setLoadingList(false);
});
MockApi.getCameraList().then(list => {
setCameras(list);
const overviews = list.filter(c => c.category === 'overview');
if (overviews.length > 0) {
setCurrentOverviewCamera(overviews[0].id);
}
});
MockApi.getInspectionIssues().then(list => setIssues(list));
}, []);
useEffect(() => {
let isMounted = true;
const loadInspectionCustoms = async () => {
setLoadingCustoms(true);
try {
if (customsId) {
const cachedCustoms = selectedCustoms?.customsId === customsId ? selectedCustoms : null;
const customs = cachedCustoms ?? await MockApi.getCustomsById(customsId);
if (!isMounted) return;
if (!customs) {
setCurrentCustoms(null);
return;
}
setCurrentCustoms(customs);
setSelectedCustoms(customs);
return;
}
setCurrentCustoms(selectedCustoms);
} catch {
if (!isMounted) return;
setCurrentCustoms(null);
} finally {
if (isMounted) {
setLoadingCustoms(false);
}
}
};
loadInspectionCustoms();
return () => {
isMounted = false;
};
}, [customsId, selectedCustoms, setSelectedCustoms]);
useEffect(() => {
if (!currentCustoms) {
setProgressData([]);
setStatus('idle');
return;
}
setProgressData(currentCustoms.items.map(item => ({
...item,
currentInspected: item.inspected
})));
setStatus(currentCustoms.status === 'inspecting' ? 'running' : 'idle');
setLogs([]);
}, [currentCustoms]);
// 模拟查验过程
useEffect(() => {
let timer: NodeJS.Timeout;
if (status === 'running') {
timer = setInterval(() => {
setProgressData(prev => {
let allDone = true;
const next = prev.map(item => {
if (item.currentInspected < item.quantify) {
allDone = false;
// 随机增加进度
if (Math.random() > 0.5) {
const newInspected = Math.min(item.currentInspected + 1, item.quantify);
if (newInspected > item.currentInspected) {
addLog(`料号 ${item.inventoryCode} (${item.inventoryName}) 核销 +1`, 'info');
}
return { ...item, currentInspected: newInspected };
}
}
return item;
});
if (allDone) {
setStatus('completed');
addLog('全部机器核销完成', 'success');
}
return next;
});
}, 3000); // 每 3 秒更新一次模拟数据
}
return () => clearInterval(timer);
}, [status]);
// 自动滚动到最新日志
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs]);
const addLog = (msg: string, type: 'info'|'warning'|'success' = 'info') => {
setLogs(prev => [...prev, { time: new Date().toLocaleTimeString(), msg, type }]);
};
const handleStart = () => {
setStatus('running');
addLog('开始自动化查验作业', 'info');
};
const handlePause = () => {
setIsPauseModalVisible(true);
};
const confirmPause = () => {
setStatus('paused');
addLog(`查验已暂停。原因:${pauseReason || '未填写'}`, 'warning');
setIsPauseModalVisible(false);
setPauseReason('');
};
const handleEnd = () => {
Modal.confirm({
title: '确认结束查验?',
content: '结束查验后无法继续当前任务。',
onOk: () => {
setStatus('completed');
addLog('用户手动结束查验', 'success');
}
});
};
const handleDisposeIssue = async (id: string) => {
await MockApi.disposeIssue(id);
setIssues(prev => prev.map(i => i.id === id ? { ...i, status: 'disposed' } : i));
addLog(`已处置异常: ${id}`, 'success');
};
const handleCancelIssue = async (id: string) => {
await MockApi.cancelIssue(id);
setIssues(prev => prev.map(i => i.id === id ? { ...i, status: 'cancelled' } : i));
addLog(`已取消异常: ${id}`, 'info');
};
const calculateTotalProgress = () => {
if (!progressData.length) return 0;
const total = progressData.reduce((acc, curr) => acc + curr.quantify, 0);
const inspected = progressData.reduce((acc, curr) => acc + curr.currentInspected, 0);
return Math.round((inspected / total) * 100);
};
const overviewCameras = cameras.filter(c => c.category === 'overview');
const agvCamera = cameras.find(c => c.category === 'agv');
const operationCamera = cameras.find(c => c.category === 'operation');
const selectedOverviewCamera = overviewCameras.find(c => c.id === currentOverviewCamera) || overviewCameras[0];
const issueColumns: ColumnsType<InspectionIssue> = [
{
title: '时间',
dataIndex: 'time',
key: 'time',
width: 90,
render: (text) => <Text style={{ fontSize: 13 }}>{text}</Text>
},
{
title: '问题描述',
dataIndex: 'description',
key: 'description',
render: (text, record) => (
<Space>
<Badge status={record.severity === 'error' ? 'error' : 'warning'} />
<Text style={{ fontSize: 13 }}>{text}</Text>
</Space>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status) => {
const map = {
pending: { color: 'red', text: '待处理' },
disposed: { color: 'green', text: '已处置' },
cancelled: { color: 'default', text: '已取消' }
};
const info = map[status as keyof typeof map];
return <Tag color={info.color} style={{ margin: 0 }}>{info.text}</Tag>;
}
},
{
title: '操作',
key: 'action',
width: 140,
render: (_, record) => (
record.status === 'pending' ? (
<Space size="small">
<Button size="small" type="primary" onClick={() => handleDisposeIssue(record.id)}></Button>
<Button size="small" onClick={() => handleCancelIssue(record.id)}></Button>
</Space>
) : null
),
},
];
if (loadingCustoms) {
return (
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
{/* 顶部工具条:面包屑 + 报关单选择器 + 状态指示灯 */}
<Flex align="center" justify="space-between" style={{ padding: '0 24px', margin: '16px 0' }}>
<Breadcrumb />
<Flex align="center" gap="large">
{currentCustoms && (
<Badge
status={status === 'running' ? 'processing' : status === 'idle' ? 'default' : status === 'paused' ? 'warning' : 'success'}
text={<Text strong>{status === 'running' ? '作业中' : status === 'idle' ? '待作业' : status === 'paused' ? '已暂停' : '已完成'}</Text>}
/>
)}
<Select
showSearch
placeholder="搜索并选择报关单..."
style={{ width: 240 }}
loading={loadingList}
optionFilterProp="label"
filterOption={(input, option) =>
(option?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
options={customsList.map(item => ({
value: item.customsId,
label: `${item.customsId} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : '已放行'}`
}))}
value={currentCustoms?.customsId || undefined}
onChange={(value) => {
router.push(`/inspection?customsId=${value}`);
}}
/>
</Flex>
</Flex>
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
{/* 左侧:多摄像头 + 控制区 */}
<Col span={16} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 三画面同框区 */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, marginBottom: 16 }}>
{/* 左列 (1/3) */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* AGV 主视角 */}
<Card
size="small"
title={<Text strong>AGV ({agvCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<div style={{ flex: 1, background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', position: 'relative' }}>
{status === 'running' ? (
<Flex vertical align="center" gap={16}>
<CaretRightOutlined style={{ fontSize: 48, color: token.colorPrimary, opacity: 0.8 }} />
<Text style={{ color: '#fff' }}>...</Text>
<div style={{ padding: '8px 16px', border: `1px dashed ${token.colorPrimary}`, borderRadius: token.borderRadius }}>
<span style={{ color: token.colorPrimary }}>AI </span>
</div>
</Flex>
) : (
<Flex vertical align="center" gap={16} style={{ color: token.colorTextDescription }}>
<PauseCircleFilled style={{ fontSize: 48 }} />
<Text type="secondary"></Text>
</Flex>
)}
</div>
</Card>
{/* 作业视角 */}
<Card
size="small"
title={<Text strong> ({operationCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<div style={{ flex: 1, background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>
{status === 'running' ? (
<Text style={{ color: '#fff' }}>...</Text>
) : (
<Text type="secondary"></Text>
)}
</div>
</Card>
</div>
{/* 右列 (2/3) 查验区监控摄像头 */}
<Card
size="small"
title={
<Flex justify="space-between" align="center">
<Text strong> ({selectedOverviewCamera?.name || '未知'})</Text>
<Button
size="small"
onClick={() => {
if (overviewCameras.length > 0) {
const currentIndex = overviewCameras.findIndex(c => c.id === currentOverviewCamera);
const nextIndex = (currentIndex + 1) % overviewCameras.length;
setCurrentOverviewCamera(overviewCameras[nextIndex]?.id || currentOverviewCamera);
}
}}
>
</Button>
</Flex>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<div style={{ flex: 1, background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>
{status === 'running' ? (
<Text style={{ color: '#fff' }}>...</Text>
) : (
<Text type="secondary"></Text>
)}
</div>
</Card>
</div>
{/* 控制按钮区 */}
<Card size="small" style={{ flexShrink: 0 }}>
<Flex justify="center" gap="middle">
{status === 'idle' || status === 'paused' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} style={{ width: 140 }}>
{status === 'idle' ? '开始查验' : '继续查验'}
</Button>
) : status === 'running' ? (
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={handlePause} style={{ width: 140 }}>
</Button>
) : null}
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'}></Button>
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}></Button>
</Flex>
</Card>
</Col>
{/* 右侧:信息面板 */}
<Col span={8} style={{ display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
{/* 核销进度 */}
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 4, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: '12px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ marginBottom: 16 }}>
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
<List
size="small"
dataSource={progressData}
renderItem={item => (
<List.Item style={{ padding: '8px 0', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<div style={{ width: '100%' }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 13 }}>{item.inventoryName}</Text>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>{item.inventoryCode}</Text>
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
</Space>
</Flex>
<Progress percent={Math.round((item.currentInspected / item.quantify) * 100)} showInfo={false} size="small" />
</div>
</List.Item>
)}
/>
</div>
</Card>
{/* 查验异常 */}
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
<Table
columns={issueColumns}
dataSource={issues}
rowKey="id"
size="small"
pagination={false}
sticky
/>
</div>
</Card>
{/* 查验日志 */}
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: '12px', flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, background: token.colorFillQuaternary } }}
>
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 8 }}>
{logs.length > 0 ? (
<Timeline
items={logs.map((item) => ({
color: item.type === 'success' ? 'green' : item.type === 'warning' ? 'orange' : 'blue',
children: (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>{item.time}</Text>
<Text style={{ fontSize: 13 }}>{item.msg}</Text>
</Space>
),
}))}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
)}
<div ref={logsEndRef} />
</div>
</Card>
</Col>
</Row>
<Modal
title="暂停查验"
open={isPauseModalVisible}
onOk={confirmPause}
onCancel={() => setIsPauseModalVisible(false)}
okText="确认暂停"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
<Text></Text>
<TextArea
rows={4}
placeholder="请输入暂停原因(可选)..."
value={pauseReason}
onChange={e => setPauseReason(e.target.value)}
/>
</Flex>
</Modal>
</div>
);
}
@@ -1,166 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, Button, Typography, Space, Spin, Flex, Breadcrumb as AntdBreadcrumb, Badge, Empty } from 'antd';
import { FullscreenOutlined, ReloadOutlined, CameraOutlined, ArrowLeftOutlined, CaretRightOutlined, HomeOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Breadcrumb } from '../../components/Breadcrumb';
import Link from 'next/link';
import { MockApi } from '../../services/mockApi';
import { CameraInfo } from '../../types';
const { Text } = Typography;
export default function VideoPage() {
const [cameras, setCameras] = useState<CameraInfo[]>([]);
const [fullscreenCamera, setFullscreenCamera] = useState<CameraInfo | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
let isMounted = true;
const loadCameras = async () => {
try {
setLoading(true);
setErrorMessage('');
const cameraList = await MockApi.getCameraList();
if (!isMounted) return;
setCameras(cameraList);
} catch {
if (!isMounted) return;
setErrorMessage('摄像头列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCameras();
return () => {
isMounted = false;
};
}, []);
// 模拟的视频播放器组件
const MockVideoPlayer = ({ camera, height, aspectRatio }: { camera: CameraInfo, height?: number | string, aspectRatio?: string }) => (
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', borderRadius: 8, background: '#141414', height, aspectRatio, boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)' }}>
{camera.status === 'online' ? (
<>
{/* 居中播放按钮 */}
<Button
type="text"
shape="circle"
icon={<CaretRightOutlined style={{ fontSize: 40, color: 'white' }} />}
style={{ width: 80, height: 80, backgroundColor: 'rgba(255, 255, 255, 0.15)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.2)' }}
/>
{/* 底部状态标识 */}
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6, backdropFilter: 'blur(4px)' }}>
<Badge status="success" text={<Text style={{ color: 'white', fontSize: 12 }}>线 / </Text>} />
</div>
</>
) : (
<Empty
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
imageStyle={{ height: 64, marginBottom: 16 }}
description={
<Space direction="vertical" size={2}>
<Text type="danger" strong style={{ fontSize: 16 }}>线</Text>
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}></Text>
</Space>
}
>
<Button
type="primary"
danger
icon={<ReloadOutlined />}
style={{ marginTop: 8 }}
onClick={(e) => {
e.stopPropagation();
// 模拟重连
}}
>
</Button>
</Empty>
)}
</div>
);
if (fullscreenCamera) {
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space align="center" size="middle">
<Button icon={<ArrowLeftOutlined />} onClick={() => setFullscreenCamera(null)}></Button>
<AntdBreadcrumb
items={[
{ title: <Link href="/"><HomeOutlined /> </Link> },
{ title: <a href="#" onClick={(e) => { e.preventDefault(); setFullscreenCamera(null); }}></a> },
{ title: fullscreenCamera.name }
]}
/>
</Space>
<Button type="primary" icon={<CameraOutlined />}></Button>
</Flex>
<Flex justify="center" align="center" style={{ height: 'calc(100vh - 180px)', marginBottom: 16 }}>
<div style={{ height: '100%', maxWidth: '100%', aspectRatio: '16 / 9', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
<MockVideoPlayer camera={fullscreenCamera} height="100%" />
</div>
</Flex>
</div>
);
}
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<Breadcrumb />
<Space>
<Button icon={<FullscreenOutlined />}></Button>
<Button type="primary" icon={<CameraOutlined />}></Button>
</Space>
</Flex>
{errorMessage && (
<Alert
type="error"
message={errorMessage}
showIcon
style={{ marginBottom: 24 }}
/>
)}
{loading && (
<Flex vertical align="center" justify="center" style={{ padding: 64 }}>
<Spin size="large" tip="正在加载摄像头..." />
</Flex>
)}
<Row gutter={[24, 24]}>
{cameras.map((camera) => (
<Col xs={24} lg={12} key={camera.id}>
<Card
hoverable={camera.status === 'online'}
style={{ overflow: 'hidden', borderRadius: 12, borderColor: camera.status === 'online' ? '#f0f0f0' : '#ffccc7' }}
styles={{ body: { padding: 0 } }}
onClick={() => camera.status === 'online' && setFullscreenCamera(camera)}
>
<MockVideoPlayer camera={camera} height={300} />
<Flex justify="space-between" align="center" style={{ padding: '12px 20px', background: camera.status === 'online' ? '#ffffff' : '#fff1f0', borderTop: '1px solid #f0f0f0' }}>
<Space size="middle">
<Badge status={camera.status === 'online' ? 'processing' : 'error'} />
<Text strong style={{ fontSize: 15, color: camera.status === 'online' ? 'inherit' : '#cf1322' }}>{camera.name}</Text>
</Space>
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small"></Button>}
</Flex>
</Card>
</Col>
))}
</Row>
</div>
);
}
@@ -1,108 +0,0 @@
'use client';
import React from 'react';
import { Layout, Menu, Badge, Avatar, Button, Dropdown, Space, Typography, theme } from 'antd';
import {
BellOutlined,
SettingOutlined,
UserOutlined,
DashboardOutlined,
VideoCameraOutlined,
SearchOutlined,
FileTextOutlined,
ScanOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons';
import { usePathname, useRouter } from 'next/navigation';
import { useAppStore } from '../store/useAppStore';
const { Header } = Layout;
export const TopHeader: React.FC = () => {
const pathname = usePathname();
const router = useRouter();
const { user, notifications } = useAppStore();
const { token } = theme.useToken();
const unreadCount = notifications.filter(n => !n.read).length;
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '首页' },
{ key: '/video', icon: <VideoCameraOutlined />, label: '视频监控' },
{ key: '/machines', icon: <SearchOutlined />, label: '机器查询' },
{ key: '/customs', icon: <FileTextOutlined />, label: '报关单' },
{ key: '/inspection', icon: <ScanOutlined />, label: '远程查验' },
];
const handleMenuClick = (e: { key: string }) => {
router.push(e.key);
};
const notificationMenu = {
items: notifications.map(n => ({
key: n.id,
label: (
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
<Typography.Text strong={!n.read} type={n.read ? 'secondary' : undefined} style={{ display: 'block' }}>
{n.title}
</Typography.Text>
<Typography.Paragraph
style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}
>
{n.message}
</Typography.Paragraph>
<Typography.Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
{n.time}
</Typography.Text>
</div>
)
}))
};
return (
<Header style={{
position: 'sticky',
top: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
padding: '0 24px',
background: token.colorBgContainer,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
}}>
<Space
size="middle"
onClick={() => router.push('/')}
style={{ cursor: 'pointer', marginRight: 48, display: 'flex', alignItems: 'center' }}
>
<SafetyCertificateOutlined style={{ fontSize: 24, color: token.colorPrimary }} />
<Typography.Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
</Typography.Title>
</Space>
<Menu
mode="horizontal"
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
items={menuItems}
onClick={handleMenuClick}
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
/>
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
<Dropdown menu={notificationMenu} placement="bottomRight" trigger={['click']}>
<Badge count={unreadCount} size="small" offset={[-4, 4]}>
<Button type="text" shape="circle" icon={<BellOutlined style={{ fontSize: 18, color: token.colorText }} />} />
</Badge>
</Dropdown>
<Button type="text" shape="circle" icon={<SettingOutlined style={{ fontSize: 18, color: token.colorText }} />} />
<Space size="small" style={{ cursor: 'pointer', padding: '0 8px', borderRadius: token.borderRadius, transition: 'background 0.3s' }} className="user-entry">
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
<Typography.Text style={{ fontSize: 14 }}>{user?.name}</Typography.Text>
</Space>
</Space>
</Header>
);
};
@@ -1,145 +0,0 @@
import {
CustomsStats,
ActivityItem,
CustomsDeclaration,
CameraInfo,
MachineDetail,
InspectionIssue
} from '../types';
const customsDeclarations: CustomsDeclaration[] = [
{
id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00',
items: [
{ inventoryCode: 'P001', inventoryName: '打印机型号A', spec: 'A4', quantify: 3, inspected: 0 },
{ inventoryCode: 'S001', inventoryName: '扫描仪型号B', spec: 'A3', quantify: 2, inspected: 0 }
]
},
{
id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00',
items: [
{ inventoryCode: 'P002', inventoryName: '打印机型号C', spec: 'A4', quantify: 8, inspected: 2 }
]
},
{
id: '3', customsId: 'CD20260618004', status: 'released', machineCount: 10, createdAt: '2026-06-18 10:00',
items: [
{ inventoryCode: 'M001', inventoryName: '显示器型号A', spec: '27寸', quantify: 10, inspected: 10 }
]
},
];
// 模拟真实接口耗时,便于原型验证加载和错误状态。
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const MockApi = {
getCustomsStats: async (): Promise<CustomsStats> => {
await delay(300);
return {
pendingCount: 12,
releasedToday: 28,
inspectingCount: 1,
abnormalCount: 0
};
},
getRecentActivities: async (): Promise<ActivityItem[]> => {
await delay(300);
return [
{ id: '1', time: '14:30', type: 'start', message: 'CD20260619003 开始查验' },
{ id: '2', time: '14:15', type: 'success', message: 'CD20260619002 查验完成/放行' },
{ id: '3', time: '14:00', type: 'info', message: '新增报关单 CD20260618005' },
{ id: '4', time: '13:45', type: 'warning', message: 'CD20260618001 查验异常暂停' },
{ id: '5', time: '13:20', type: 'success', message: 'CD20260618004 查验完成/放行' },
];
},
getPendingCustoms: async (): Promise<CustomsDeclaration[]> => {
await delay(400);
return [
{ id: '1', customsId: 'CD20260619001', status: 'pending', machineCount: 5, createdAt: '2026-06-19 14:00', items: [] },
{ id: '2', customsId: 'CD20260619003', status: 'inspecting', machineCount: 8, createdAt: '2026-06-19 13:00', items: [] },
{ id: '3', customsId: 'CD20260619004', status: 'pending', machineCount: 3, createdAt: '2026-06-19 12:00', items: [] },
{ id: '4', customsId: 'CD20260618005', status: 'pending', machineCount: 6, createdAt: '2026-06-18 16:00', items: [] },
];
},
getCustomsList: async (): Promise<CustomsDeclaration[]> => {
await delay(500);
return customsDeclarations;
},
getCustomsById: async (customsId: string): Promise<CustomsDeclaration | null> => {
await delay(300);
return customsDeclarations.find(item => item.customsId === customsId) ?? null;
},
getCameraList: async (): Promise<CameraInfo[]> => {
await delay(300);
return [
{ id: '1', name: '监控摄像头 1', location: '查验区东侧', streamUrl: '', status: 'online', category: 'overview' },
{ id: '2', name: '监控摄像头 2', location: '查验区南侧', streamUrl: '', status: 'online', category: 'overview' },
{ id: '3', name: '监控摄像头 3', location: '查验区西侧', streamUrl: '', status: 'online', category: 'overview' },
{ id: '4', name: '监控摄像头 4', location: '查验区北侧', streamUrl: '', status: 'online', category: 'overview' },
{ id: '5', name: 'AGV 主摄像头', location: 'AGV 前端', streamUrl: '', status: 'online', category: 'agv' },
{ id: '6', name: '作业视角', location: '机械臂', streamUrl: '', status: 'online', category: 'operation' },
];
},
getInspectionIssues: async (): Promise<InspectionIssue[]> => {
await delay(300);
return [
{ id: 'issue-1', time: '14:25:30', description: '序列号不匹配', severity: 'error', status: 'pending' },
{ id: 'issue-2', time: '14:26:15', description: '外包装破损', severity: 'warning', status: 'pending' },
{ id: 'issue-3', time: '14:20:00', description: '数量缺少 1 件', severity: 'error', status: 'disposed', disposedAt: '14:22:10', disposedBy: '系统自动' },
];
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
disposeIssue: async (_id: string): Promise<boolean> => {
await delay(200);
return true;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
cancelIssue: async (_id: string): Promise<boolean> => {
await delay(200);
return true;
},
getMachineDetail: async (serialNumber: string): Promise<MachineDetail> => {
await delay(400);
return {
serialNumber: serialNumber,
modelName: '打印机型号A',
modelId: 'MDL-A4-001',
customsId: 'CD20260619001',
customsName: '某科技公司进口设备批次',
status: 'pending',
specs: {
'尺寸': '480×320×260mm',
'重量': '12.5kg',
'产地': '中国 / 深圳',
'入库日期': '2026-06-15'
},
createdAt: '2026-06-15 10:00',
images: {
incomingInspection: [
{ id: 'i1', url: 'https://picsum.photos/800/600?1', thumbnailUrl: 'https://picsum.photos/200/150?1', name: '来料检验单 第1页', createdAt: '2026-06-10' },
{ id: 'i2', url: 'https://picsum.photos/800/600?2', thumbnailUrl: 'https://picsum.photos/200/150?2', name: '来料检验单 第2页', createdAt: '2026-06-10' }
],
startupTestSample: [
{ id: 'i3', url: 'https://picsum.photos/800/600?3', thumbnailUrl: 'https://picsum.photos/200/150?3', name: '开机测试样张', createdAt: '2026-06-12' }
],
productionOrder: [
{ id: 'i4', url: 'https://picsum.photos/800/600?4', thumbnailUrl: 'https://picsum.photos/200/150?4', name: '生产加工单', createdAt: '2026-06-12' }
],
robotInspection: [
{ id: 'i5', url: 'https://picsum.photos/800/600?5', thumbnailUrl: 'https://picsum.photos/200/150?5', name: '正面照', createdAt: '2026-06-19' },
{ id: 'i6', url: 'https://picsum.photos/800/600?6', thumbnailUrl: 'https://picsum.photos/200/150?6', name: '背面照', createdAt: '2026-06-19' }
]
},
inspectionRecords: []
};
}
};
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+9
View File
@@ -0,0 +1,9 @@
.next
node_modules
out
dist
.env*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
+9
View File
@@ -0,0 +1,9 @@
# 海关智慧查验平台前端
这是从 `prototype/` 复刻并重构后的真实后端接入版本。原型目录保持只读,新代码集中在当前目录。
默认通过 Next.js rewrites 将 `/api/*``/photos/*` 转发到 Flask 后端 `http://127.0.0.1:5000`。如需改后端地址,可设置:
```bash
BACKEND_URL=http://后端地址:5000 npm run dev
```
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const backendUrl = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL || 'http://127.0.0.1:5000';
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${backendUrl}/api/:path*`,
},
{
source: '/photos/:path*',
destination: `${backendUrl}/photos/:path*`,
},
];
},
};
export default nextConfig;
@@ -1,11 +1,11 @@
{ {
"name": "customs-tablet-frontend", "name": "public-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "customs-tablet-frontend", "name": "public-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.5", "@ant-design/icons": "^6.2.5",
@@ -1266,12 +1266,6 @@
"react-dom": ">=18.0.0" "react-dom": ">=18.0.0"
} }
}, },
"node_modules/@rc-component/util/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@rc-component/virtual-list": { "node_modules/@rc-component/virtual-list": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz",
@@ -4642,9 +4636,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.13", "version": "3.3.14",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz",
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5117,6 +5111,13 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5174,10 +5175,9 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-photoswipe-gallery": { "node_modules/react-photoswipe-gallery": {
@@ -5426,9 +5426,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -1,12 +1,13 @@
{ {
"name": "customs-tablet-frontend", "name": "public-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.5", "@ant-design/icons": "^6.2.5",
+243
View File
@@ -0,0 +1,243 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Col, DatePicker, Form, Input, Row, Select, Space, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlayCircleOutlined, SearchOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import dayjs from 'dayjs';
import { Breadcrumb } from '@/components/Breadcrumb';
import { StatusBadge } from '@/components/StatusBadge';
import { BackendApi } from '@/services/backendApi';
import { useAppStore } from '@/store/useAppStore';
import type { CustomsDeclaration, InspectionItem } from '@/types';
const { RangePicker } = DatePicker;
export default function CustomsPage() {
const router = useRouter();
const [data, setData] = useState<CustomsDeclaration[]>([]);
const [filteredData, setFilteredData] = useState<CustomsDeclaration[]>([]);
const [loading, setLoading] = useState(true);
const [startingId, setStartingId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const [form] = Form.useForm();
const { setSelectedCustoms, setInspection } = useAppStore();
useEffect(() => {
let isMounted = true;
const loadCustomsList = async () => {
try {
setLoading(true);
setErrorMessage('');
const customsList = await BackendApi.getCustomsList(1, 100);
if (!isMounted) return;
setData(customsList);
setFilteredData(customsList);
} catch (error) {
if (!isMounted) return;
setErrorMessage(error instanceof Error ? error.message : '报关单列表加载失败,请稍后重试');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadCustomsList();
return () => {
isMounted = false;
};
}, []);
const handleSearch = () => {
const values = form.getFieldsValue();
const keyword = values.searchText?.trim().toLowerCase() || '';
const status = values.statusFilter || 'all';
const dateRange = values.dateRange;
const nextData = data.filter((item) => {
const matchesStatus = status === 'all' || item.status === status;
const matchesKeyword = !keyword || item.customsName.toLowerCase().includes(keyword) || item.customsId.toLowerCase().includes(keyword);
const createdAt = dayjs(item.createdAt);
const matchesDateRange = !dateRange?.[0] || !dateRange?.[1] || !createdAt.isValid()
|| (createdAt.isAfter(dateRange[0].startOf('day')) && createdAt.isBefore(dateRange[1].endOf('day')));
return matchesStatus && matchesKeyword && matchesDateRange;
});
setFilteredData(nextData);
};
const handleReset = () => {
form.resetFields();
setFilteredData(data);
};
const loadExpandedItems = async (record: CustomsDeclaration): Promise<CustomsDeclaration> => {
if (record.items.length) {
return record;
}
const items = await BackendApi.getCustomsMachines(record.id);
const nextRecord = {
...record,
items,
machineCount: record.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
};
setData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
setFilteredData((current) => current.map((item) => (item.id === record.id ? nextRecord : item)));
return nextRecord;
};
const handleStartInspection = async (record: CustomsDeclaration) => {
setStartingId(record.id);
try {
const nextRecord = await loadExpandedItems(record);
const inspection = await BackendApi.startCustomsInspection(nextRecord);
setSelectedCustoms(nextRecord);
setInspection(inspection);
messageApi.success('查验已开始');
router.push(`/inspection?customsId=${encodeURIComponent(nextRecord.id)}`);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '开始查验失败');
} finally {
setStartingId(null);
}
};
const itemColumns: ColumnsType<InspectionItem> = useMemo(() => [
{ title: '料号', dataIndex: 'inventoryCode', key: 'inventoryCode' },
{ title: '品名', dataIndex: 'inventoryName', key: 'inventoryName' },
{ title: '规格', dataIndex: 'spec', key: 'spec' },
{ title: '总数', dataIndex: 'quantify', key: 'quantify' },
{ title: '已查验', dataIndex: 'inspected', key: 'inspected' },
], []);
const expandedRowRender = (record: CustomsDeclaration) => (
<Table
columns={itemColumns}
dataSource={record.items}
pagination={false}
size="small"
rowKey={(item) => item.inventoryCode}
locale={{ emptyText: '展开后端机器列表为空,或暂未加载' }}
/>
);
const columns: ColumnsType<CustomsDeclaration> = [
{
title: '报关单号',
dataIndex: 'customsName',
key: 'customsName',
render: (text: string) => <b>{text}</b>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
},
{
title: '机器总数',
dataIndex: 'machineCount',
key: 'machineCount',
render: (count: number) => (count ? `${count}` : '-'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space size="middle">
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={startingId === record.id}
onClick={() => handleStartInspection(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
{contextHolder}
<Breadcrumb />
{errorMessage && (
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
)}
<Card title="筛选条件" style={{ marginBottom: 24 }}>
<Form form={form} layout="inline">
<Row gutter={24} align="middle">
<Col>
<Form.Item label="状态" name="statusFilter" initialValue="all">
<Select
style={{ width: 120 }}
options={[
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待查验' },
{ value: 'inspecting', label: '查验中' },
{ value: 'released', label: '已放行' },
{ value: 'abnormal', label: '异常' },
]}
/>
</Form.Item>
</Col>
<Col>
<Form.Item label="日期范围" name="dateRange">
<RangePicker />
</Form.Item>
</Col>
<Col>
<Form.Item name="searchText">
<Input
placeholder="搜索报关单号..."
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
style={{ width: 250 }}
/>
</Form.Item>
</Col>
<Col>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
</Card>
<Card title="报关单列表" styles={{ body: { padding: 0 } }}>
<Table
dataSource={filteredData}
loading={loading}
rowKey="id"
columns={columns}
expandable={{
expandedRowRender,
onExpand: (expanded, record) => {
if (expanded && !record.items.length) {
loadExpandedItems(record).catch((error) => {
messageApi.error(error instanceof Error ? error.message : '机器列表加载失败');
});
}
},
}}
/>
</Card>
</div>
);
}

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -1,4 +1,3 @@
/* Ant Design 自带主题 Token,移除不必要的自定义变量 */
:root { :root {
--color-border-light: #f0f0f0; --color-border-light: #f0f0f0;
} }
@@ -13,7 +12,7 @@ body {
body { body {
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
background: #f0f2f5; background: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -50,3 +49,16 @@ a {
overflow-y: auto; overflow-y: auto;
background: #f0f2f5; background: #f0f2f5;
} }
.cameraFrameImage {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.scanCorner {
position: absolute;
width: 20px;
height: 20px;
}
+570
View File
@@ -0,0 +1,570 @@
'use client';
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Button,
Card,
Col,
Empty,
Flex,
Input,
Modal,
Progress,
Row,
Select,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
message,
theme,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PauseCircleFilled,
PauseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { useRouter, useSearchParams } from 'next/navigation';
import { Breadcrumb } from '@/components/Breadcrumb';
import { CameraFrame } from '@/components/CameraFrame';
import { BackendApi } from '@/services/backendApi';
import { useAppStore } from '@/store/useAppStore';
import type { ActivityItem, CameraInfo, CustomsDeclaration, InspectionIssue, InspectionItem, MissionRuntimeState } from '@/types';
const { Text } = Typography;
const { TextArea } = Input;
interface ProgressItem extends InspectionItem {
currentInspected: number;
}
export default function InspectionPage() {
return (
<Suspense
fallback={
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
}
>
<InspectionContent />
</Suspense>
);
}
function InspectionContent() {
const router = useRouter();
const searchParams = useSearchParams();
const customsId = searchParams.get('customsId');
const { selectedCustoms, setSelectedCustoms, setInspection } = useAppStore();
const [customsList, setCustomsList] = useState<CustomsDeclaration[]>([]);
const [currentCustoms, setCurrentCustoms] = useState<CustomsDeclaration | null>(null);
const [status, setStatus] = useState<MissionRuntimeState>('idle');
const [logs, setLogs] = useState<ActivityItem[]>([]);
const [progressData, setProgressData] = useState<ProgressItem[]>([]);
const [issues, setIssues] = useState<InspectionIssue[]>([]);
const [cameras, setCameras] = useState<CameraInfo[]>([]);
const [currentOverviewCamera, setCurrentOverviewCamera] = useState<string>('');
const [isPauseModalVisible, setIsPauseModalVisible] = useState(false);
const [pauseReason, setPauseReason] = useState('');
const [loadingCustoms, setLoadingCustoms] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [operationLoading, setOperationLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const { token } = theme.useToken();
const logsEndRef = useRef<HTMLDivElement>(null);
const refreshRuntime = async () => {
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
BackendApi.getMissionState(),
BackendApi.getCurrentInspection().catch(() => null),
BackendApi.getMissionLogs().catch(() => []),
BackendApi.getInspectionIssues().catch(() => []),
]);
const inspection = missionState.inspection ?? currentInspection;
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
? 'running'
: missionState.state === 'paused'
? 'paused'
: inspection
? 'idle'
: 'idle';
setStatus(runtimeStatus);
setLogs(missionLogs);
setIssues(nextIssues);
if (inspection) {
setInspection(inspection);
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
};
useEffect(() => {
let isMounted = true;
const loadBaseData = async () => {
setLoadingList(true);
try {
const [list, cameraList] = await Promise.all([
BackendApi.getCustomsList(1, 100),
BackendApi.getCameras(),
]);
if (!isMounted) return;
setCustomsList(list);
setCameras(cameraList);
const overviews = cameraList.filter((camera) => camera.category === 'overview');
if (overviews.length > 0) {
setCurrentOverviewCamera(overviews[0].id);
}
} catch (error) {
if (isMounted) {
messageApi.error(error instanceof Error ? error.message : '基础数据加载失败');
}
} finally {
if (isMounted) {
setLoadingList(false);
}
}
};
loadBaseData();
return () => {
isMounted = false;
};
}, [messageApi]);
useEffect(() => {
let isMounted = true;
const loadInspectionCustoms = async () => {
setLoadingCustoms(true);
try {
if (customsId) {
const cachedCustoms = selectedCustoms?.id === customsId || selectedCustoms?.customsId === customsId ? selectedCustoms : null;
const customs = cachedCustoms ?? await BackendApi.getCustomsById(customsId);
if (!isMounted) return;
setCurrentCustoms(customs);
setSelectedCustoms(customs);
if (customs) {
setProgressData(customs.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
return;
}
const currentInspection = await BackendApi.getCurrentInspection();
if (!isMounted) return;
if (currentInspection) {
setInspection(currentInspection);
setCurrentCustoms({
id: currentInspection.customsId,
customsId: currentInspection.customsId,
customsName: currentInspection.customsName,
status: 'inspecting',
machineCount: currentInspection.items.reduce((sum, item) => sum + item.quantify, 0),
createdAt: '-',
items: currentInspection.items,
});
setProgressData(currentInspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
} else {
setCurrentCustoms(selectedCustoms);
}
} catch {
if (isMounted) {
setCurrentCustoms(null);
}
} finally {
if (isMounted) {
setLoadingCustoms(false);
}
}
};
loadInspectionCustoms();
return () => {
isMounted = false;
};
}, [customsId, selectedCustoms, setInspection, setSelectedCustoms]);
useEffect(() => {
const loadRuntime = async () => {
const [missionState, currentInspection, missionLogs, nextIssues] = await Promise.all([
BackendApi.getMissionState(),
BackendApi.getCurrentInspection().catch(() => null),
BackendApi.getMissionLogs().catch(() => []),
BackendApi.getInspectionIssues().catch(() => []),
]);
const inspection = missionState.inspection ?? currentInspection;
const runtimeStatus = missionState.state === 'running' || missionState.state === 'setting'
? 'running'
: missionState.state === 'paused'
? 'paused'
: inspection
? 'idle'
: 'idle';
setStatus(runtimeStatus);
setLogs(missionLogs);
setIssues(nextIssues);
if (inspection) {
setInspection(inspection);
setProgressData(inspection.items.map((item) => ({ ...item, currentInspected: item.inspected })));
}
};
loadRuntime().catch(() => undefined);
const timer = window.setInterval(() => {
loadRuntime().catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [setInspection]);
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const calculateTotalProgress = () => {
if (!progressData.length) return 0;
const total = progressData.reduce((sum, item) => sum + item.quantify, 0);
const inspected = progressData.reduce((sum, item) => sum + item.currentInspected, 0);
return total > 0 ? Math.round((inspected / total) * 100) : 0;
};
const overviewCameras = cameras.filter((camera) => camera.category === 'overview');
const agvCamera = cameras.find((camera) => camera.category === 'agv');
const operationCamera = cameras.find((camera) => camera.category === 'operation');
const selectedOverviewCamera = overviewCameras.find((camera) => camera.id === currentOverviewCamera) || overviewCameras[0];
const selectOptions = useMemo(() => customsList.map((item) => ({
value: item.id,
label: `${item.customsName} - ${item.status === 'pending' ? '待查验' : item.status === 'inspecting' ? '查验中' : item.status === 'released' ? '已放行' : '异常'}`,
})), [customsList]);
const handleStart = async () => {
if (!currentCustoms) {
messageApi.warning('请先选择报关单');
return;
}
setOperationLoading(true);
try {
const inspection = await BackendApi.startCustomsInspection(currentCustoms);
setInspection(inspection);
await BackendApi.startMission();
setStatus('running');
await refreshRuntime();
messageApi.success('自动化查验作业已启动');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '启动查验失败');
} finally {
setOperationLoading(false);
}
};
const confirmPause = async () => {
setOperationLoading(true);
try {
await BackendApi.pauseMission();
setStatus('paused');
setLogs((current) => [
...current,
{ id: `pause-${Date.now()}`, time: new Date().toLocaleTimeString(), type: 'warning', message: `查验已暂停。原因:${pauseReason || '未填写'}` },
]);
setIsPauseModalVisible(false);
setPauseReason('');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '暂停查验失败');
} finally {
setOperationLoading(false);
}
};
const handleResume = async () => {
setOperationLoading(true);
try {
await BackendApi.resumeMission();
setStatus('running');
await refreshRuntime();
messageApi.success('查验已继续');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '继续查验失败');
} finally {
setOperationLoading(false);
}
};
const handleEnd = () => {
Modal.confirm({
title: '确认结束查验?',
content: '结束查验会停止当前自动任务并清空当前报关单查验状态。',
okText: '确认结束',
cancelText: '取消',
onOk: async () => {
setOperationLoading(true);
try {
await BackendApi.stopMission();
await BackendApi.endCustomsInspection();
setStatus('completed');
setInspection(null);
await refreshRuntime();
messageApi.success('查验已结束');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '结束查验失败');
} finally {
setOperationLoading(false);
}
},
});
};
const issueColumns: ColumnsType<InspectionIssue> = [
{
title: '时间',
dataIndex: 'time',
key: 'time',
width: 90,
render: (text: string) => <Text style={{ fontSize: 13 }}>{text}</Text>,
},
{
title: '问题描述',
dataIndex: 'description',
key: 'description',
render: (text: string, record) => (
<Space>
<Badge status={record.severity === 'error' ? 'error' : 'warning'} />
<Text style={{ fontSize: 13 }}>{text}</Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (issueStatus: InspectionIssue['status']) => {
const info = {
pending: { color: 'red', text: '待处理' },
disposed: { color: 'green', text: '已处置' },
cancelled: { color: 'default', text: '已取消' },
}[issueStatus];
return <Tag color={info.color} style={{ margin: 0 }}>{info.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
record.status === 'pending' ? (
<Space size="small">
<Button size="small" type="primary" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'disposed' } : issue))}></Button>
<Button size="small" onClick={() => setIssues((current) => current.map((issue) => issue.id === record.id ? { ...issue, status: 'cancelled' } : issue))}></Button>
</Space>
) : null
),
},
];
if (loadingCustoms) {
return (
<Flex vertical align="center" justify="center" style={{ padding: 48, height: '100vh' }}>
<Spin tip="正在加载查验任务..." />
</Flex>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
{contextHolder}
<Flex align="center" justify="space-between" style={{ padding: '0 24px', margin: '16px 0' }}>
<Breadcrumb />
<Flex align="center" gap="large">
{currentCustoms && (
<Badge
status={status === 'running' ? 'processing' : status === 'idle' ? 'default' : status === 'paused' ? 'warning' : 'success'}
text={<Text strong>{status === 'running' ? '作业中' : status === 'idle' ? '待作业' : status === 'paused' ? '已暂停' : '已完成'}</Text>}
/>
)}
<Select
showSearch
placeholder="搜索并选择报关单..."
style={{ width: 240 }}
loading={loadingList}
optionFilterProp="label"
options={selectOptions}
value={currentCustoms?.id}
onChange={(value) => router.push(`/inspection?customsId=${encodeURIComponent(value)}`)}
/>
</Flex>
</Flex>
<Row gutter={24} style={{ flex: 1, minHeight: 0, margin: '0 24px 24px 24px' }}>
<Col span={16} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card
size="small"
title={<Text strong>AGV ({agvCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={agvCamera} active={status === 'running'} />
</Card>
<Card
size="small"
title={<Text strong> ({operationCamera?.name || '未知'})</Text>}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={operationCamera} active={status === 'running'} />
</Card>
</div>
<Card
size="small"
title={
<Flex justify="space-between" align="center">
<Text strong> ({selectedOverviewCamera?.name || '未知'})</Text>
<Button
size="small"
onClick={() => {
if (overviewCameras.length > 0) {
const currentIndex = overviewCameras.findIndex((camera) => camera.id === currentOverviewCamera);
const nextIndex = (currentIndex + 1) % overviewCameras.length;
setCurrentOverviewCamera(overviewCameras[nextIndex]?.id || currentOverviewCamera);
}
}}
>
</Button>
</Flex>
}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
style={{ flex: 2, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
<CameraFrame camera={selectedOverviewCamera} active={status === 'running'} />
</Card>
</div>
<Card size="small" style={{ flexShrink: 0 }}>
<Flex justify="center" gap="middle">
{status === 'idle' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleStart} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : status === 'paused' ? (
<Button type="primary" size="large" icon={<PlayCircleOutlined />} onClick={handleResume} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : status === 'running' ? (
<Button type="primary" danger size="large" icon={<PauseCircleOutlined />} onClick={() => setIsPauseModalVisible(true)} loading={operationLoading} style={{ width: 140 }}>
</Button>
) : null}
<Button size="large" icon={<ReloadOutlined />} disabled={status === 'completed'} onClick={() => refreshRuntime()}></Button>
<Button danger size="large" icon={<StopOutlined />} onClick={handleEnd} disabled={status === 'completed' || status === 'idle'}></Button>
</Flex>
</Card>
</Col>
<Col span={8} style={{ display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 4, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ marginBottom: 16 }}>
<Progress percent={calculateTotalProgress()} status={status === 'completed' ? 'success' : 'active'} strokeWidth={10} />
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{progressData.length ? progressData.map((item) => (
<div key={item.inventoryCode} style={{ padding: '8px 0', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Flex justify="space-between" align="center" style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 13 }}>{item.inventoryName}</Text>
<Space>
<Text type="secondary" style={{ fontSize: 12 }}>{item.inventoryCode}</Text>
<Badge count={`${item.currentInspected} / ${item.quantify}`} style={{ backgroundColor: item.currentInspected === item.quantify ? token.colorSuccess : token.colorPrimary }} />
</Space>
</Flex>
<Progress percent={item.quantify > 0 ? Math.round((item.currentInspected / item.quantify) * 100) : 0} showInfo={false} size="small" />
</div>
)) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无核销数据" />
)}
</div>
</Card>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
<Table columns={issueColumns} dataSource={issues} rowKey="id" size="small" pagination={false} sticky locale={{ emptyText: <Empty description="暂无异常" /> }} />
</div>
</Card>
<Card
title={<Text strong></Text>}
size="small"
style={{ flex: 3, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{ body: { padding: 12, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, background: token.colorFillQuaternary } }}
>
<div style={{ flex: 1, overflowY: 'auto', paddingRight: 8 }}>
{logs.length > 0 ? (
<Timeline
items={logs.map((item) => ({
color: item.type === 'success' ? 'green' : item.type === 'warning' ? 'orange' : 'blue',
children: (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>{item.time}</Text>
<Text style={{ fontSize: 13 }}>{item.message}</Text>
</Space>
),
}))}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日志" style={{ margin: '20px 0' }} />
)}
<div ref={logsEndRef} />
</div>
</Card>
</Col>
</Row>
<Modal
title="暂停查验"
open={isPauseModalVisible}
onOk={confirmPause}
onCancel={() => setIsPauseModalVisible(false)}
okText="确认暂停"
cancelText="取消"
okButtonProps={{ danger: true, loading: operationLoading }}
>
<Flex vertical gap={16} style={{ paddingTop: 16 }}>
<Text></Text>
<TextArea
rows={4}
placeholder="请输入暂停原因"
value={pauseReason}
onChange={(event) => setPauseReason(event.target.value)}
/>
</Flex>
</Modal>
</div>
);
}
@@ -1,15 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Inter } from "next/font/google";
import "./globals.css";
import { AntdRegistry } from '@ant-design/nextjs-registry'; import { AntdRegistry } from '@ant-design/nextjs-registry';
import { ConfigProvider, App } from 'antd'; import { App, ConfigProvider } from 'antd';
import { TopHeader } from '../components/TopHeader'; import './globals.css';
import { TopHeader } from '@/components/TopHeader';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "海关平板前端系统", title: '海关智慧查验平台',
description: "海关查验系统平板端原型", description: '海关查验系统平板端',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -19,7 +16,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className={`${inter.className} appBody`}> <body className="appBody">
<AntdRegistry> <AntdRegistry>
<ConfigProvider <ConfigProvider
theme={{ theme={{
@@ -34,16 +31,14 @@ export default function RootLayout({
Statistic: { Statistic: {
contentFontSize: 32, contentFontSize: 32,
titleFontSize: 16, titleFontSize: 16,
} },
} },
}} }}
> >
<App className="antAppRoot"> <App className="antAppRoot">
<div className="appShell"> <div className="appShell">
<TopHeader /> <TopHeader />
<main className="appMain"> <main className="appMain">{children}</main>
{children}
</main>
</div> </div>
</App> </App>
</ConfigProvider> </ConfigProvider>
@@ -1,18 +1,20 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, Typography, Space, Button, Tabs, Table, Image as AntImage, Empty, Spin, Flex } from 'antd'; import { Alert, Button, Card, Col, Empty, Flex, Image as AntImage, Row, Space, Spin, Table, Tabs, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../../components/Breadcrumb'; import { Breadcrumb } from '@/components/Breadcrumb';
import { MockApi } from '../../../services/mockApi'; import { StatusBadge } from '@/components/StatusBadge';
import { MachineDetail, ImageItem } from '../../../types'; import { BackendApi } from '@/services/backendApi';
import { StatusBadge } from '../../../components/StatusBadge'; import type { InspectionRecord, MachineDetail, MachineImageItem } from '@/types';
const { Text } = Typography; const { Text } = Typography;
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) { export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
const router = useRouter(); const router = useRouter();
const serialNumber = decodeURIComponent(params.serialNumber);
const [machine, setMachine] = useState<MachineDetail | null>(null); const [machine, setMachine] = useState<MachineDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
@@ -24,13 +26,13 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
try { try {
setLoading(true); setLoading(true);
setErrorMessage(''); setErrorMessage('');
const data = await MockApi.getMachineDetail(params.serialNumber); const data = await BackendApi.getMachineDetail(serialNumber);
if (!isMounted) return; if (!isMounted) return;
setMachine(data); setMachine(data);
} catch { } catch (error) {
if (!isMounted) return; if (!isMounted) return;
setMachine(null); setMachine(null);
setErrorMessage('机器详情加载失败,请稍后重试'); setErrorMessage(error instanceof Error ? error.message : '机器详情加载失败,请稍后重试');
} finally { } finally {
if (isMounted) { if (isMounted) {
setLoading(false); setLoading(false);
@@ -43,7 +45,41 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [params.serialNumber]); }, [serialNumber]);
const renderImageGroup = (images: MachineImageItem[]) => {
if (!images.length) return <Empty description="暂无图片" />;
return (
<AntImage.PreviewGroup>
<Space size={[16, 16]} wrap>
{images.map((image) => (
<Flex key={image.id} vertical style={{ position: 'relative', width: 120, gap: 4 }}>
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
<AntImage
src={image.url}
alt={image.name}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
preview={{ src: image.url }}
/>
</div>
<Text style={{ fontSize: 12, textAlign: 'center' }}>{image.name}</Text>
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{image.createdAt}</Text>
</Flex>
))}
</Space>
</AntImage.PreviewGroup>
);
};
const recordColumns: ColumnsType<InspectionRecord> = [
{ title: '查验时间', dataIndex: 'time', key: 'time' },
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
{ title: '结果', dataIndex: 'result', key: 'result' },
{ title: '备注', dataIndex: 'remark', key: 'remark' },
];
if (loading) { if (loading) {
return ( return (
@@ -57,12 +93,7 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
return ( return (
<Flex vertical align="center" style={{ padding: 48 }}> <Flex vertical align="center" style={{ padding: 48 }}>
{errorMessage && ( {errorMessage && (
<Alert <Alert type="error" message={errorMessage} showIcon style={{ maxWidth: 480, marginBottom: 16 }} />
type="error"
message={errorMessage}
showIcon
style={{ maxWidth: 480, marginBottom: 16 }}
/>
)} )}
<Empty description="暂无机器详情" /> <Empty description="暂无机器详情" />
<Button type="primary" onClick={() => router.push('/machines')} style={{ marginTop: 16 }}> <Button type="primary" onClick={() => router.push('/machines')} style={{ marginTop: 16 }}>
@@ -72,43 +103,6 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
); );
} }
const renderImageGroup = (images: ImageItem[]) => {
if (!images || images.length === 0) return <Empty description="暂无图片" />;
return (
<AntImage.PreviewGroup>
<Space size={[16, 16]} wrap>
{images.map((img) => (
<Flex
key={img.id}
vertical
style={{
position: 'relative',
width: 120,
gap: 4
}}
>
<div style={{ width: '100%', aspectRatio: '4/3', overflow: 'hidden', borderRadius: 8, background: '#f0f0f0' }}>
<AntImage
src={img.url}
alt={img.name}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
preview={{
src: img.url,
}}
/>
</div>
<Text style={{ fontSize: 12, textAlign: 'center' }}>{img.name}</Text>
<Text type="secondary" style={{ fontSize: 11, textAlign: 'center' }}>{img.createdAt}</Text>
</Flex>
))}
</Space>
</AntImage.PreviewGroup>
);
};
const imageTabs = [ const imageTabs = [
{ key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) }, { key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) },
{ key: 'startup', label: '开机测试样张', children: renderImageGroup(machine.images.startupTestSample) }, { key: 'startup', label: '开机测试样张', children: renderImageGroup(machine.images.startupTestSample) },
@@ -123,6 +117,10 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
<Button icon={<ArrowLeftOutlined />} onClick={() => router.back()}></Button> <Button icon={<ArrowLeftOutlined />} onClick={() => router.back()}></Button>
</Flex> </Flex>
{errorMessage && (
<Alert type="warning" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
)}
<Card title="机器基本信息" style={{ marginBottom: 24 }}> <Card title="机器基本信息" style={{ marginBottom: 24 }}>
<Row gutter={[24, 16]}> <Row gutter={[24, 16]}>
<Col span={8}> <Col span={8}>
@@ -132,7 +130,7 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
<Text type="secondary"></Text> <Text strong>{machine.modelName}</Text> <Text type="secondary"></Text> <Text strong>{machine.modelName}</Text>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Text type="secondary"></Text> <Button type="link">{machine.customsId}</Button> <Text type="secondary"></Text> <Button type="link" disabled={machine.customsId === '-'}>{machine.customsId}</Button>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Text type="secondary"></Text> <StatusBadge status={machine.status} /> <Text type="secondary"></Text> <StatusBadge status={machine.status} />
@@ -154,13 +152,9 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
dataSource={machine.inspectionRecords} dataSource={machine.inspectionRecords}
rowKey="id" rowKey="id"
pagination={false} pagination={false}
columns={recordColumns}
locale={{ emptyText: <Empty description="暂无查验记录" /> }} locale={{ emptyText: <Empty description="暂无查验记录" /> }}
> />
<Table.Column title="查验时间" dataIndex="time" />
<Table.Column title="操作人" dataIndex="operator" />
<Table.Column title="结果" dataIndex="result" />
<Table.Column title="备注" dataIndex="remark" />
</Table>
</Card> </Card>
</div> </div>
); );
@@ -1,53 +1,84 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Input, Button, Table, Typography, Space, Modal, Upload, Flex } from 'antd'; import { Button, Card, Col, Flex, Input, Modal, Row, Space, Table, Typography, Upload, message } from 'antd';
import { CameraOutlined, BarcodeOutlined, FileImageOutlined, SearchOutlined, BulbOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table';
import { BarcodeOutlined, BulbOutlined, CameraOutlined, FileImageOutlined, SearchOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Breadcrumb } from '../../components/Breadcrumb'; import { Breadcrumb } from '@/components/Breadcrumb';
import type { RecentMachineQuery } from '@/types';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Dragger } = Upload;
const RECENT_QUERY_STORAGE_KEY = 'recent_queries';
export default function MachineQueryPage() { export default function MachineQueryPage() {
const router = useRouter(); const router = useRouter();
const [serialNumber, setSerialNumber] = useState(''); const [serialNumber, setSerialNumber] = useState('');
const [isScanModalVisible, setIsScanModalVisible] = useState(false); const [isScanModalVisible, setIsScanModalVisible] = useState(false);
const [recentQueries, setRecentQueries] = useState<{serialNumber: string, name: string, time: string}[]>([]); const [recentQueries, setRecentQueries] = useState<RecentMachineQuery[]>([]);
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => { useEffect(() => {
// Load recent queries from localStorage or mock const saved = window.localStorage.getItem(RECENT_QUERY_STORAGE_KEY);
const saved = localStorage.getItem('recent_queries'); if (!saved) {
if (saved) { setRecentQueries([]);
setRecentQueries(JSON.parse(saved)); return;
} else { }
setRecentQueries([
{ serialNumber: 'BG042110276', name: '打印机型号A', time: '06-19 14:30' }, try {
{ serialNumber: 'BG042110285', name: '扫描仪型号B', time: '06-19 10:15' } setRecentQueries(JSON.parse(saved) as RecentMachineQuery[]);
]); } catch {
setRecentQueries([]);
} }
}, []); }, []);
const handleSearch = (sn: string) => { const saveRecentQuery = (nextSerialNumber: string) => {
if (!sn) return; const query: RecentMachineQuery = {
serialNumber: nextSerialNumber,
// Save to recent queries name: '待后端返回',
const newQuery = { serialNumber: sn, name: '未知设备 (模拟)', time: new Date().toLocaleString() }; time: new Date().toLocaleString(),
const updated = [newQuery, ...recentQueries.filter(q => q.serialNumber !== sn)].slice(0, 10);
setRecentQueries(updated);
localStorage.setItem('recent_queries', JSON.stringify(updated));
router.push(`/machines/${sn}`);
}; };
const updated = [query, ...recentQueries.filter((item) => item.serialNumber !== nextSerialNumber)].slice(0, 10);
setRecentQueries(updated);
window.localStorage.setItem(RECENT_QUERY_STORAGE_KEY, JSON.stringify(updated));
};
const handleSearch = (value: string) => {
const nextSerialNumber = value.trim();
if (!nextSerialNumber) {
messageApi.warning('请输入序列号');
return;
}
saveRecentQuery(nextSerialNumber);
router.push(`/machines/${encodeURIComponent(nextSerialNumber)}`);
};
const columns: ColumnsType<RecentMachineQuery> = [
{ title: '序列号', dataIndex: 'serialNumber', key: 'serialNumber' },
{ title: '机器名称', dataIndex: 'name', key: 'name' },
{ title: '查询时间', dataIndex: 'time', key: 'time' },
{
title: '操作',
key: 'action',
render: (_, record) => (
<Button type="link" onClick={() => handleSearch(record.serialNumber)}></Button>
),
},
];
return ( return (
<div> <div>
{contextHolder}
<Breadcrumb /> <Breadcrumb />
<Card title="查询方式选择" style={{ marginBottom: 24 }}> <Card title="查询方式选择" style={{ marginBottom: 24 }}>
<Row gutter={48}> <Row gutter={48}>
<Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}> <Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}>
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}> <Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<CameraOutlined style={{ fontSize: 48, color: 'var(--color-primary)' }} /> <CameraOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
<Title level={4} style={{ margin: 0 }}></Title> <Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary">使</Text> <Text type="secondary">使</Text>
<Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}></Button> <Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}></Button>
@@ -55,7 +86,7 @@ export default function MachineQueryPage() {
</Col> </Col>
<Col span={12}> <Col span={12}>
<Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}> <Flex vertical align="center" gap={16} style={{ padding: '24px 0' }}>
<BarcodeOutlined style={{ fontSize: 48, color: 'var(--color-primary)' }} /> <BarcodeOutlined style={{ fontSize: 48, color: 'var(--ant-color-primary, #1677ff)' }} />
<Title level={4} style={{ margin: 0 }}></Title> <Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
<Space.Compact style={{ width: '80%' }}> <Space.Compact style={{ width: '80%' }}>
@@ -63,7 +94,7 @@ export default function MachineQueryPage() {
placeholder="请输入序列号..." placeholder="请输入序列号..."
size="large" size="large"
value={serialNumber} value={serialNumber}
onChange={(e) => setSerialNumber(e.target.value)} onChange={(event) => setSerialNumber(event.target.value)}
onPressEnter={() => handleSearch(serialNumber)} onPressEnter={() => handleSearch(serialNumber)}
/> />
<Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}></Button> <Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}></Button>
@@ -74,22 +105,20 @@ export default function MachineQueryPage() {
</Card> </Card>
<Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}> <Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}>
<Upload.Dragger <Dragger
accept="image/*" accept="image/*"
showUploadList={false} showUploadList={false}
customRequest={({ onSuccess }) => { beforeUpload={() => {
setTimeout(() => { messageApi.info('图片二维码识别后端暂未提供,请手动输入序列号');
onSuccess?.('ok'); return Upload.LIST_IGNORE;
handleSearch('BG042110276'); // 模拟识别成功
}, 1000);
}} }}
> >
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<FileImageOutlined style={{ fontSize: 48 }} /> <FileImageOutlined style={{ fontSize: 48 }} />
</p> </p>
<p className="ant-upload-text"></p> <p className="ant-upload-text"></p>
<p className="ant-upload-hint"> JPG / PNG / BMP</p> <p className="ant-upload-hint"></p>
</Upload.Dragger> </Dragger>
</Card> </Card>
<Card title="最近查询记录"> <Card title="最近查询记录">
@@ -98,17 +127,8 @@ export default function MachineQueryPage() {
rowKey="serialNumber" rowKey="serialNumber"
pagination={false} pagination={false}
size="middle" size="middle"
> columns={columns}
<Table.Column title="序列号" dataIndex="serialNumber" />
<Table.Column title="机器名称" dataIndex="name" />
<Table.Column title="查询时间" dataIndex="time" />
<Table.Column
title="操作"
render={(_, record: {serialNumber: string}) => (
<Button type="link" onClick={() => handleSearch(record.serialNumber)}></Button>
)}
/> />
</Table>
</Card> </Card>
<Modal <Modal
@@ -121,7 +141,7 @@ export default function MachineQueryPage() {
</Button>, </Button>,
<Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}> <Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}>
</Button> </Button>,
]} ]}
width={600} width={600}
centered centered
@@ -134,40 +154,32 @@ export default function MachineQueryPage() {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 8, borderRadius: 8,
background: '#000000' background: '#000000',
}} }}
> >
<Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}> <Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}>
<CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} /> <CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} />
<div></div> <div></div>
<Button <Text style={{ color: 'rgba(255,255,255,0.65)' }}></Text>
onClick={() => {
setIsScanModalVisible(false);
handleSearch('BG042110276');
}}
>
(BG042110276)
</Button>
</Flex> </Flex>
{/* 模拟扫码框 */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
width: 200, width: 200,
height: 200, height: 200,
border: '2px solid rgba(24, 144, 255, 0.5)', border: '2px solid rgba(24, 144, 255, 0.5)',
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)' boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
}} }}
> >
<div style={{ position: 'absolute', top: -2, left: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div> <div className="scanCorner" style={{ top: -2, left: -2, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
<div style={{ position: 'absolute', top: -2, right: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div> <div className="scanCorner" style={{ top: -2, right: -2, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
<div style={{ position: 'absolute', bottom: -2, left: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div> <div className="scanCorner" style={{ bottom: -2, left: -2, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
<div style={{ position: 'absolute', bottom: -2, right: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div> <div className="scanCorner" style={{ bottom: -2, right: -2, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
</div> </div>
</div> </div>
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}> <div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
<BulbOutlined /> <BulbOutlined /> 使
</div> </div>
</Modal> </Modal>
</div> </div>
@@ -1,23 +1,24 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Alert, Card, Row, Col, List, Button, Table, Statistic, Flex } from 'antd'; import { Alert, Button, Card, Col, Empty, Flex, Row, Statistic, Table, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { import {
FileTextOutlined,
CheckCircleOutlined, CheckCircleOutlined,
SyncOutlined, ClockCircleOutlined,
WarningOutlined, FileTextOutlined,
ProfileOutlined,
RightOutlined,
ScanOutlined, ScanOutlined,
SearchOutlined, SearchOutlined,
SyncOutlined,
VideoCameraOutlined, VideoCameraOutlined,
RightOutlined, WarningOutlined,
ClockCircleOutlined,
ProfileOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { MockApi } from '../services/mockApi'; import { BackendApi } from '@/services/backendApi';
import { CustomsStats, ActivityItem, CustomsDeclaration } from '../types'; import type { ActivityItem, CustomsDeclaration, CustomsStats } from '@/types';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -25,6 +26,7 @@ export default function DashboardPage() {
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]); const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -32,20 +34,20 @@ export default function DashboardPage() {
const loadData = async () => { const loadData = async () => {
try { try {
setErrorMessage(''); setErrorMessage('');
const [statsData, actData, customsData] = await Promise.all([ const [statsData, activityData, customsData] = await Promise.all([
MockApi.getCustomsStats(), BackendApi.getCustomsStats(),
MockApi.getRecentActivities(), BackendApi.getRecentActivities(),
MockApi.getPendingCustoms() BackendApi.getCustomsList(1, 10),
]); ]);
if (!isMounted) return; if (!isMounted) return;
setStats(statsData); setStats(statsData);
setActivities(actData); setActivities(activityData);
setPendingCustoms(customsData); setPendingCustoms(customsData.filter((item) => item.status === 'pending' || item.status === 'inspecting'));
} catch { } catch (error) {
if (!isMounted) return; if (!isMounted) return;
setErrorMessage('首页数据加载失败,请稍后重试'); setErrorMessage(error instanceof Error ? error.message : '首页数据加载失败,请稍后重试');
} }
}; };
@@ -56,14 +58,17 @@ export default function DashboardPage() {
}; };
}, []); }, []);
const inspectingCustoms = pendingCustoms.find(item => item.status === 'inspecting'); const goToInspection = async () => {
const goToInspection = () => { try {
if (inspectingCustoms) { const inspection = await BackendApi.getCurrentInspection();
router.push(`/inspection?customsId=${encodeURIComponent(inspectingCustoms.customsId)}`); if (inspection) {
router.push(`/inspection?customsId=${encodeURIComponent(inspection.customsId)}`);
return; return;
} }
router.push('/customs'); router.push('/customs');
} catch {
router.push('/customs');
}
}; };
const statCards = [ const statCards = [
@@ -72,74 +77,95 @@ export default function DashboardPage() {
value: stats?.pendingCount || 0, value: stats?.pendingCount || 0,
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
suffix: '份报关单', suffix: '份报关单',
valueStyle: { color: '#1890ff' }, contentStyle: { color: '#1890ff' },
onClick: () => router.push('/customs') onClick: () => router.push('/customs'),
}, },
{ {
title: '今日已放行', title: '今日已放行',
value: stats?.releasedToday || 0, value: stats?.releasedToday || 0,
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
suffix: '份报关单', suffix: '份报关单',
valueStyle: { color: '#52c41a' } contentStyle: { color: '#52c41a' },
}, },
{ {
title: '查验进行中', title: '查验进行中',
value: stats?.inspectingCount || 0, value: stats?.inspectingCount || 0,
icon: <SyncOutlined spin />, icon: <SyncOutlined spin />,
suffix: '个任务', suffix: '个任务',
valueStyle: { color: '#faad14' }, contentStyle: { color: '#faad14' },
onClick: goToInspection onClick: goToInspection,
}, },
{ {
title: '异常', title: '异常',
value: stats?.abnormalCount || 0, value: stats?.abnormalCount || 0,
icon: <WarningOutlined />, icon: <WarningOutlined />,
suffix: '个异常', suffix: '个异常',
valueStyle: { color: '#ff4d4f' } contentStyle: { color: '#ff4d4f' },
}, },
]; ];
const quickActions = [ const quickActions = [
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' }, { title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
{ title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' }, { title: '序列号查询机器', desc: '手动输入序列号查询机器全部资料', icon: <SearchOutlined />, onClick: () => router.push('/machines'), color: '#1890ff' },
{ title: '视频监控', desc: '查看厂房实时监控画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video'), color: '#1890ff' }, { title: '视频监控', desc: '查看已接入与待接入的视频画面', icon: <VideoCameraOutlined />, onClick: () => router.push('/video'), color: '#1890ff' },
];
const pendingColumns: ColumnsType<CustomsDeclaration> = [
{
title: '报关单号',
dataIndex: 'customsName',
key: 'customsName',
render: (text: string) => <b>{text}</b>,
},
{
title: '机器数量',
dataIndex: 'machineCount',
key: 'machineCount',
render: (count: number) => (count ? `${count}` : '-'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: CustomsDeclaration['status']) => <StatusBadge status={status} />,
},
]; ];
return ( return (
<div style={{ paddingBottom: 24 }}> <div style={{ paddingBottom: 24 }}>
{contextHolder}
{errorMessage && ( {errorMessage && (
<Alert <Alert
type="error" type="error"
message={errorMessage} message={errorMessage}
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
action={<Button size="small" onClick={() => router.refresh()}></Button>}
/> />
)} )}
{/* 统计卡片区域 */}
<Row gutter={24} style={{ marginBottom: 24 }}> <Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((stat, idx) => ( {statCards.map((stat) => (
<Col span={6} key={idx}> <Col span={6} key={stat.title}>
<Card hoverable={!!stat.onClick} onClick={stat.onClick}> <Card hoverable={Boolean(stat.onClick)} onClick={stat.onClick}>
<Statistic <Statistic
title={stat.title} title={stat.title}
value={stat.value} value={stat.value}
suffix={stat.suffix} suffix={stat.suffix}
prefix={stat.icon} prefix={stat.icon}
valueStyle={stat.valueStyle} styles={{ content: stat.contentStyle }}
loading={!stats} loading={!stats && !errorMessage}
/> />
</Card> </Card>
</Col> </Col>
))} ))}
</Row> </Row>
{/* 快捷操作区域 */}
<div style={{ marginBottom: 32, marginTop: 16 }}> <div style={{ marginBottom: 32, marginTop: 16 }}>
<div style={{ fontSize: 18, marginBottom: 16, fontWeight: 600, color: '#333' }}></div> <div style={{ fontSize: 18, marginBottom: 16, fontWeight: 600, color: '#333' }}></div>
<Row gutter={24}> <Row gutter={24}>
{quickActions.map((action, idx) => ( {quickActions.map((action) => (
<Col span={8} key={idx}> <Col span={8} key={action.title}>
<Card <Card
hoverable hoverable
onClick={action.onClick} onClick={action.onClick}
@@ -149,7 +175,7 @@ export default function DashboardPage() {
border: '1px solid #f0f0f0', border: '1px solid #f0f0f0',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)', boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}} }}
styles={{ body: { padding: '24px', background: '#fff', height: '100%' } }} styles={{ body: { padding: 24, background: '#fff', height: '100%' } }}
> >
<Flex align="center" gap={20}> <Flex align="center" gap={20}>
<Flex <Flex
@@ -162,7 +188,7 @@ export default function DashboardPage() {
background: '#f0f5ff', background: '#f0f5ff',
color: action.color, color: action.color,
fontSize: 28, fontSize: 28,
flexShrink: 0 flexShrink: 0,
}} }}
> >
{action.icon} {action.icon}
@@ -179,35 +205,38 @@ export default function DashboardPage() {
</div> </div>
<Row gutter={24}> <Row gutter={24}>
{/* 最近查验动态 */}
<Col span={12}> <Col span={12}>
<Card <Card
title="最近查验动态" title="最近查验动态"
extra={<Button type="link" icon={<RightOutlined />}></Button>} extra={<Button type="link" icon={<RightOutlined />} onClick={() => messageApi.info('后端暂未提供完整动态列表接口')}></Button>}
styles={{ body: { height: 'calc(100% - 57px)' } }} styles={{ body: { height: 'calc(100% - 57px)' } }}
> >
<List {activities.length ? (
itemLayout="horizontal" <Flex vertical>
dataSource={activities} {activities.map((item) => (
renderItem={(item) => ( <Flex
<List.Item> key={item.id}
<List.Item.Meta align="center"
avatar={ gap={12}
item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> : style={{ padding: '12px 0', borderBottom: '1px solid #f0f0f0' }}
>
{item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> : item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> :
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> : item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
<ProfileOutlined style={{ fontSize: 20 }} /> <ProfileOutlined style={{ fontSize: 20 }} />}
} <Flex vertical gap={2}>
title={<span style={{ fontWeight: 500 }}>{item.message}</span>} <span style={{ fontWeight: 500 }}>{item.message}</span>
description={item.time} <span style={{ color: '#8c8c8c', fontSize: 13 }}>{item.time}</span>
/> </Flex>
</List.Item> </Flex>
))}
</Flex>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无后端动态" />
)} )}
/>
</Card> </Card>
</Col> </Col>
{/* 待查验报关单 */}
<Col span={12}> <Col span={12}>
<Card <Card
title="待查验报关单" title="待查验报关单"
@@ -217,17 +246,14 @@ export default function DashboardPage() {
<Table <Table
dataSource={pendingCustoms} dataSource={pendingCustoms}
rowKey="id" rowKey="id"
columns={pendingColumns}
pagination={false} pagination={false}
size="small" size="small"
onRow={() => ({ onRow={() => ({
onClick: () => router.push('/customs'), onClick: () => router.push('/customs'),
style: { cursor: 'pointer' } style: { cursor: 'pointer' },
})} })}
> />
<Table.Column title="报关单号" dataIndex="customsId" key="customsId" render={(text) => <b>{text}</b>} />
<Table.Column title="机器数量" dataIndex="machineCount" key="machineCount" render={(count) => `${count}`} />
<Table.Column title="状态" dataIndex="status" key="status" render={(status: string) => <StatusBadge status={status as 'pending' | 'inspecting' | 'released' | 'abnormal'} />} />
</Table>
</Card> </Card>
</Col> </Col>
</Row> </Row>
+113
View File
@@ -0,0 +1,113 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { Alert, Badge, Breadcrumb as AntdBreadcrumb, Button, Card, Col, Empty, Flex, Row, Space, Spin, Typography, message } from 'antd';
import { ArrowLeftOutlined, CameraOutlined, FullscreenOutlined, HomeOutlined, ReloadOutlined } from '@ant-design/icons';
import { Breadcrumb } from '@/components/Breadcrumb';
import { CameraFrame } from '@/components/CameraFrame';
import { BackendApi } from '@/services/backendApi';
import type { CameraInfo } from '@/types';
const { Text } = Typography;
export default function VideoPage() {
const [cameras, setCameras] = useState<CameraInfo[]>([]);
const [fullscreenCamera, setFullscreenCamera] = useState<CameraInfo | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [messageApi, contextHolder] = message.useMessage();
const loadCameras = async () => {
try {
setLoading(true);
setErrorMessage('');
const cameraList = await BackendApi.getCameras();
setCameras(cameraList);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '摄像头列表加载失败,请稍后重试');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCameras();
}, []);
if (fullscreenCamera) {
return (
<div>
{contextHolder}
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Space align="center" size="middle">
<Button icon={<ArrowLeftOutlined />} onClick={() => setFullscreenCamera(null)}></Button>
<AntdBreadcrumb
items={[
{ title: <Link href="/"><HomeOutlined /> </Link> },
{ title: <a href="#" onClick={(event) => { event.preventDefault(); setFullscreenCamera(null); }}></a> },
{ title: fullscreenCamera.name },
]}
/>
</Space>
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('截图接口暂未提供')}></Button>
</Flex>
<Flex justify="center" align="center" style={{ height: 'calc(100vh - 180px)', marginBottom: 16 }}>
<div style={{ height: '100%', maxWidth: '100%', aspectRatio: '16 / 9', boxShadow: '0 8px 24px rgba(0,0,0,0.1)' }}>
<CameraFrame camera={fullscreenCamera} height="100%" />
</div>
</Flex>
</div>
);
}
return (
<div>
{contextHolder}
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<Breadcrumb />
<Space>
<Button icon={<ReloadOutlined />} onClick={loadCameras}></Button>
<Button icon={<FullscreenOutlined />} onClick={() => messageApi.info('请选择一个在线画面进入全屏')}></Button>
<Button type="primary" icon={<CameraOutlined />} onClick={() => messageApi.info('全部截图接口暂未提供')}></Button>
</Space>
</Flex>
{errorMessage && (
<Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 24 }} />
)}
{loading ? (
<Flex vertical align="center" justify="center" style={{ padding: 64 }}>
<Spin size="large" tip="正在加载摄像头..." />
</Flex>
) : cameras.length ? (
<Row gutter={[24, 24]}>
{cameras.map((camera) => (
<Col xs={24} lg={12} key={camera.id}>
<Card
hoverable={camera.status === 'online'}
style={{ overflow: 'hidden', borderRadius: 12, borderColor: camera.status === 'online' ? '#f0f0f0' : '#ffccc7' }}
styles={{ body: { padding: 0 } }}
onClick={() => camera.status === 'online' && setFullscreenCamera(camera)}
>
<CameraFrame camera={camera} height={300} />
<Flex justify="space-between" align="center" style={{ padding: '12px 20px', background: camera.status === 'online' ? '#ffffff' : '#fff1f0', borderTop: '1px solid #f0f0f0' }}>
<Space size="middle">
<Badge status={camera.status === 'online' ? 'processing' : 'error'} />
<Text strong style={{ fontSize: 15, color: camera.status === 'online' ? 'inherit' : '#cf1322' }}>{camera.name}</Text>
{camera.placeholder && <Text type="secondary" style={{ fontSize: 12 }}></Text>}
</Space>
{camera.status === 'online' && <Button type="link" icon={<FullscreenOutlined />} size="small"></Button>}
</Flex>
</Card>
</Col>
))}
</Row>
) : (
<Empty description="暂无摄像头数据" />
)}
</div>
);
}
@@ -1,10 +1,10 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Breadcrumb as AntdBreadcrumb } from 'antd'; import { Breadcrumb as AntdBreadcrumb } from 'antd';
import { HomeOutlined } from '@ant-design/icons'; import { HomeOutlined } from '@ant-design/icons';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
const breadcrumbNameMap: Record<string, string> = { const breadcrumbNameMap: Record<string, string> = {
'/video': '视频监控', '/video': '视频监控',
@@ -15,7 +15,7 @@ const breadcrumbNameMap: Record<string, string> = {
export const Breadcrumb: React.FC = () => { export const Breadcrumb: React.FC = () => {
const pathname = usePathname(); const pathname = usePathname();
const pathSnippets = pathname.split('/').filter(i => i); const pathSnippets = pathname.split('/').filter(Boolean);
const extraBreadcrumbItems = pathSnippets.map((_, index) => { const extraBreadcrumbItems = pathSnippets.map((_, index) => {
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`; const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
@@ -34,17 +34,17 @@ export const Breadcrumb: React.FC = () => {
}; };
}); });
const breadcrumbItems = [ return (
<div style={{ marginBottom: 16 }}>
<AntdBreadcrumb
items={[
{ {
key: 'home', key: 'home',
title: <Link href="/"><HomeOutlined /> </Link>, title: <Link href="/"><HomeOutlined /> </Link>,
}, },
...extraBreadcrumbItems, ...extraBreadcrumbItems,
]; ]}
/>
return (
<div style={{ marginBottom: 16 }}>
<AntdBreadcrumb items={breadcrumbItems} />
</div> </div>
); );
}; };
@@ -0,0 +1,91 @@
'use client';
import React, { useMemo, useState } from 'react';
import { Button, Empty, Flex, Typography } from 'antd';
import { CaretRightOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { CameraInfo } from '@/types';
const { Text } = Typography;
interface CameraFrameProps {
camera?: CameraInfo;
active?: boolean;
height?: number | string;
aspectRatio?: string;
}
export const CameraFrame: React.FC<CameraFrameProps> = ({ camera, active = true, height, aspectRatio }) => {
const [reloadKey, setReloadKey] = useState(Date.now());
const streamUrl = useMemo(() => {
if (!camera?.streamUrl) {
return '';
}
return `${camera.streamUrl}${camera.streamUrl.includes('?') ? '&' : '?'}t=${reloadKey}`;
}, [camera?.streamUrl, reloadKey]);
const offline = !camera || camera.status !== 'online' || camera.placeholder;
const isPollingJpeg = camera?.streamUrl === '/api/camera/refresh';
return (
<div
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: 8,
background: '#141414',
height: height || '100%',
width: '100%',
flex: 1,
aspectRatio,
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.5)',
}}
>
{!offline && active && streamUrl ? (
<>
{/* 后端的 AGV 接口是单帧 JPEG,机械臂接口是 MJPEG;img 可同时承载两者。 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={isPollingJpeg ? reloadKey : camera.id}
className="cameraFrameImage"
src={streamUrl}
alt={camera.name}
onLoad={() => {
if (isPollingJpeg && active) {
window.setTimeout(() => setReloadKey(Date.now()), 1500);
}
}}
/>
<div style={{ position: 'absolute', bottom: 16, left: 16, zIndex: 10, padding: '4px 10px', background: 'rgba(0,0,0,0.6)', borderRadius: 6 }}>
<Text style={{ color: '#ffffff', fontSize: 12 }}>{camera.name} / </Text>
</div>
</>
) : offline ? (
<Empty
image={<VideoCameraOutlined style={{ fontSize: 64, color: '#ff4d4f', opacity: 0.9 }} />}
imageStyle={{ height: 64, marginBottom: 16 }}
description={
<Flex vertical gap={2}>
<Text type="danger" strong style={{ fontSize: 16 }}>{camera?.placeholder ? '摄像头未配置' : '设备离线'}</Text>
<Text type="secondary" style={{ color: 'rgba(255,255,255,0.45)' }}>{camera?.location ?? '暂无视频源'}</Text>
</Flex>
}
>
{camera?.streamUrl && (
<Button type="primary" danger icon={<ReloadOutlined />} style={{ marginTop: 8 }} onClick={() => setReloadKey(Date.now())}>
</Button>
)}
</Empty>
) : (
<Flex vertical align="center" gap={16} style={{ color: '#ffffff' }}>
<CaretRightOutlined style={{ fontSize: 48, opacity: 0.65 }} />
<Text style={{ color: '#ffffff' }}></Text>
</Flex>
)}
</div>
);
};
@@ -1,17 +1,27 @@
import React from 'react'; import React from 'react';
import { Tag } from 'antd'; import { Tag } from 'antd';
import { import {
ClockCircleOutlined,
SyncOutlined,
CheckCircleOutlined, CheckCircleOutlined,
WarningOutlined, ClockCircleOutlined,
CloseCircleOutlined,
MinusCircleOutlined, MinusCircleOutlined,
PauseCircleOutlined, PauseCircleOutlined,
CloseCircleOutlined, QuestionCircleOutlined,
QuestionCircleOutlined SyncOutlined,
WarningOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
type StatusType = 'pending' | 'inspecting' | 'released' | 'abnormal' | 'idle' | 'running' | 'paused' | 'completed' | 'online' | 'offline'; type StatusType =
| 'pending'
| 'inspecting'
| 'released'
| 'abnormal'
| 'idle'
| 'running'
| 'paused'
| 'completed'
| 'online'
| 'offline';
interface StatusBadgeProps { interface StatusBadgeProps {
status: StatusType; status: StatusType;
@@ -19,7 +29,7 @@ interface StatusBadgeProps {
} }
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => { export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => {
const getStatusConfig = () => { const config = (() => {
switch (status) { switch (status) {
case 'pending': case 'pending':
return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> }; return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> };
@@ -42,9 +52,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge'
default: default:
return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> }; return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> };
} }
}; })();
const config = getStatusConfig();
if (type === 'tag') { if (type === 'tag') {
return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>; return <Tag color={config.color} icon={config.icon}>{config.text}</Tag>;
@@ -52,7 +60,8 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge'
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{config.icon} <span>{config.text}</span> {config.icon}
<span>{config.text}</span>
</span> </span>
); );
}; };
@@ -0,0 +1,144 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Avatar, Badge, Button, Dropdown, Layout, Menu, Space, Switch, Typography, message, theme } from 'antd';
import {
BellOutlined,
DashboardOutlined,
FileTextOutlined,
SafetyCertificateOutlined,
ScanOutlined,
SearchOutlined,
SettingOutlined,
UserOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { usePathname, useRouter } from 'next/navigation';
import { BackendApi } from '@/services/backendApi';
import { useAppStore } from '@/store/useAppStore';
import type { ApiMode } from '@/types';
const { Header } = Layout;
const { Text, Paragraph, Title } = Typography;
export const TopHeader: React.FC = () => {
const pathname = usePathname();
const router = useRouter();
const { user, notifications } = useAppStore();
const { token } = theme.useToken();
const [apiMode, setApiMode] = useState<ApiMode | null>(null);
const [switchingMode, setSwitchingMode] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const unreadCount = notifications.filter((notification) => !notification.read).length;
useEffect(() => {
BackendApi.getApiMode()
.then(setApiMode)
.catch(() => setApiMode(null));
}, []);
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '首页' },
{ key: '/video', icon: <VideoCameraOutlined />, label: '视频监控' },
{ key: '/machines', icon: <SearchOutlined />, label: '机器查询' },
{ key: '/customs', icon: <FileTextOutlined />, label: '报关单' },
{ key: '/inspection', icon: <ScanOutlined />, label: '远程查验' },
];
const notificationMenu = {
items: notifications.map((notification) => ({
key: notification.id,
label: (
<div style={{ width: 250, padding: '4px 0', whiteSpace: 'normal' }}>
<Text strong={!notification.read} type={notification.read ? 'secondary' : undefined} style={{ display: 'block' }}>
{notification.title}
</Text>
<Paragraph style={{ marginTop: 4, marginBottom: 0, color: token.colorTextSecondary, fontSize: 12, lineHeight: 1.5 }}>
{notification.message}
</Paragraph>
<Text style={{ marginTop: 4, display: 'block', color: token.colorTextTertiary, fontSize: 10 }}>
{notification.time}
</Text>
</div>
),
})),
};
const toggleApiMode = async (nextTestMode: boolean) => {
setSwitchingMode(true);
try {
const nextMode = await BackendApi.setApiMode(nextTestMode);
setApiMode(nextMode);
messageApi.success(`已切换至${nextMode.label}`);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '切换 API 环境失败');
} finally {
setSwitchingMode(false);
}
};
return (
<>
{contextHolder}
<Header
style={{
position: 'sticky',
top: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
padding: '0 24px',
background: token.colorBgContainer,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
}}
>
<Space
size="middle"
onClick={() => router.push('/')}
style={{ cursor: 'pointer', marginRight: 48, display: 'flex', alignItems: 'center' }}
>
<SafetyCertificateOutlined style={{ fontSize: 24, color: token.colorPrimary }} />
<Title level={4} style={{ margin: 0, color: token.colorPrimary, whiteSpace: 'nowrap' }}>
</Title>
</Space>
<Menu
mode="horizontal"
selectedKeys={[pathname === '/' ? '/' : `/${pathname.split('/')[1]}`]}
items={menuItems}
onClick={({ key }) => router.push(key)}
style={{ flex: 1, minWidth: 0, borderBottom: 'none', lineHeight: '62px' }}
/>
<Space size="large" style={{ marginLeft: 24, display: 'flex', alignItems: 'center' }}>
<Space size={8}>
<Text type="secondary" style={{ fontSize: 12 }}>{apiMode?.label ?? '环境'}</Text>
<Switch
size="small"
checked={apiMode?.testMode ?? false}
checkedChildren="测"
unCheckedChildren="正"
loading={switchingMode}
onChange={toggleApiMode}
/>
</Space>
<Dropdown menu={notificationMenu} placement="bottomRight" trigger={['click']}>
<Badge count={unreadCount} size="small" offset={[-4, 4]}>
<Button type="text" shape="circle" icon={<BellOutlined style={{ fontSize: 18, color: token.colorText }} />} />
</Badge>
</Dropdown>
<Button type="text" shape="circle" icon={<SettingOutlined style={{ fontSize: 18, color: token.colorText }} />} />
<Space size="small" style={{ cursor: 'pointer', padding: '0 8px', borderRadius: token.borderRadius }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: token.colorPrimary }} />
<Text style={{ fontSize: 14 }}>{user?.name}</Text>
</Space>
</Space>
</Header>
</>
);
};
+66
View File
@@ -0,0 +1,66 @@
type RequestOptions = RequestInit & {
query?: Record<string, string | number | boolean | undefined | null>;
};
export class ApiError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
const buildUrl = (path: string, query?: RequestOptions['query']) => {
const url = new URL(path, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
Object.entries(query ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
return `${url.pathname}${url.search}`;
};
const parseResponse = async (response: Response) => {
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return response.json();
}
return response.text();
};
export async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { query, headers, body, ...restOptions } = options;
const response = await fetch(buildUrl(path, query), {
...restOptions,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body,
cache: 'no-store',
});
const data = await parseResponse(response);
if (!response.ok) {
const message = typeof data === 'object' && data && 'error' in data
? String((data as { error: unknown }).error)
: `请求失败:HTTP ${response.status}`;
throw new ApiError(message, response.status, data);
}
return data as T;
}
export function postJson<T>(path: string, payload?: unknown): Promise<T> {
return requestJson<T>(path, {
method: 'POST',
body: payload === undefined ? undefined : JSON.stringify(payload),
});
}
+280
View File
@@ -0,0 +1,280 @@
import dayjs from 'dayjs';
import { postJson, requestJson } from '@/services/apiClient';
import {
asArray,
asRecord,
asString,
buildCameraList,
extractRows,
normalizeCustomsDeclaration,
normalizeInspection,
normalizeInspectionItem,
normalizeMachineDetail,
normalizeMissionRuntimeState,
} from '@/services/normalizers';
import type {
ActivityItem,
ApiMode,
CameraInfo,
CustomsDeclaration,
CustomsStats,
InspectionIssue,
InspectionState,
MachineDetail,
MissionStateResponse,
SystemStatus,
} from '@/types';
interface OkResponse<T> {
ok?: boolean;
data?: T;
error?: string;
}
const ensureOk = <T extends OkResponse<unknown>>(payload: T, fallbackMessage: string) => {
if (payload.ok === false) {
throw new Error(payload.error || fallbackMessage);
}
return payload;
};
export const BackendApi = {
async getSystemStatus(): Promise<SystemStatus> {
const [statusPayload, capabilitiesPayload] = await Promise.all([
requestJson<Record<string, unknown>>('/api/status'),
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
]);
return {
state: asString(statusPayload.state, 'idle') as SystemStatus['state'],
agvConnected: Boolean(statusPayload.agv_connected),
armConnected: Boolean(statusPayload.arm_connected),
cameraOpened: Boolean(statusPayload.camera_opened),
armCameraOpened: Boolean(statusPayload.arm_camera_opened),
mapLoaded: Boolean(statusPayload.map_loaded),
pointsCount: Number(statusPayload.points_count ?? 0),
modelsCount: Number(statusPayload.models_count ?? 0),
machinesCount: Number(statusPayload.machines_count ?? 0),
hasAgvCamera: Boolean(statusPayload.has_agv_camera ?? capabilitiesPayload.has_agv_camera ?? statusPayload.camera_opened),
hasArmCamera: Boolean(statusPayload.has_arm_camera ?? capabilitiesPayload.has_arm_camera ?? statusPayload.arm_camera_opened),
};
},
async connectAll() {
return requestJson<{ agv: boolean; arm: boolean; camera: boolean; errors?: string[] }>('/api/system/connect', {
method: 'POST',
});
},
async disconnectAll() {
return postJson<{ ok: boolean }>('/api/system/disconnect');
},
async connectDevice(device: 'agv' | 'arm' | 'camera' | 'arm_camera') {
return postJson<{ device: string; ok: boolean; error?: string }>('/api/device/connect', { device });
},
async getApiMode(): Promise<ApiMode> {
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode'), '读取 API 环境失败');
return {
testMode: Boolean(payload.test_mode),
baseUrl: asString(payload.base_url),
label: asString(payload.label, Boolean(payload.test_mode) ? '测试环境' : '正式环境'),
};
},
async setApiMode(testMode: boolean): Promise<ApiMode> {
const payload = ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/config/mode', { test_mode: testMode }), '切换 API 环境失败');
return {
testMode: Boolean(payload.test_mode),
baseUrl: asString(payload.base_url),
label: asString(payload.label, testMode ? '测试环境' : '正式环境'),
};
},
async getCustomsList(pageNum = 1, pageSize = 50): Promise<CustomsDeclaration[]> {
const payload = ensureOk(
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/list', {
query: { pageNum, pageSize },
}),
'报关单列表加载失败',
);
const rows = extractRows(payload);
return rows.map(normalizeCustomsDeclaration);
},
async getCustomsMachines(customsId: string) {
const payload = ensureOk(
await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/machines', {
query: { customsId },
}),
'机器列表加载失败',
);
return extractRows(payload).map(normalizeInspectionItem);
},
async getCustomsById(customsId: string): Promise<CustomsDeclaration | null> {
const list = await this.getCustomsList(1, 100);
const found = list.find((item) => item.customsId === customsId || item.id === customsId || item.customsName === customsId);
if (!found) {
return null;
}
const items = await this.getCustomsMachines(found.id).catch(() => found.items);
return {
...found,
items,
machineCount: found.machineCount || items.reduce((sum, item) => sum + item.quantify, 0),
};
},
async startCustomsInspection(customs: Pick<CustomsDeclaration, 'id' | 'customsName'>): Promise<InspectionState> {
const payload = ensureOk(
await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection/start', {
customsId: customs.id,
customsName: customs.customsName,
}),
'开始查验失败',
);
const inspection = normalizeInspection(payload.inspection, 'running');
if (!inspection) {
throw new Error('后端未返回查验状态');
}
return inspection;
},
async getCurrentInspection(): Promise<InspectionState | null> {
const payload = ensureOk(await requestJson<OkResponse<unknown> & Record<string, unknown>>('/api/customs/inspection'), '查验状态加载失败');
return normalizeInspection(payload.inspection, 'running');
},
async endCustomsInspection() {
return postJson<{ ok: boolean }>('/api/customs/inspection/end');
},
async getMissionState(): Promise<MissionStateResponse> {
const payload = await requestJson<Record<string, unknown>>('/api/mission/state');
const runtimeStatus = normalizeMissionRuntimeState(payload.state);
return {
state: asString(payload.state, 'idle') as MissionStateResponse['state'],
inspection: normalizeInspection(payload.inspection, runtimeStatus),
rows: Number(payload.rows ?? 0),
cols: Number(payload.cols ?? 0),
tasks: asArray(payload.tasks),
errorMsg: asString(payload.error_msg),
waitingStep: Boolean(payload.waiting_step),
waitingError: Boolean(payload.waiting_error),
};
},
async startMission() {
return ensureOk(await postJson<OkResponse<unknown> & Record<string, unknown>>('/api/mission/start', {}), '启动自动任务失败');
},
async pauseMission() {
return postJson<{ ok: boolean }>('/api/mission/pause');
},
async resumeMission() {
return postJson<{ ok: boolean }>('/api/mission/resume');
},
async stopMission() {
return postJson<{ ok: boolean }>('/api/mission/stop');
},
async getMissionLogs(): Promise<ActivityItem[]> {
const payload = await requestJson<Record<string, unknown>>('/api/mission/log');
const logItems = asArray(payload.log);
return logItems.slice(-80).map((item, index) => {
const record = asRecord(item);
const message = asString(record.msg ?? record.message ?? item, '系统日志');
const time = asString(record.time, dayjs().format('HH:mm:ss'));
const lowered = message.toLowerCase();
const type: ActivityItem['type'] = lowered.includes('error') || message.includes('失败')
? 'warning'
: message.includes('完成') || lowered.includes('success')
? 'success'
: 'info';
return {
id: asString(record.id, `log-${index}`),
time,
type,
message,
};
});
},
async getCameras(): Promise<CameraInfo[]> {
const [statusPayload, capabilitiesPayload] = await Promise.all([
requestJson<Record<string, unknown>>('/api/status').catch((): Record<string, unknown> => ({})),
requestJson<Record<string, unknown>>('/api/camera/capabilities').catch((): Record<string, unknown> => ({})),
]);
return buildCameraList(statusPayload, capabilitiesPayload);
},
async getMachineDetail(serialNumber: string): Promise<MachineDetail> {
const payload = await requestJson<Record<string, unknown>>('/api/customs/printer', {
query: { serialNumber },
});
ensureOk(payload, '机器详情加载失败');
return normalizeMachineDetail(serialNumber, payload);
},
async getCustomsStats(): Promise<CustomsStats> {
const [customsList, inspection] = await Promise.all([
this.getCustomsList(1, 50),
this.getCurrentInspection().catch(() => null),
]);
return {
pendingCount: customsList.filter((item) => item.status === 'pending').length,
releasedToday: customsList.filter((item) => item.status === 'released' && item.createdAt.startsWith(dayjs().format('YYYY-MM-DD'))).length,
inspectingCount: inspection ? 1 : customsList.filter((item) => item.status === 'inspecting').length,
abnormalCount: customsList.filter((item) => item.status === 'abnormal').length,
};
},
async getRecentActivities(): Promise<ActivityItem[]> {
const [logs, inspection] = await Promise.all([
this.getMissionLogs().catch(() => []),
this.getCurrentInspection().catch(() => null),
]);
if (logs.length) {
return logs.slice(-5).reverse();
}
if (inspection) {
return [
{
id: 'current-inspection',
time: inspection.startedAt ? dayjs.unix(inspection.startedAt).format('HH:mm') : dayjs().format('HH:mm'),
type: 'start',
message: `${inspection.customsName || inspection.customsId} 查验中`,
},
];
}
return [];
},
async getInspectionIssues(): Promise<InspectionIssue[]> {
const missionState = await this.getMissionState().catch(() => null);
if (missionState?.waitingError && missionState.errorMsg) {
return [
{
id: 'mission-error',
time: dayjs().format('HH:mm:ss'),
description: missionState.errorMsg,
severity: 'error',
status: 'pending',
},
];
}
return [];
},
};
+289
View File
@@ -0,0 +1,289 @@
import dayjs from 'dayjs';
import type {
CameraInfo,
CustomsDeclaration,
CustomsStatus,
InspectionItem,
InspectionState,
MachineDetail,
MachineImageItem,
MissionRuntimeState,
} from '@/types';
type UnknownRecord = Record<string, unknown>;
export const asRecord = (value: unknown): UnknownRecord => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as UnknownRecord;
}
return {};
};
export const asArray = (value: unknown): unknown[] => {
if (Array.isArray(value)) {
return value;
}
return [];
};
export const asString = (value: unknown, fallback = '') => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return fallback;
};
export const asNumber = (value: unknown, fallback = 0) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
};
const pickString = (record: UnknownRecord, keys: string[], fallback = '') => {
for (const key of keys) {
const value = record[key];
if (value !== undefined && value !== null && value !== '') {
return asString(value, fallback);
}
}
return fallback;
};
const pickNumber = (record: UnknownRecord, keys: string[], fallback = 0) => {
for (const key of keys) {
const value = record[key];
if (value !== undefined && value !== null && value !== '') {
return asNumber(value, fallback);
}
}
return fallback;
};
const normalizeDateTime = (value: unknown) => {
if (!value) {
return '-';
}
const text = asString(value);
const parsed = dayjs(text);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : text;
};
const normalizeStatus = (raw: unknown, hasCustomsCode = false): CustomsStatus => {
const status = asString(raw).toLowerCase();
if (['released', 'finish', 'finished', 'completed', 'done', 'pass', 'passed'].includes(status)) {
return 'released';
}
if (['abnormal', 'error', 'failed', 'exception'].includes(status)) {
return 'abnormal';
}
if (['inspecting', 'running', 'processing'].includes(status)) {
return 'inspecting';
}
return hasCustomsCode ? 'pending' : 'pending';
};
export const extractRows = (payload: unknown): unknown[] => {
const root = asRecord(payload);
const data = asRecord(root.data);
const nestedData = asRecord(data.data);
if (Array.isArray(payload)) return payload;
if (Array.isArray(root.rows)) return root.rows;
if (Array.isArray(root.records)) return root.records;
if (Array.isArray(root.data)) return root.data;
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.records)) return data.records;
if (Array.isArray(data.data)) return data.data;
if (Array.isArray(nestedData.rows)) return nestedData.rows;
if (Array.isArray(nestedData.records)) return nestedData.records;
return [];
};
export const normalizeInspectionItem = (value: unknown): InspectionItem => {
const item = asRecord(value);
return {
inventoryCode: pickString(item, ['inventoryCode', 'machineCode', 'code'], '-'),
inventoryName: pickString(item, ['inventoryName', 'machineName', 'name', 'modelName'], '-'),
spec: pickString(item, ['spec', 'inventorySpecification', 'specification'], '-'),
quantify: pickNumber(item, ['quantify', 'quantity', 'count'], 0),
inspected: pickNumber(item, ['inspected', 'inspectionCount', 'checkedCount'], 0),
};
};
export const normalizeCustomsDeclaration = (value: unknown): CustomsDeclaration => {
const row = asRecord(value);
const customs = asRecord(row.customs);
const customsId = pickString(customs, ['id'], pickString(row, ['customsId', 'customs_id', 'id']));
const customsCode = pickString(customs, ['customsCode'], pickString(row, ['customsCode', 'orderCode', 'drawCode'], customsId || '-'));
const orderIds = pickString(customs, ['orderId']);
const machineCount = pickNumber(row, ['machineCount', 'machine_count'], orderIds ? orderIds.split(',').filter(Boolean).length : 0);
const rawItems = asArray(row.items);
const hasCustomsCode = Boolean(pickString(customs, ['customsCode'], pickString(row, ['customsCode'])));
return {
id: customsId || customsCode,
customsId: customsId || customsCode,
customsName: customsCode,
status: normalizeStatus(pickString(row, ['status', 'state']), hasCustomsCode),
machineCount,
createdAt: normalizeDateTime(pickString(row, ['createTime', 'createdAt', 'applyTime', 'updateTime'], pickString(row, ['orderCode', 'drawCode']))),
items: rawItems.map(normalizeInspectionItem),
raw: value,
};
};
export const normalizeInspection = (value: unknown, runtimeState: MissionRuntimeState = 'idle'): InspectionState | null => {
const inspection = asRecord(value);
const customsId = pickString(inspection, ['customsId', 'customs_id', 'id']);
if (!customsId && !inspection.items) {
return null;
}
return {
customsId,
customsName: pickString(inspection, ['customsName', 'customs_name', 'name'], customsId),
status: runtimeState,
items: asArray(inspection.items).map(normalizeInspectionItem),
startedAt: pickNumber(inspection, ['startedAt', 'started_at'], 0),
};
};
export const normalizeMissionRuntimeState = (state: unknown): MissionRuntimeState => {
const text = asString(state).toLowerCase();
if (text === 'running' || text === 'setting') return 'running';
if (text === 'paused') return 'paused';
if (text === 'completed') return 'completed';
return 'idle';
};
const normalizeImage = (value: unknown, index: number, fallbackName: string): MachineImageItem => {
const item = asRecord(value);
const url = pickString(item, ['url', 'imageUrl', 'path', 'fileUrl']);
return {
id: pickString(item, ['id'], `${fallbackName}-${index}`),
url,
thumbnailUrl: pickString(item, ['thumbnailUrl', 'thumbUrl'], url),
name: pickString(item, ['name', 'fileName'], `${fallbackName} ${index + 1}`),
createdAt: normalizeDateTime(pickString(item, ['createdAt', 'createTime', 'uploadTime'])),
};
};
const emptyImageGroups = () => ({
incomingInspection: [] as MachineImageItem[],
startupTestSample: [] as MachineImageItem[],
productionOrder: [] as MachineImageItem[],
robotInspection: [] as MachineImageItem[],
});
export const normalizeMachineDetail = (serialNumber: string, payload: unknown): MachineDetail => {
const root = asRecord(payload);
const printer = asRecord(root.printer ?? asRecord(root.data).printer);
const orderItem = asRecord(root.orderItem ?? asRecord(root.data).orderItem);
const inventory = asRecord(orderItem.inventory ?? printer.inventory);
const modelName = pickString(root, ['modelName'], pickString(inventory, ['inventoryName', 'name'], pickString(printer, ['model', 'machineModel'], '未知设备')));
const images = emptyImageGroups();
const rawImages = asRecord(root.images ?? printer.images);
images.incomingInspection = asArray(rawImages.incomingInspection).map((item, index) => normalizeImage(item, index, '来料检验单'));
images.startupTestSample = asArray(rawImages.startupTestSample).map((item, index) => normalizeImage(item, index, '开机测试样张'));
images.productionOrder = asArray(rawImages.productionOrder).map((item, index) => normalizeImage(item, index, '生产加工单'));
images.robotInspection = asArray(rawImages.robotInspection).map((item, index) => normalizeImage(item, index, '机器人查验拍照'));
return {
serialNumber,
modelName,
modelId: pickString(inventory, ['inventoryCode', 'code'], pickString(root, ['inventoryCode'], '-')),
customsId: pickString(orderItem, ['customsId'], '-'),
customsName: pickString(orderItem, ['customsName'], '-'),
status: 'pending',
specs: {
物料编码: pickString(inventory, ['inventoryCode', 'code'], '-'),
规格: pickString(inventory, ['inventorySpecification', 'specification', 'spec'], '-'),
序列号: pickString(printer, ['serialNumber'], serialNumber),
},
createdAt: normalizeDateTime(pickString(printer, ['createTime', 'createdAt'])),
images,
inspectionRecords: [],
raw: payload,
};
};
export const buildCameraList = (statusPayload: unknown, capabilitiesPayload: unknown): CameraInfo[] => {
const status = asRecord(statusPayload);
const capabilities = asRecord(capabilitiesPayload);
const hasAgvCamera = Boolean(status.has_agv_camera ?? capabilities.has_agv_camera ?? status.camera_opened);
const hasArmCamera = Boolean(status.has_arm_camera ?? capabilities.has_arm_camera ?? status.arm_camera_opened);
return [
{
id: 'overview-1',
name: '监控摄像头 1',
location: '查验区东侧',
streamUrl: '',
status: 'offline',
category: 'overview',
placeholder: true,
},
{
id: 'overview-2',
name: '监控摄像头 2',
location: '查验区南侧',
streamUrl: '',
status: 'offline',
category: 'overview',
placeholder: true,
},
{
id: 'overview-3',
name: '监控摄像头 3',
location: '查验区西侧',
streamUrl: '',
status: 'offline',
category: 'overview',
placeholder: true,
},
{
id: 'overview-4',
name: '监控摄像头 4',
location: '查验区北侧',
streamUrl: '',
status: 'offline',
category: 'overview',
placeholder: true,
},
{
id: 'agv-camera',
name: 'AGV 主摄像头',
location: 'AGV 前端',
streamUrl: '/api/camera/refresh',
status: hasAgvCamera && Boolean(status.camera_opened) ? 'online' : 'offline',
category: 'agv',
placeholder: !hasAgvCamera,
},
{
id: 'arm-camera',
name: '作业视角',
location: '机械臂',
streamUrl: '/api/camera/arm_preview',
status: hasArmCamera && Boolean(status.arm_camera_opened) ? 'online' : 'offline',
category: 'operation',
placeholder: !hasArmCamera,
},
];
};
@@ -1,13 +1,13 @@
'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import { User, Notification, CustomsDeclaration, InspectionState } from '../types'; import type { CustomsDeclaration, InspectionState, Notification, User } from '@/types';
interface AppState { interface AppState {
user: User | null; user: User | null;
notifications: Notification[]; notifications: Notification[];
selectedCustoms: CustomsDeclaration | null; selectedCustoms: CustomsDeclaration | null;
inspection: InspectionState | null; inspection: InspectionState | null;
// Actions
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
addNotification: (notification: Notification) => void; addNotification: (notification: Notification) => void;
markNotificationRead: (id: string) => void; markNotificationRead: (id: string) => void;
@@ -19,30 +19,21 @@ interface AppState {
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
user: { name: '张三', role: '海关查验员' }, user: { name: '张三', role: '海关查验员' },
notifications: [ notifications: [
{ id: '1', title: '异常告警', message: '入料口摄像头离线', time: '14:30', read: false }, { id: '1', title: '系统通知', message: '远程查验前端已连接真实后端接口', time: '当前', read: false },
{ id: '2', title: '查验完成', message: '报关单 CD20260619002 查验完成', time: '14:15', read: false },
{ id: '3', title: '系统通知', message: '新增 5 份待查验报关单', time: '14:00', read: true }
], ],
selectedCustoms: null, selectedCustoms: null,
inspection: null, inspection: null,
setUser: (user) => set({ user }), setUser: (user) => set({ user }),
addNotification: (notification) => set((state) => ({ notifications: [notification, ...state.notifications] })),
addNotification: (notification) => set((state) => ({
notifications: [notification, ...state.notifications]
})),
markNotificationRead: (id) => set((state) => ({ markNotificationRead: (id) => set((state) => ({
notifications: state.notifications.map(n => notifications: state.notifications.map((notification) => (
n.id === id ? { ...n, read: true } : n notification.id === id ? { ...notification, read: true } : notification
) )),
})), })),
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }), setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
setInspection: (inspection) => set({ inspection }), setInspection: (inspection) => set({ inspection }),
updateInspectionStatus: (status) => set((state) => ({ updateInspectionStatus: (status) => set((state) => ({
inspection: state.inspection ? { ...state.inspection, status } : null inspection: state.inspection ? { ...state.inspection, status } : null,
})), })),
})); }));
@@ -11,25 +11,72 @@ export interface Notification {
read: boolean; read: boolean;
} }
export interface MachineDetail { export type CustomsStatus = 'pending' | 'released' | 'abnormal' | 'inspecting';
serialNumber: string; export type MissionRuntimeState = 'idle' | 'running' | 'paused' | 'completed';
modelName: string; export type DeviceStatus = 'online' | 'offline';
modelId: string;
customsId: string; export interface InspectionItem {
customsName: string; inventoryCode: string;
status: 'pending' | 'inspecting' | 'released' | 'abnormal'; inventoryName: string;
specs: Record<string, string>; spec: string;
createdAt: string; quantify: number;
images: { inspected: number;
incomingInspection: ImageItem[];
startupTestSample: ImageItem[];
productionOrder: ImageItem[];
robotInspection: ImageItem[];
};
inspectionRecords: InspectionRecord[];
} }
export interface ImageItem { export interface CustomsDeclaration {
id: string;
customsId: string;
customsName: string;
status: CustomsStatus;
machineCount: number;
createdAt: string;
items: InspectionItem[];
raw?: unknown;
}
export interface CustomsStats {
pendingCount: number;
releasedToday: number;
inspectingCount: number;
abnormalCount: number;
}
export interface ActivityItem {
id: string;
time: string;
type: 'start' | 'success' | 'info' | 'warning';
message: string;
}
export interface CameraInfo {
id: string;
name: string;
location: string;
streamUrl: string;
status: DeviceStatus;
category: 'overview' | 'agv' | 'operation';
placeholder?: boolean;
}
export interface InspectionIssue {
id: string;
time: string;
description: string;
severity: 'warning' | 'error';
status: 'pending' | 'disposed' | 'cancelled';
disposedAt?: string;
disposedBy?: string;
}
export interface InspectionState {
customsId: string;
customsName: string;
status: MissionRuntimeState;
items: InspectionItem[];
startedAt: number;
}
export interface MachineImageItem {
id: string; id: string;
url: string; url: string;
thumbnailUrl: string; thumbnailUrl: string;
@@ -45,66 +92,59 @@ export interface InspectionRecord {
remark: string; remark: string;
} }
export interface CustomsStats { export interface MachineDetail {
pendingCount: number; serialNumber: string;
releasedToday: number; modelName: string;
inspectingCount: number; modelId: string;
abnormalCount: number;
}
export interface CameraInfo {
id: string;
name: string;
location: string;
streamUrl: string;
status: 'online' | 'offline';
snapshot?: string;
category?: 'overview' | 'agv' | 'operation';
}
export interface InspectionIssue {
id: string;
time: string;
description: string;
severity: 'warning' | 'error';
status: 'pending' | 'disposed' | 'cancelled';
disposedAt?: string;
disposedBy?: string;
}
export interface CustomsDeclaration {
id: string;
customsId: string;
status: 'pending' | 'released' | 'abnormal' | 'inspecting';
machineCount: number;
createdAt: string;
items: InspectionItem[];
}
export interface InspectionState {
customsId: string; customsId: string;
customsName: string; customsName: string;
status: 'idle' | 'running' | 'paused' | 'completed'; status: CustomsStatus;
items: InspectionItem[]; specs: Record<string, string>;
startedAt: number; createdAt: string;
currentMachine?: { images: {
machineId: string; incomingInspection: MachineImageItem[];
serialNumber: string; startupTestSample: MachineImageItem[];
step: string; productionOrder: MachineImageItem[];
robotInspection: MachineImageItem[];
}; };
inspectionRecords: InspectionRecord[];
raw?: unknown;
} }
export interface InspectionItem { export interface SystemStatus {
inventoryCode: string; state: 'idle' | 'setting' | 'running' | 'paused';
inventoryName: string; agvConnected: boolean;
spec: string; armConnected: boolean;
quantify: number; cameraOpened: boolean;
inspected: number; armCameraOpened: boolean;
mapLoaded: boolean;
pointsCount: number;
modelsCount: number;
machinesCount: number;
hasAgvCamera: boolean;
hasArmCamera: boolean;
} }
export interface ActivityItem { export interface MissionStateResponse {
id: string; state: 'idle' | 'setting' | 'running' | 'paused';
inspection: InspectionState | null;
rows?: number;
cols?: number;
tasks?: unknown[];
log?: unknown[];
errorMsg?: string;
waitingStep?: boolean;
waitingError?: boolean;
}
export interface ApiMode {
testMode: boolean;
baseUrl: string;
label: string;
}
export interface RecentMachineQuery {
serialNumber: string;
name: string;
time: string; time: string;
type: 'start' | 'success' | 'info' | 'warning';
message: string;
} }
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""系统时钟发布器。
向 /clock 持续发布系统时间,配合 Nav2 的 use_sim_time 配置,避免 AGV
节点、雷达节点和导航栈之间出现时间源不一致。
"""
import os
import sys
import rclpy
from rclpy.node import Node
from rosgraph_msgs.msg import Clock
LOCKFILE = "/tmp/clock_publisher.lock"
PUBLISH_INTERVAL_SECONDS = 0.01
LOG_INTERVAL_COUNT = 1000
def ensure_single_instance(lockfile: str) -> None:
if os.path.exists(lockfile):
with open(lockfile) as f:
old_pid = int(f.read().strip())
try:
os.kill(old_pid, 0)
print(f"Another clock_publisher running PID {old_pid}, exit.", file=sys.stderr)
sys.exit(1)
except (OSError, ProcessLookupError):
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
with open(lockfile, "w") as f:
f.write(str(os.getpid()))
class SystemClockPublisher(Node):
def __init__(self):
super().__init__("system_clock_publisher")
self.publisher = self.create_publisher(Clock, "/clock", 10)
self.timer = self.create_timer(PUBLISH_INTERVAL_SECONDS, self.publish_clock)
self.count = 0
self.get_logger().info(f"Clock publisher PID={os.getpid()}, publishing at 100Hz")
def publish_clock(self):
message = Clock()
message.clock = self.get_clock().now().to_msg()
self.publisher.publish(message)
self.count += 1
if self.count % LOG_INTERVAL_COUNT == 0:
self.get_logger().info(f"Published {self.count} clock messages")
def main():
ensure_single_instance(LOCKFILE)
rclpy.init(args=sys.argv[1:])
node = SystemClockPublisher()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if os.path.exists(LOCKFILE):
os.remove(LOCKFILE)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""激光雷达时间戳动态修正器。
订阅 /scan,将 LaserScan 的 header.stamp 替换为当前 ROS 时间后发布到
/scan_corrected,保证 AMCL/Costmap 收到的雷达时间戳和 TF 时间同步。
"""
import os
import sys
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
LOCKFILE = "/tmp/scan_fixer.lock"
LOG_INTERVAL_COUNT = 200
def ensure_single_instance(lockfile: str) -> None:
if os.path.exists(lockfile):
with open(lockfile) as f:
old_pid = int(f.read().strip())
try:
os.kill(old_pid, 0)
print(f"Another fixer running PID {old_pid}, exit.", file=sys.stderr)
sys.exit(1)
except (OSError, ProcessLookupError):
print(f"Stale lock removed (PID {old_pid} dead)", file=sys.stderr)
with open(lockfile, "w") as f:
f.write(str(os.getpid()))
def copy_scan_with_current_time(source_scan: LaserScan, node: Node) -> LaserScan:
corrected_scan = LaserScan()
corrected_scan.header.frame_id = source_scan.header.frame_id
corrected_scan.header.stamp = node.get_clock().now().to_msg()
corrected_scan.angle_min = source_scan.angle_min
corrected_scan.angle_max = source_scan.angle_max
corrected_scan.angle_increment = source_scan.angle_increment
corrected_scan.time_increment = source_scan.time_increment
corrected_scan.scan_time = source_scan.scan_time
corrected_scan.range_min = source_scan.range_min
corrected_scan.range_max = source_scan.range_max
corrected_scan.ranges = source_scan.ranges
corrected_scan.intensities = source_scan.intensities
return corrected_scan
def main():
ensure_single_instance(LOCKFILE)
rclpy.init(args=sys.argv[1:])
node = Node("scan_timestamp_fixer")
publisher = node.create_publisher(LaserScan, "/scan_corrected", 10)
count = 0
first_timestamp = None
def on_scan(scan: LaserScan):
nonlocal count, first_timestamp
count += 1
if first_timestamp is None:
first_timestamp = scan.header.stamp.sec
node.get_logger().info(f"First /scan timestamp: {first_timestamp}")
publisher.publish(copy_scan_with_current_time(scan, node))
if count % LOG_INTERVAL_COUNT == 0:
node.get_logger().info(f"#{count} republished with current time")
node.create_subscription(LaserScan, "/scan", on_scan, 10)
node.get_logger().info(f"Fixer v6 PID={os.getpid()}, using current system time")
try:
while rclpy.ok():
rclpy.spin_once(node, timeout_sec=0.1)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if os.path.exists(LOCKFILE):
os.remove(LOCKFILE)
if __name__ == "__main__":
main()
+4 -1
View File
@@ -11,6 +11,9 @@ scripts/
└── dev_start.sh ← 本地开发用(前台运行,不启动 ROS2) └── dev_start.sh ← 本地开发用(前台运行,不启动 ROS2)
``` ```
`scan_fixer/` 是生产启动链路的一部分:`clock_publisher.py` 发布 `/clock`
`fix_scan_timestamp_v6.py``/scan` 重发为 `/scan_corrected`,供 Nav2/AMCL 使用。
## 使用场景 ## 使用场景
### 0. 初始化 Python 环境 ### 0. 初始化 Python 环境
@@ -69,7 +72,7 @@ ssh elephant@192.168.60.80 'bash -s' < scripts/start_flask.sh
| `AGV_PROJECT_DIR` | `/home/elephant/work/smart-inspection` | 仓库根目录 | | `AGV_PROJECT_DIR` | `/home/elephant/work/smart-inspection` | 仓库根目录 |
| `AGV_APP_DIR` | `$AGV_PROJECT_DIR/agv_app` | Flask 应用目录 | | `AGV_APP_DIR` | `$AGV_PROJECT_DIR/agv_app` | Flask 应用目录 |
| `AGV_ROS2_DIR` | `/home/elephant/agv_pro_ros2` | ROS2 工作空间 | | `AGV_ROS2_DIR` | `/home/elephant/agv_pro_ros2` | ROS2 工作空间 |
| `SCAN_FIXER_DIR` | `/home/elephant/work/scan_fixer` | 时间戳修正工具目录 | | `SCAN_FIXER_DIR` | `$AGV_PROJECT_DIR/scan_fixer` | 时间戳修正工具目录 |
| `FIXER_SCRIPT` | `fix_scan_timestamp_v6.py` | fixer 脚本名 | | `FIXER_SCRIPT` | `fix_scan_timestamp_v6.py` | fixer 脚本名 |
## 日志位置(AGV 上) ## 日志位置(AGV 上)
+8 -4
View File
@@ -8,7 +8,11 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AGV_APP_DIR="$PROJECT_DIR/agv_app" AGV_APP_DIR="$PROJECT_DIR/agv_app"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}" AGV_ROS2_DIR="${AGV_ROS2_DIR:-$HOME/agv_pro_ros2}"
ROS_DISTRO="${ROS_DISTRO:-humble}"
ROS_SETUP="${ROS_SETUP:-/opt/ros/$ROS_DISTRO/setup.bash}"
ROS_WORKSPACE_SETUP="${ROS_WORKSPACE_SETUP:-$AGV_ROS2_DIR/install/setup.bash}"
FLASK_PORT="${FLASK_PORT:-5000}"
echo "==========================================" echo "=========================================="
echo " 本地开发模式 - 仅启动 Flask" echo " 本地开发模式 - 仅启动 Flask"
@@ -16,8 +20,8 @@ echo "=========================================="
echo "" echo ""
# 切换到项目目录 # 切换到项目目录
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true source "$ROS_WORKSPACE_SETUP" 2>/dev/null || true
cd "$AGV_APP_DIR" cd "$AGV_APP_DIR"
@@ -38,6 +42,6 @@ fi
# 使用前台模式运行(方便看日志和 Ctrl+C 停止) # 使用前台模式运行(方便看日志和 Ctrl+C 停止)
echo "启动 Flask (前台模式,Ctrl+C 停止)..." echo "启动 Flask (前台模式,Ctrl+C 停止)..."
echo "访问: http://127.0.0.1:5000" echo "访问: http://127.0.0.1:$FLASK_PORT"
echo "" echo ""
exec uv run --locked python app.py exec uv run --locked python app.py
+20 -10
View File
@@ -6,12 +6,22 @@
# ============================================================ # ============================================================
set -e set -e
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-$PROJECT_DIR}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}" AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}" AGV_ROS2_DIR="${AGV_ROS2_DIR:-$HOME/agv_pro_ros2}"
ROS_DISTRO="${ROS_DISTRO:-humble}"
ROS_SETUP="${ROS_SETUP:-/opt/ros/$ROS_DISTRO/setup.bash}"
ROS_WORKSPACE_SETUP="${ROS_WORKSPACE_SETUP:-$AGV_ROS2_DIR/install/setup.bash}"
LOG_DIR="${LOG_DIR:-/tmp}"
FLASK_PORT="${FLASK_PORT:-5000}"
FLASK_LOG="$LOG_DIR/agv_flask.log"
source /opt/ros/humble/setup.bash 2>/dev/null || true mkdir -p "$LOG_DIR"
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
source "$ROS_SETUP" 2>/dev/null || true
source "$ROS_WORKSPACE_SETUP" 2>/dev/null || true
cd "$AGV_APP_DIR" cd "$AGV_APP_DIR"
@@ -37,25 +47,25 @@ find "$AGV_APP_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
pkill -f "python.*app.py" 2>/dev/null || true pkill -f "python.*app.py" 2>/dev/null || true
pkill -f "uv run .*python app.py" 2>/dev/null || true pkill -f "uv run .*python app.py" 2>/dev/null || true
sleep 1 sleep 1
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 & nohup uv run --locked python app.py > "$FLASK_LOG" 2>&1 &
FLASK_PID=$! FLASK_PID=$!
echo " Flask PID: $FLASK_PID" echo " Flask PID: $FLASK_PID"
# 3. 验证 # 3. 验证
echo "[3/3] 验证服务..." echo "[3/3] 验证服务..."
sleep 3 sleep 3
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then if ss -tlnp 2>/dev/null | grep -q ":$FLASK_PORT " || netstat -tlnp 2>/dev/null | grep -q ":$FLASK_PORT "; then
echo " ✅ 端口 5000 正常监听" echo " ✅ 端口 $FLASK_PORT 正常监听"
# 测试机械臂摄像头单帧 # 测试机械臂摄像头单帧
result=$(curl -s --max-time 5 http://127.0.0.1:5000/api/camera/arm_refresh 2>/dev/null | head -c 4) result=$(curl -s --max-time 5 "http://127.0.0.1:$FLASK_PORT/api/camera/arm_refresh" 2>/dev/null | head -c 4)
if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then if [ "$result" = "$(echo -en '\xff\xd8\xff\xe0')" ]; then
echo " ✅ arm_refresh 返回 JPEG" echo " ✅ arm_refresh 返回 JPEG"
else else
echo " ⚠️ arm_refresh 返回异常(机械臂可能未连接)" echo " ⚠️ arm_refresh 返回异常(机械臂可能未连接)"
fi fi
else else
echo " ❌ 端口 5000 未监听,查看日志:" echo " ❌ 端口 $FLASK_PORT 未监听,查看日志:"
tail -10 /tmp/agv_flask.log tail -10 "$FLASK_LOG"
exit 1 exit 1
fi fi
+64 -47
View File
@@ -10,13 +10,30 @@
set -e set -e
# ---- 可配置项(环境变量覆盖默认值) ---- # ---- 可配置项(环境变量覆盖默认值) ----
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-$PROJECT_DIR}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}" AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}" AGV_ROS2_DIR="${AGV_ROS2_DIR:-$HOME/agv_pro_ros2}"
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-/home/elephant/work/scan_fixer}" ROS_DISTRO="${ROS_DISTRO:-humble}"
ROS_SETUP="${ROS_SETUP:-/opt/ros/$ROS_DISTRO/setup.bash}"
ROS_WORKSPACE_SETUP="${ROS_WORKSPACE_SETUP:-$AGV_ROS2_DIR/install/setup.bash}"
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-$AGV_PROJECT_DIR/scan_fixer}"
FIXER_SCRIPT="${FIXER_SCRIPT:-fix_scan_timestamp_v6.py}" FIXER_SCRIPT="${FIXER_SCRIPT:-fix_scan_timestamp_v6.py}"
LOG_DIR="${LOG_DIR:-/tmp}"
LOCK_DIR="${LOCK_DIR:-/tmp}"
FASTRTPS_SHM_DIR="${FASTRTPS_SHM_DIR:-/dev/shm}"
AGV_CONTROLLER_DEVICE="${AGV_CONTROLLER_DEVICE:-/dev/agvpro_controller}"
ROS_DOMAIN_ID_VAL=1 ROS_DOMAIN_ID_VAL=1
BRINGUP_LOG="$LOG_DIR/ros2_bringup.log"
NAV2_LOG="$LOG_DIR/ros2_nav2.log"
CLOCK_LOG="$LOG_DIR/clock_publisher.log"
SCAN_FIXER_LOG="$LOG_DIR/scan_fixer.log"
FLASK_LOG="$LOG_DIR/agv_flask.log"
mkdir -p "$LOG_DIR"
echo "==========================================" echo "=========================================="
echo " Robot AGV 全量启动 v4.0" echo " Robot AGV 全量启动 v4.0"
echo "==========================================" echo "=========================================="
@@ -56,16 +73,16 @@ sleep 2
# 【关键】清理 FastRTPS 共享内存文件(杀进程后立即清理) # 【关键】清理 FastRTPS 共享内存文件(杀进程后立即清理)
echo " 清理 FastRTPS 共享内存文件..." echo " 清理 FastRTPS 共享内存文件..."
FASTRTPS_COUNT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0) FASTRTPS_COUNT=$(ls "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null | wc -l || echo 0)
if [ "$FASTRTPS_COUNT" -gt 0 ]; then if [ "$FASTRTPS_COUNT" -gt 0 ]; then
rm -rf /dev/shm/fastrtps_* rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_*
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件" echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
else else
echo " 无 FastRTPS 文件残留" echo " 无 FastRTPS 文件残留"
fi fi
# 清理 scan_fixer 锁文件 # 清理 scan_fixer 锁文件
rm -f /tmp/scan_fixer.lock rm -f "$LOCK_DIR/scan_fixer.lock"
# 【关键】验证进程已全部停止 # 【关键】验证进程已全部停止
echo " 验证进程停止..." echo " 验证进程停止..."
@@ -86,19 +103,19 @@ echo " ✅ 清理完成"
# ---------- 2. 启动 ros2 daemon ---------- # ---------- 2. 启动 ros2 daemon ----------
echo "[2/8] 启动 ros2 daemon..." echo "[2/8] 启动 ros2 daemon..."
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
# 再次确保没有残留共享内存(启动 daemon 前) # 再次确保没有残留共享内存(启动 daemon 前)
rm -rf /dev/shm/fastrtps_* 2>/dev/null || true rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
# 使用 bash -c 确保环境变量正确传递 # 使用 bash -c 确保环境变量正确传递
nohup bash -c "source /opt/ros/humble/setup.bash && export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 daemon start" >/dev/null 2>&1 & nohup bash -c "source \"$ROS_SETUP\" && export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 daemon start" >/dev/null 2>&1 &
sleep 4 sleep 4
# 验证 daemon 是否就绪(用简单的 topic list 测试) # 验证 daemon 是否就绪(用简单的 topic list 测试)
DAEMON_OK=0 DAEMON_OK=0
for i in $(seq 1 5); do for i in $(seq 1 5); do
DAEMON_TOPICS=$(source /opt/ros/humble/setup.bash && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 3 ros2 topic list 2>&1 | wc -l || echo 0) DAEMON_TOPICS=$(source "$ROS_SETUP" && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 3 ros2 topic list 2>&1 | wc -l || echo 0)
if [ "$DAEMON_TOPICS" -gt 0 ]; then if [ "$DAEMON_TOPICS" -gt 0 ]; then
DAEMON_OK=1 DAEMON_OK=1
echo " ✅ ros2 daemon 就绪" echo " ✅ ros2 daemon 就绪"
@@ -112,15 +129,15 @@ fi
# ---------- 3. 启动 bringup (含激光雷达) ---------- # ---------- 3. 启动 bringup (含激光雷达) ----------
echo "[3/8] 启动 AGV Bringup..." echo "[3/8] 启动 AGV Bringup..."
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
# 【关键】启动前最后确认没有残留共享内存 # 【关键】启动前最后确认没有残留共享内存
rm -rf /dev/shm/fastrtps_* 2>/dev/null || true rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
cd "$AGV_ROS2_DIR" cd "$AGV_ROS2_DIR"
source install/setup.bash source "$ROS_WORKSPACE_SETUP"
nohup bash -c "export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=/dev/agvpro_controller" > /tmp/ros2_bringup.log 2>&1 & nohup bash -c "export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && ros2 launch agv_pro_bringup agv_pro_bringup.launch.py port_name:=$AGV_CONTROLLER_DEVICE" > "$BRINGUP_LOG" 2>&1 &
BRINGUP_PID=$! BRINGUP_PID=$!
echo " bringup PID: $BRINGUP_PID" echo " bringup PID: $BRINGUP_PID"
@@ -136,15 +153,15 @@ for i in $(seq 1 20); do
done done
if [ "$BRINGUP_OK" -eq 0 ]; then if [ "$BRINGUP_OK" -eq 0 ]; then
echo " ⚠️ bringup 未检测到 /odom,继续启动后续组件..." echo " ⚠️ bringup 未检测到 /odom,继续启动后续组件..."
tail -5 /tmp/ros2_bringup.log 2>/dev/null || true tail -5 "$BRINGUP_LOG" 2>/dev/null || true
fi fi
# ---------- 3.5 启动系统时钟发布器 ---------- # ---------- 3.5 启动系统时钟发布器 ----------
echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..." echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..."
nohup bash -c "source /opt/ros/humble/setup.bash && \ nohup bash -c "source \"$ROS_SETUP\" && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/clock_publisher.py" \ ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/clock_publisher.py\"" \
> /tmp/clock_publisher.log 2>&1 & > "$CLOCK_LOG" 2>&1 &
CLOCK_PID=$! CLOCK_PID=$!
echo " clock_publisher PID: $CLOCK_PID" echo " clock_publisher PID: $CLOCK_PID"
sleep 2 sleep 2
@@ -154,7 +171,7 @@ if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/cloc
echo " ✅ /clock 已上线" echo " ✅ /clock 已上线"
else else
echo " ⚠️ /clock 未上线,检查日志:" echo " ⚠️ /clock 未上线,检查日志:"
tail -5 /tmp/clock_publisher.log 2>/dev/null || true tail -5 "$CLOCK_LOG" 2>/dev/null || true
fi fi
# ---------- 4. 启动激光时间戳修正节点 ---------- # ---------- 4. 启动激光时间戳修正节点 ----------
@@ -174,9 +191,9 @@ if [ "$SCAN_OK" -eq 0 ]; then
echo " ⚠️ /scan 未上线,检查 bringup 日志" echo " ⚠️ /scan 未上线,检查 bringup 日志"
fi fi
nohup bash -c "source /opt/ros/humble/setup.bash && \ nohup bash -c "source \"$ROS_SETUP\" && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \ ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/$FIXER_SCRIPT\"" \
> /tmp/scan_fixer.log 2>&1 & > "$SCAN_FIXER_LOG" 2>&1 &
FIXER_PID=$! FIXER_PID=$!
echo " fix_scan_timestamp PID: $FIXER_PID" echo " fix_scan_timestamp PID: $FIXER_PID"
sleep 5 sleep 5
@@ -186,12 +203,12 @@ FIXER_COUNT=$(ps aux | grep -c "[f]ix_scan_timestamp" 2>/dev/null || echo 0)
if [ "$FIXER_COUNT" -gt 1 ]; then if [ "$FIXER_COUNT" -gt 1 ]; then
echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..." echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..."
pkill -f "fix_scan_timestamp" 2>/dev/null || true pkill -f "fix_scan_timestamp" 2>/dev/null || true
pkill -f "clock_publisher" 2>/dev/null || true pkill -f "clock_publisher" 2>/dev/null || true
sleep 2 sleep 2
rm -f /tmp/scan_fixer.lock rm -f "$LOCK_DIR/scan_fixer.lock"
nohup bash -c "source /opt/ros/humble/setup.bash && \ nohup bash -c "source \"$ROS_SETUP\" && \
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \ ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/$FIXER_SCRIPT\"" \
> /tmp/scan_fixer.log 2>&1 & > "$SCAN_FIXER_LOG" 2>&1 &
FIXER_PID=$! FIXER_PID=$!
sleep 3 sleep 3
fi fi
@@ -200,20 +217,20 @@ if ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL ros2 topic list 2>/dev/null | grep -q '/scan
echo " ✅ /scan_corrected 已上线" echo " ✅ /scan_corrected 已上线"
else else
echo " ⚠️ /scan_corrected 未上线,检查日志:" echo " ⚠️ /scan_corrected 未上线,检查日志:"
tail -5 /tmp/scan_fixer.log 2>/dev/null || true tail -5 "$SCAN_FIXER_LOG" 2>/dev/null || true
fi fi
# ---------- 5. 启动 Nav2 ---------- # ---------- 5. 启动 Nav2 ----------
echo "[5/8] 启动 Nav2 导航..." echo "[5/8] 启动 Nav2 导航..."
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
cd "$AGV_ROS2_DIR" cd "$AGV_ROS2_DIR"
source install/setup.bash source "$ROS_WORKSPACE_SETUP"
nohup bash -c "source /opt/ros/humble/setup.bash && \ nohup bash -c "source \"$ROS_SETUP\" && \
source /home/elephant/agv_pro_ros2/install/setup.bash && \ source \"$ROS_WORKSPACE_SETUP\" && \
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && \ export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && \
ros2 launch agv_pro_navigation2 navigation2_active.launch.py \ ros2 launch agv_pro_navigation2 navigation2_active.launch.py \
autostart:=True" > /tmp/ros2_nav2.log 2>&1 & autostart:=True" > "$NAV2_LOG" 2>&1 &
NAV2_PID=$! NAV2_PID=$!
echo " Nav2 PID: $NAV2_PID" echo " Nav2 PID: $NAV2_PID"
sleep 12 sleep 12
@@ -237,9 +254,9 @@ fi
# ---------- 6. 设置精度参数 ---------- # ---------- 6. 设置精度参数 ----------
echo "[6/8] 设置导航精度参数 (xy_goal_tolerance=0.05m)..." echo "[6/8] 设置导航精度参数 (xy_goal_tolerance=0.05m)..."
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
cd "$AGV_ROS2_DIR" cd "$AGV_ROS2_DIR"
source install/setup.bash source "$ROS_WORKSPACE_SETUP"
for NODE in /controller_server /bt_navigator /planner_server; do for NODE in /controller_server /bt_navigator /planner_server; do
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 1 ros2 param set $NODE general_goal_checker.xy_goal_tolerance 0.05 2>/dev/null || true
@@ -252,9 +269,9 @@ echo " ✅ 精度参数已设置"
# ---------- 7. 启动 Flask ---------- # ---------- 7. 启动 Flask ----------
echo "[7/8] 启动 Flask API..." echo "[7/8] 启动 Flask API..."
export ROS_DOMAIN_ID=1 export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL
cd "$AGV_APP_DIR" cd "$AGV_APP_DIR"
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 & nohup uv run --locked python app.py > "$FLASK_LOG" 2>&1 &
FLASK_PID=$! FLASK_PID=$!
echo " Flask PID: $FLASK_PID" echo " Flask PID: $FLASK_PID"
sleep 4 sleep 4
@@ -268,13 +285,13 @@ echo "=========================================="
# 8a. 验证 ros2 topic list(核心指标) # 8a. 验证 ros2 topic list(核心指标)
echo "" echo ""
echo "验证 ros2 topic list..." echo "验证 ros2 topic list..."
TOPIC_COUNT=$(source /opt/ros/humble/setup.bash && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 5 ros2 topic list 2>/dev/null | wc -l || echo 0) TOPIC_COUNT=$(source "$ROS_SETUP" && ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL timeout 5 ros2 topic list 2>/dev/null | wc -l || echo 0)
echo " 话题数量: $TOPIC_COUNT" echo " 话题数量: $TOPIC_COUNT"
if [ "$TOPIC_COUNT" -gt 10 ]; then if [ "$TOPIC_COUNT" -gt 10 ]; then
echo " ✅ ros2 daemon 正常 (${TOPIC_COUNT} 个话题)" echo " ✅ ros2 daemon 正常 (${TOPIC_COUNT} 个话题)"
else else
echo " ❌ ros2 topic list 异常 (${TOPIC_COUNT} 个话题,可能 DDS 有问题)" echo " ❌ ros2 topic list 异常 (${TOPIC_COUNT} 个话题,可能 DDS 有问题)"
echo " 手动执行: rm -rf /dev/shm/fastrtps_* && ros2 daemon stop && ros2 daemon start" echo " 手动执行: rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_* && ros2 daemon stop && ros2 daemon start"
fi fi
# 8b. 验证关键话题 # 8b. 验证关键话题
@@ -304,7 +321,7 @@ fi
# 8d. FastRTPS 共享内存状态 # 8d. FastRTPS 共享内存状态
echo "" echo ""
echo "FastRTPS 共享内存状态:" echo "FastRTPS 共享内存状态:"
FASTRTPS_NEW=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0) FASTRTPS_NEW=$(ls "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null | wc -l || echo 0)
echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)" echo " 当前文件数: $FASTRTPS_NEW (正常运行时会有一些)"
# 8e. Flask API 测试 # 8e. Flask API 测试
@@ -332,12 +349,12 @@ for PROC in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$F
done done
echo "" echo ""
echo " 日志文件:" echo " 日志文件:"
echo " bringup : /tmp/ros2_bringup.log" echo " bringup : $BRINGUP_LOG"
echo " Nav2 : /tmp/ros2_nav2.log" echo " Nav2 : $NAV2_LOG"
echo " fixer : /tmp/scan_fixer.log" echo " fixer : $SCAN_FIXER_LOG"
echo " Flask : /tmp/agv_flask.log" echo " Flask : $FLASK_LOG"
echo "" echo ""
echo " 如果仍有问题,请依次执行:" echo " 如果仍有问题,请依次执行:"
echo " 1. ./stop_all.sh" echo " 1. ./scripts/stop_all.sh"
echo " 2. rm -rf /dev/shm/fastrtps_*" echo " 2. rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
echo " 3. ./start_all.sh" echo " 3. ./scripts/start_all.sh"
+18 -8
View File
@@ -3,24 +3,34 @@
# start_flask.sh - 仅启动/重启 Flask 服务(不启动 ROS2) # start_flask.sh - 仅启动/重启 Flask 服务(不启动 ROS2)
# 适用于: 修改了前端/API 代码后快速重启 # 适用于: 修改了前端/API 代码后快速重启
# ============================================================ # ============================================================
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-/home/elephant/work/smart-inspection}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
AGV_PROJECT_DIR="${AGV_PROJECT_DIR:-$PROJECT_DIR}"
AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}" AGV_APP_DIR="${AGV_APP_DIR:-$AGV_PROJECT_DIR/agv_app}"
AGV_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}" AGV_ROS2_DIR="${AGV_ROS2_DIR:-$HOME/agv_pro_ros2}"
ROS_DISTRO="${ROS_DISTRO:-humble}"
ROS_SETUP="${ROS_SETUP:-/opt/ros/$ROS_DISTRO/setup.bash}"
ROS_WORKSPACE_SETUP="${ROS_WORKSPACE_SETUP:-$AGV_ROS2_DIR/install/setup.bash}"
LOG_DIR="${LOG_DIR:-/tmp}"
FLASK_PORT="${FLASK_PORT:-5000}"
FLASK_LOG="$LOG_DIR/agv_flask.log"
mkdir -p "$LOG_DIR"
pkill -f "python.*app.py" 2>/dev/null || true pkill -f "python.*app.py" 2>/dev/null || true
pkill -f "uv run .*python app.py" 2>/dev/null || true pkill -f "uv run .*python app.py" 2>/dev/null || true
sleep 1 sleep 1
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true source "$ROS_WORKSPACE_SETUP" 2>/dev/null || true
cd "$AGV_APP_DIR" cd "$AGV_APP_DIR"
nohup uv run --locked python app.py > /tmp/agv_flask.log 2>&1 & nohup uv run --locked python app.py > "$FLASK_LOG" 2>&1 &
echo "Flask started, PID: $!" echo "Flask started, PID: $!"
sleep 2 sleep 2
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then if ss -tlnp 2>/dev/null | grep -q ":$FLASK_PORT " || netstat -tlnp 2>/dev/null | grep -q ":$FLASK_PORT "; then
echo "✅ 端口 5000 正常" echo "✅ 端口 $FLASK_PORT 正常"
else else
echo "⚠️ 端口 5000 未监听,检查 /tmp/agv_flask.log" echo "⚠️ 端口 $FLASK_PORT 未监听,检查 $FLASK_LOG"
fi fi
+12 -7
View File
@@ -7,6 +7,11 @@
# ============================================================ # ============================================================
set -e set -e
ROS_DISTRO="${ROS_DISTRO:-humble}"
ROS_SETUP="${ROS_SETUP:-/opt/ros/$ROS_DISTRO/setup.bash}"
LOCK_DIR="${LOCK_DIR:-/tmp}"
FASTRTPS_SHM_DIR="${FASTRTPS_SHM_DIR:-/dev/shm}"
echo "==========================================" echo "=========================================="
echo " Robot AGV 全量停止" echo " Robot AGV 全量停止"
echo "==========================================" echo "=========================================="
@@ -40,17 +45,17 @@ sleep 1
# ---------- 3. 【关键】清理 FastRTPS 共享内存 ---------- # ---------- 3. 【关键】清理 FastRTPS 共享内存 ----------
echo "[3/5] 清理 FastRTPS 共享内存..." echo "[3/5] 清理 FastRTPS 共享内存..."
FASTRTPS_COUNT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0) FASTRTPS_COUNT=$(ls "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null | wc -l || echo 0)
if [ "$FASTRTPS_COUNT" -gt 0 ]; then if [ "$FASTRTPS_COUNT" -gt 0 ]; then
rm -rf /dev/shm/fastrtps_* rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_*
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件" echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
else else
echo " 无 FastRTPS 文件残留" echo " 无 FastRTPS 文件残留"
fi fi
# 清理 scan_fixer 锁文件 # 清理 scan_fixer 锁文件
rm -f /tmp/scan_fixer.lock rm -f "$LOCK_DIR/scan_fixer.lock"
rm -f /tmp/clock_publisher.lock rm -f "$LOCK_DIR/clock_publisher.lock"
echo " ✅ FastRTPS 清理完成" echo " ✅ FastRTPS 清理完成"
# ---------- 4. 【关键】重置 ros2 daemon ---------- # ---------- 4. 【关键】重置 ros2 daemon ----------
@@ -58,14 +63,14 @@ echo "[4/5] 重置 ros2 daemon..."
pkill -f "ros2-daemon" 2>/dev/null || true pkill -f "ros2-daemon" 2>/dev/null || true
pkill -9 -f "ros2-daemon" 2>/dev/null || true pkill -9 -f "ros2-daemon" 2>/dev/null || true
sleep 2 sleep 2
source /opt/ros/humble/setup.bash 2>/dev/null || true source "$ROS_SETUP" 2>/dev/null || true
ros2 daemon stop 2>/dev/null || true ros2 daemon stop 2>/dev/null || true
echo " ✅ ros2 daemon 已重置" echo " ✅ ros2 daemon 已重置"
# ---------- 5. 验证清理结果 ---------- # ---------- 5. 验证清理结果 ----------
echo "[5/5] 验证清理结果..." echo "[5/5] 验证清理结果..."
PROC_COUNT=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|clock_publisher|app.py|ros2-daemon' | grep -v grep | wc -l || echo 0) PROC_COUNT=$(ps aux | grep -E 'agv_pro_node|lslidar_driver_node|component_container|fix_scan_timestamp|clock_publisher|app.py|ros2-daemon' | grep -v grep | wc -l || echo 0)
FASTRTPS_LEFT=$(ls /dev/shm/fastrtps_* 2>/dev/null | wc -l || echo 0) FASTRTPS_LEFT=$(ls "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null | wc -l || echo 0)
echo " 残留进程数: $PROC_COUNT" echo " 残留进程数: $PROC_COUNT"
echo " FastRTPS 文件数: $FASTRTPS_LEFT" echo " FastRTPS 文件数: $FASTRTPS_LEFT"
@@ -85,7 +90,7 @@ else
echo " pkill -9 -f 'agv_pro_node|lslidar|component_container'" echo " pkill -9 -f 'agv_pro_node|lslidar|component_container'"
echo " pkill -9 -f 'fix_scan_timestamp|app.py'" echo " pkill -9 -f 'fix_scan_timestamp|app.py'"
echo " pkill -9 -f 'ros2-daemon'" echo " pkill -9 -f 'ros2-daemon'"
echo " rm -rf /dev/shm/fastrtps_*" echo " rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
fi fi
echo "" echo ""
echo " 现在可以安全运行 ./start_all.sh" echo " 现在可以安全运行 ./start_all.sh"