From 1429442dbd3c8e50c3e17c11d766faf31db8073a Mon Sep 17 00:00:00 2001 From: FaulknerWu Date: Mon, 22 Jun 2026 10:18:20 +0800 Subject: [PATCH] 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) --- agv_app/utils/qr_scanner.py | 159 ++++- agv_pro_ros2/param/agvpro.yaml | 4 +- arm/arm_server.py | 408 ------------- arm_server/arm_server.py | 149 ++--- arm_server/arm_server.service | 9 +- customs-tablet-frontend/.eslintrc.json | 3 - customs-tablet-frontend/.gitignore | 36 -- customs-tablet-frontend/README.md | 36 -- customs-tablet-frontend/next.config.mjs | 4 - .../src/app/customs/page.tsx | 175 ------ .../src/app/inspection/page.tsx | 529 ---------------- .../src/app/video/page.tsx | 166 ----- .../src/components/TopHeader.tsx | 108 ---- .../src/services/mockApi.ts | 145 ----- public-frontend/.eslintrc.json | 3 + public-frontend/.gitignore | 9 + public-frontend/README.md | 9 + public-frontend/next.config.mjs | 19 + .../package-lock.json | 36 +- .../package.json | 5 +- public-frontend/src/app/customs/page.tsx | 243 ++++++++ .../src/app/favicon.ico | Bin .../src/app/fonts/GeistMonoVF.woff | Bin .../src/app/fonts/GeistVF.woff | Bin .../src/app/globals.css | 16 +- public-frontend/src/app/inspection/page.tsx | 570 ++++++++++++++++++ .../src/app/layout.tsx | 25 +- .../src/app/machines/[serialNumber]/page.tsx | 112 ++-- .../src/app/machines/page.tsx | 140 +++-- .../src/app/page.tsx | 170 +++--- public-frontend/src/app/video/page.tsx | 113 ++++ .../src/components/Breadcrumb.tsx | 24 +- .../src/components/CameraFrame.tsx | 91 +++ .../src/components/StatusBadge.tsx | 39 +- public-frontend/src/components/TopHeader.tsx | 144 +++++ public-frontend/src/services/apiClient.ts | 66 ++ public-frontend/src/services/backendApi.ts | 280 +++++++++ public-frontend/src/services/normalizers.ts | 289 +++++++++ .../src/store/useAppStore.ts | 27 +- .../src/types/index.ts | 182 +++--- .../tsconfig.json | 0 scan_fixer/clock_publisher.py | 68 +++ scan_fixer/fix_scan_timestamp_v6.py | 85 +++ scripts/README.md | 5 +- scripts/dev_start.sh | 12 +- scripts/restart_flask.sh | 30 +- scripts/start_all.sh | 111 ++-- scripts/start_flask.sh | 26 +- scripts/stop_all.sh | 19 +- 49 files changed, 2758 insertions(+), 2141 deletions(-) delete mode 100644 arm/arm_server.py delete mode 100644 customs-tablet-frontend/.eslintrc.json delete mode 100644 customs-tablet-frontend/.gitignore delete mode 100644 customs-tablet-frontend/README.md delete mode 100644 customs-tablet-frontend/next.config.mjs delete mode 100644 customs-tablet-frontend/src/app/customs/page.tsx delete mode 100644 customs-tablet-frontend/src/app/inspection/page.tsx delete mode 100644 customs-tablet-frontend/src/app/video/page.tsx delete mode 100644 customs-tablet-frontend/src/components/TopHeader.tsx delete mode 100644 customs-tablet-frontend/src/services/mockApi.ts create mode 100644 public-frontend/.eslintrc.json create mode 100644 public-frontend/.gitignore create mode 100644 public-frontend/README.md create mode 100644 public-frontend/next.config.mjs rename {customs-tablet-frontend => public-frontend}/package-lock.json (99%) rename {customs-tablet-frontend => public-frontend}/package.json (88%) create mode 100644 public-frontend/src/app/customs/page.tsx rename {customs-tablet-frontend => public-frontend}/src/app/favicon.ico (100%) rename {customs-tablet-frontend => public-frontend}/src/app/fonts/GeistMonoVF.woff (100%) rename {customs-tablet-frontend => public-frontend}/src/app/fonts/GeistVF.woff (100%) rename {customs-tablet-frontend => public-frontend}/src/app/globals.css (69%) create mode 100644 public-frontend/src/app/inspection/page.tsx rename {customs-tablet-frontend => public-frontend}/src/app/layout.tsx (63%) rename {customs-tablet-frontend => public-frontend}/src/app/machines/[serialNumber]/page.tsx (68%) rename {customs-tablet-frontend => public-frontend}/src/app/machines/page.tsx (51%) rename {customs-tablet-frontend => public-frontend}/src/app/page.tsx (53%) create mode 100644 public-frontend/src/app/video/page.tsx rename {customs-tablet-frontend => public-frontend}/src/components/Breadcrumb.tsx (80%) create mode 100644 public-frontend/src/components/CameraFrame.tsx rename {customs-tablet-frontend => public-frontend}/src/components/StatusBadge.tsx (79%) create mode 100644 public-frontend/src/components/TopHeader.tsx create mode 100644 public-frontend/src/services/apiClient.ts create mode 100644 public-frontend/src/services/backendApi.ts create mode 100644 public-frontend/src/services/normalizers.ts rename {customs-tablet-frontend => public-frontend}/src/store/useAppStore.ts (61%) rename {customs-tablet-frontend => public-frontend}/src/types/index.ts (53%) rename {customs-tablet-frontend => public-frontend}/tsconfig.json (100%) create mode 100644 scan_fixer/clock_publisher.py create mode 100644 scan_fixer/fix_scan_timestamp_v6.py diff --git a/agv_app/utils/qr_scanner.py b/agv_app/utils/qr_scanner.py index 41b17b9..c2d9f71 100644 --- a/agv_app/utils/qr_scanner.py +++ b/agv_app/utils/qr_scanner.py @@ -1,15 +1,20 @@ """ -二维码识别模块 - 使用 OpenCV 识别二维码获取 serialNumber +二维码识别模块 - 使用 AGV 摄像头识别二维码获取 serialNumber + +优先通过 v4l2-ctl 读取 MJPG 帧,避开部分 Jetson/arm64 环境中 OpenCV +直接读取 MJPG 花屏或解码失败的问题;v4l2-ctl 不可用时回退到 OpenCV。 """ import cv2 import time import logging +import os +import subprocess 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__) -# 尝试导入二维码识别库 try: from pyzbar.pyzbar import decode as qr_decode PYZBAR_AVAILABLE = True @@ -19,17 +24,76 @@ except ImportError: 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.width = width + self.height = height + self.prefer_v4l2_ctl = prefer_v4l2_ctl 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: """打开摄像头""" + 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: - # 强制 V4L2 后端 self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_V4L2) if not self._cap.isOpened(): self._cap = cv2.VideoCapture(self.device_index) @@ -38,12 +102,10 @@ class QRScanner: logger.error(f"无法打开摄像头 {self.device_index}") return False - # 确保 OpenCV 做 BGR 转换(部分 V4L2 后端默认不做 YUYV→BGR 转换) self._cap.set(cv2.CAP_PROP_CONVERT_RGB, 1) - # 设置分辨率(使用默认分辨率,不强制 MJPG) w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - logger.info(f"摄像头 {self.device_index} 已打开,分辨率 {w}x{h}") + logger.info(f"摄像头 {self.device_index} 使用 OpenCV 读取,分辨率 {w}x{h}") return True except Exception as e: logger.error(f"摄像头打开失败: {e}") @@ -53,6 +115,47 @@ class QRScanner: if self._cap: self._cap.release() 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]: """修复绿屏/格式错误帧,返回修复后的 BGR 帧或 None""" @@ -101,13 +204,10 @@ class QRScanner: # 正常 BGR 帧 return frame - def read_frame(self, timeout: float = 2.0) -> Optional[np.ndarray]: - """读取一帧(带超时保护,避免 V4L2 select() 永久阻塞)""" + def _read_frame_with_opencv(self, timeout: float) -> Optional[np.ndarray]: if not self._cap or not self._cap.isOpened(): return None - from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout - pool = ThreadPoolExecutor(max_workers=1) try: fut = pool.submit(self._cap.read) @@ -131,17 +231,43 @@ class QRScanner: finally: 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]: """从图像帧中检测二维码""" if frame is None: return None + + if len(frame.shape) == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + try: - # OpenCV 内置二维码检测 data, vertices, _ = self._qr_detector.detectAndDecode(frame) if data and len(data) > 0: return data.strip() 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 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]: """多次扫描直到成功或达到最大次数""" for i in range(max_attempts): + logger.debug(f"QR 扫描尝试 {i + 1}/{max_attempts}") result = self.scan_once() if result: return result diff --git a/agv_pro_ros2/param/agvpro.yaml b/agv_pro_ros2/param/agvpro.yaml index 3a3b844..7f35780 100644 --- a/agv_pro_ros2/param/agvpro.yaml +++ b/agv_pro_ros2/param/agvpro.yaml @@ -211,7 +211,7 @@ local_costmap: mark_threshold: 0 observation_sources: scan scan: - topic: /scan_corrected_corrected + topic: /scan_corrected max_obstacle_height: 2.0 clearing: True marking: True @@ -247,7 +247,7 @@ global_costmap: enabled: True observation_sources: scan scan: - topic: /scan_corrected_corrected + topic: /scan_corrected max_obstacle_height: 2.0 clearing: True marking: True diff --git a/arm/arm_server.py b/arm/arm_server.py deleted file mode 100644 index 6488799..0000000 --- a/arm/arm_server.py +++ /dev/null @@ -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() diff --git a/arm_server/arm_server.py b/arm_server/arm_server.py index e340bba..3a0af67 100644 --- a/arm_server/arm_server.py +++ b/arm_server/arm_server.py @@ -1,7 +1,7 @@ """ 机械臂服务端 - 机械臂端主程序 运行在 10.247.46.165 上,端口 5002 (TCP) + 5003 (视频流) -通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (630 Socket API) +通过 TCP Socket 接收 AGV 发来的指令,转发给 RoboFlow (ElephantRobot) 同时通过 ffmpeg 提供 HTTP 视频流 """ import socket @@ -11,6 +11,8 @@ 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 @@ -41,6 +43,19 @@ _frame_cond = threading.Condition() _latest_frame = None _latest_frame_ts = 0.0 _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(): @@ -57,8 +72,8 @@ def _stop_ffmpeg(): def _frame_reader(): - """从 ffmpeg 的连续 MJPEG 输出中解析 JPEG 帧,并缓存最新一帧。""" - global _ffmpeg_proc, _latest_frame, _latest_frame_ts + """从 ffmpeg 的连续 MJPEG 输出中解析、校验并缓存最新一帧。""" + global _ffmpeg_proc, _latest_frame, _latest_frame_ts, _invalid_count buf = b"" while not _stop_ffmpeg_reader.is_set(): proc = _ffmpeg_proc @@ -72,6 +87,11 @@ def _frame_reader(): 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 @@ -83,15 +103,24 @@ def _frame_reader(): break frame = buf[start:end + 2] buf = buf[end + 2:] - with _frame_cond: - _latest_frame = frame - _latest_frame_ts = time.time() - _frame_cond.notify_all() + 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 def _ensure_ffmpeg(): """确保 ffmpeg 进程在运行,自动重启崩溃的进程""" - global _ffmpeg_proc, _ffmpeg_thread + global _ffmpeg_proc, _ffmpeg_thread, _invalid_count with _ffmpeg_lock: if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return @@ -100,6 +129,7 @@ def _ensure_ffmpeg(): 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( @@ -107,11 +137,11 @@ def _ensure_ffmpeg(): "ffmpeg", "-f", "v4l2", "-input_format", "mjpeg", - "-framerate", "12", - "-video_size", "1280x720", + "-framerate", "8", + "-video_size", "640x480", "-i", f"/dev/video{ARM_CAMERA_INDEX}", - "-vf", "rotate=PI", - "-q:v", "4", + "-fflags", "nobuffer", + "-analyzeduration", "0", "-f", "mjpeg", "-" ], @@ -166,17 +196,22 @@ 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}) + 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 + 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}) @@ -193,72 +228,18 @@ def arm_camera_snapshot(): logger.warning("snapshot failed: no cached frame") 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 指令 ========== 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.port = port self._sock: socket.socket = None self._running = False - self.roboflow: RoboFlowClient = None - self._connect_roboflow() - - def _connect_roboflow(self): - self.roboflow = RoboFlowClient() - if self.roboflow.connect(): - logger.info("RoboFlow 连接成功(上电由 power_on_arm() 完成)") - else: - logger.warning("RoboFlow 连接失败,服务将以 limited 模式运行") + self._elephant = elephant + if self._elephant is None: + logger.warning("ElephantRobot 实例为空,命令将返回错误") def start(self): self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -312,10 +293,10 @@ class AGVCommandServer: logger.info("AGV 客户端已断开") def _execute_command(self, cmd: str) -> str: - if not self.roboflow or not self.roboflow._sock: - return f"ERROR: RoboFlow not connected" + if self._elephant is None: + return "ERROR: Robot not initialized" try: - return self.roboflow.send_recv(cmd) + return self._elephant.send_command(cmd) except Exception as e: return f"ERROR: {e}" @@ -326,16 +307,13 @@ class AGVCommandServer: self._sock.close() except: pass - if self.roboflow: - self.roboflow.close() logger.info("机械臂服务端已停止") # ========== 入口 ========== -import time - def power_on_arm(max_retries: int = 5) -> bool: """通过 ElephantRobot 给机械臂上电并激活(带重试)""" + global _elephant from pymycobot import ElephantRobot 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秒...") time.sleep(5) logger.info("✅ 机械臂上电+激活 全部完成") + _elephant = el return True except Exception as e: logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}") @@ -372,10 +351,9 @@ def main(): # 先通过 ElephantRobot 给机械臂上电并激活 power_on_arm() - server = AGVCommandServer(port=5002) + server = AGVCommandServer(_elephant, port=5002) # 启动 Flask 视频流服务(端口 5003) - from werkzeug.serving import make_server arm_server_http = None for attempt in range(5): try: @@ -393,11 +371,16 @@ def main(): def signal_handler(sig, frame): logger.info("收到停止信号...") - global _ffmpeg_proc + global _ffmpeg_proc, _elephant if _ffmpeg_proc: _ffmpeg_proc.terminate() server.stop() arm_server_http.shutdown() + if _elephant: + try: + _elephant.stop_client() + except Exception: + pass sys.exit(0) signal.signal(signal.SIGINT, signal_handler) diff --git a/arm_server/arm_server.service b/arm_server/arm_server.service index 49dec51..df191cc 100644 --- a/arm_server/arm_server.service +++ b/arm_server/arm_server.service @@ -6,14 +6,13 @@ Wants=network-online.target [Service] Type=simple User=pi -WorkingDirectory=/home/pi/work/smart-inspection/arm_server -Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-/etc/default/arm_server 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 RestartSec=5 -StandardOutput=append:/home/pi/work/smart-inspection/arm_server/stdout.log -StandardError=append:/home/pi/work/smart-inspection/arm_server/stderr.log +StandardOutput=journal +StandardError=journal [Install] WantedBy=multi-user.target diff --git a/customs-tablet-frontend/.eslintrc.json b/customs-tablet-frontend/.eslintrc.json deleted file mode 100644 index 3722418..0000000 --- a/customs-tablet-frontend/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} diff --git a/customs-tablet-frontend/.gitignore b/customs-tablet-frontend/.gitignore deleted file mode 100644 index fd3dbb5..0000000 --- a/customs-tablet-frontend/.gitignore +++ /dev/null @@ -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 diff --git a/customs-tablet-frontend/README.md b/customs-tablet-frontend/README.md deleted file mode 100644 index e215bc4..0000000 --- a/customs-tablet-frontend/README.md +++ /dev/null @@ -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. diff --git a/customs-tablet-frontend/next.config.mjs b/customs-tablet-frontend/next.config.mjs deleted file mode 100644 index 4678774..0000000 --- a/customs-tablet-frontend/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/customs-tablet-frontend/src/app/customs/page.tsx b/customs-tablet-frontend/src/app/customs/page.tsx deleted file mode 100644 index a340cbd..0000000 --- a/customs-tablet-frontend/src/app/customs/page.tsx +++ /dev/null @@ -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([]); - const [filteredData, setFilteredData] = useState([]); - 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 ; - }; - - return ( -
- - - {errorMessage && ( - - )} - - -
- -
- - - - - - - - - - - - } - onPressEnter={handleSearch} - style={{ width: 250 }} - /> - - - - - - - - - - - - - -
- {text}} /> - } /> - `${count} 台`} /> - - ( - - {record.status === 'pending' || record.status === 'inspecting' ? ( - - ) : ( - - )} - - )} - /> -
- - - ); -} diff --git a/customs-tablet-frontend/src/app/inspection/page.tsx b/customs-tablet-frontend/src/app/inspection/page.tsx deleted file mode 100644 index f87c70e..0000000 --- a/customs-tablet-frontend/src/app/inspection/page.tsx +++ /dev/null @@ -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 ( - - - - }> - - - ); -} - -function InspectionContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const customsId = searchParams.get('customsId'); - const { selectedCustoms, setSelectedCustoms } = useAppStore(); - const [currentCustoms, setCurrentCustoms] = useState(null); - const [loadingCustoms, setLoadingCustoms] = useState(true); - const [status, setStatus] = useState('idle'); - const [logs, setLogs] = useState<{time: string, msg: string, type: 'info'|'warning'|'success'}[]>([]); - const [progressData, setProgressData] = useState([]); - const [isPauseModalVisible, setIsPauseModalVisible] = useState(false); - const [pauseReason, setPauseReason] = useState(''); - const [customsList, setCustomsList] = useState([]); - const [loadingList, setLoadingList] = useState(false); - - // Issues and cameras - const [issues, setIssues] = useState([]); - const [cameras, setCameras] = useState([]); - - // The segmented control options based on overview cameras - const [currentOverviewCamera, setCurrentOverviewCamera] = useState(''); - - const { token } = theme.useToken(); - const logsEndRef = useRef(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 = [ - { - title: '时间', - dataIndex: 'time', - key: 'time', - width: 90, - render: (text) => {text} - }, - { - title: '问题描述', - dataIndex: 'description', - key: 'description', - render: (text, record) => ( - - - {text} - - ) - }, - { - 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 {info.text}; - } - }, - { - title: '操作', - key: 'action', - width: 140, - render: (_, record) => ( - record.status === 'pending' ? ( - - - - - ) : null - ), - }, - ]; - - if (loadingCustoms) { - return ( - - - - ); - } - - return ( -
- {/* 顶部工具条:面包屑 + 报关单选择器 + 状态指示灯 */} - - - - {currentCustoms && ( - {status === 'running' ? '作业中' : status === 'idle' ? '待作业' : status === 'paused' ? '已暂停' : '已完成'}} - /> - )} -