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:
+143
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+66
-83
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
dist
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
+18
-18
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "customs-tablet-frontend",
|
||||
"name": "public-frontend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "customs-tablet-frontend",
|
||||
"name": "public-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
@@ -1266,12 +1266,6 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz",
|
||||
@@ -4642,9 +4636,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.13",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
|
||||
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
|
||||
"version": "3.3.14",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz",
|
||||
"integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5117,6 +5111,13 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -5174,10 +5175,9 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"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/react-photoswipe-gallery": {
|
||||
@@ -5426,9 +5426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "customs-tablet-frontend",
|
||||
"name": "public-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
@@ -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 {
|
||||
--color-border-light: #f0f0f0;
|
||||
}
|
||||
@@ -13,7 +12,7 @@ body {
|
||||
body {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
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;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -50,3 +49,16 @@ a {
|
||||
overflow-y: auto;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.cameraFrameImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.scanCorner {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -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 { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type { Metadata } from 'next';
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { ConfigProvider, App } from 'antd';
|
||||
import { TopHeader } from '../components/TopHeader';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
import { App, ConfigProvider } from 'antd';
|
||||
import './globals.css';
|
||||
import { TopHeader } from '@/components/TopHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "海关平板前端系统",
|
||||
description: "海关查验系统平板端原型",
|
||||
title: '海关智慧查验平台',
|
||||
description: '海关查验系统平板端',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -19,7 +16,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${inter.className} appBody`}>
|
||||
<body className="appBody">
|
||||
<AntdRegistry>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@@ -34,16 +31,14 @@ export default function RootLayout({
|
||||
Statistic: {
|
||||
contentFontSize: 32,
|
||||
titleFontSize: 16,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App className="antAppRoot">
|
||||
<div className="appShell">
|
||||
<TopHeader />
|
||||
<main className="appMain">
|
||||
{children}
|
||||
</main>
|
||||
<main className="appMain">{children}</main>
|
||||
</div>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
+53
-59
@@ -1,18 +1,20 @@
|
||||
'use client';
|
||||
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import { Breadcrumb } from '../../../components/Breadcrumb';
|
||||
import { MockApi } from '../../../services/mockApi';
|
||||
import { MachineDetail, ImageItem } from '../../../types';
|
||||
import { StatusBadge } from '../../../components/StatusBadge';
|
||||
import { Breadcrumb } from '@/components/Breadcrumb';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
import { BackendApi } from '@/services/backendApi';
|
||||
import type { InspectionRecord, MachineDetail, MachineImageItem } from '@/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function MachineDetailPage({ params }: { params: { serialNumber: string } }) {
|
||||
const router = useRouter();
|
||||
const serialNumber = decodeURIComponent(params.serialNumber);
|
||||
const [machine, setMachine] = useState<MachineDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -24,13 +26,13 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
|
||||
try {
|
||||
setLoading(true);
|
||||
setErrorMessage('');
|
||||
const data = await MockApi.getMachineDetail(params.serialNumber);
|
||||
const data = await BackendApi.getMachineDetail(serialNumber);
|
||||
if (!isMounted) return;
|
||||
setMachine(data);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
setMachine(null);
|
||||
setErrorMessage('机器详情加载失败,请稍后重试');
|
||||
setErrorMessage(error instanceof Error ? error.message : '机器详情加载失败,请稍后重试');
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
@@ -43,7 +45,41 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
|
||||
return () => {
|
||||
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) {
|
||||
return (
|
||||
@@ -57,12 +93,7 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
|
||||
return (
|
||||
<Flex vertical align="center" style={{ padding: 48 }}>
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={errorMessage}
|
||||
showIcon
|
||||
style={{ maxWidth: 480, marginBottom: 16 }}
|
||||
/>
|
||||
<Alert type="error" message={errorMessage} showIcon style={{ maxWidth: 480, marginBottom: 16 }} />
|
||||
)}
|
||||
<Empty description="暂无机器详情" />
|
||||
<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 = [
|
||||
{ key: 'incoming', label: '来料检验单', children: renderImageGroup(machine.images.incomingInspection) },
|
||||
{ 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>
|
||||
</Flex>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert type="warning" message={errorMessage} showIcon style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Card title="机器基本信息" style={{ marginBottom: 24 }}>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col span={8}>
|
||||
@@ -132,7 +130,7 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
|
||||
<Text type="secondary">机器型号:</Text> <Text strong>{machine.modelName}</Text>
|
||||
</Col>
|
||||
<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 span={8}>
|
||||
<Text type="secondary">当前状态:</Text> <StatusBadge status={machine.status} />
|
||||
@@ -154,13 +152,9 @@ export default function MachineDetailPage({ params }: { params: { serialNumber:
|
||||
dataSource={machine.inspectionRecords}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={recordColumns}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
+76
-64
@@ -1,53 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Input, Button, Table, Typography, Space, Modal, Upload, Flex } from 'antd';
|
||||
import { CameraOutlined, BarcodeOutlined, FileImageOutlined, SearchOutlined, BulbOutlined } from '@ant-design/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Col, Flex, Input, Modal, Row, Space, Table, Typography, Upload, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { BarcodeOutlined, BulbOutlined, CameraOutlined, FileImageOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
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 { Dragger } = Upload;
|
||||
|
||||
const RECENT_QUERY_STORAGE_KEY = 'recent_queries';
|
||||
|
||||
export default function MachineQueryPage() {
|
||||
const router = useRouter();
|
||||
const [serialNumber, setSerialNumber] = useState('');
|
||||
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(() => {
|
||||
// Load recent queries from localStorage or mock
|
||||
const saved = localStorage.getItem('recent_queries');
|
||||
if (saved) {
|
||||
setRecentQueries(JSON.parse(saved));
|
||||
} else {
|
||||
setRecentQueries([
|
||||
{ serialNumber: 'BG042110276', name: '打印机型号A', time: '06-19 14:30' },
|
||||
{ serialNumber: 'BG042110285', name: '扫描仪型号B', time: '06-19 10:15' }
|
||||
]);
|
||||
const saved = window.localStorage.getItem(RECENT_QUERY_STORAGE_KEY);
|
||||
if (!saved) {
|
||||
setRecentQueries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setRecentQueries(JSON.parse(saved) as RecentMachineQuery[]);
|
||||
} catch {
|
||||
setRecentQueries([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSearch = (sn: string) => {
|
||||
if (!sn) return;
|
||||
|
||||
// Save to recent queries
|
||||
const newQuery = { serialNumber: sn, name: '未知设备 (模拟)', time: new Date().toLocaleString() };
|
||||
const updated = [newQuery, ...recentQueries.filter(q => q.serialNumber !== sn)].slice(0, 10);
|
||||
const saveRecentQuery = (nextSerialNumber: string) => {
|
||||
const query: RecentMachineQuery = {
|
||||
serialNumber: nextSerialNumber,
|
||||
name: '待后端返回',
|
||||
time: new Date().toLocaleString(),
|
||||
};
|
||||
const updated = [query, ...recentQueries.filter((item) => item.serialNumber !== nextSerialNumber)].slice(0, 10);
|
||||
setRecentQueries(updated);
|
||||
localStorage.setItem('recent_queries', JSON.stringify(updated));
|
||||
|
||||
router.push(`/machines/${sn}`);
|
||||
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 (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<Breadcrumb />
|
||||
|
||||
|
||||
<Card title="查询方式选择" style={{ marginBottom: 24 }}>
|
||||
<Row gutter={48}>
|
||||
<Col span={12} style={{ borderRight: '1px solid var(--color-border-light)' }}>
|
||||
<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>
|
||||
<Text type="secondary">使用平板摄像头扫描机器机身二维码</Text>
|
||||
<Button type="primary" size="large" onClick={() => setIsScanModalVisible(true)}>打开扫码</Button>
|
||||
@@ -55,7 +86,7 @@ export default function MachineQueryPage() {
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<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>
|
||||
<Text type="secondary">输入机器序列号精确查询机器信息</Text>
|
||||
<Space.Compact style={{ width: '80%' }}>
|
||||
@@ -63,7 +94,7 @@ export default function MachineQueryPage() {
|
||||
placeholder="请输入序列号..."
|
||||
size="large"
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value)}
|
||||
onChange={(event) => setSerialNumber(event.target.value)}
|
||||
onPressEnter={() => handleSearch(serialNumber)}
|
||||
/>
|
||||
<Button type="primary" size="large" icon={<SearchOutlined />} onClick={() => handleSearch(serialNumber)}>查询</Button>
|
||||
@@ -74,22 +105,20 @@ export default function MachineQueryPage() {
|
||||
</Card>
|
||||
|
||||
<Card title="或上传二维码照片识别" style={{ marginBottom: 24 }}>
|
||||
<Upload.Dragger
|
||||
<Dragger
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={({ onSuccess }) => {
|
||||
setTimeout(() => {
|
||||
onSuccess?.('ok');
|
||||
handleSearch('BG042110276'); // 模拟识别成功
|
||||
}, 1000);
|
||||
beforeUpload={() => {
|
||||
messageApi.info('图片二维码识别后端暂未提供,请手动输入序列号');
|
||||
return Upload.LIST_IGNORE;
|
||||
}}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<FileImageOutlined style={{ fontSize: 48 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">拖拽或点击上传二维码照片</p>
|
||||
<p className="ant-upload-hint">支持 JPG / PNG / BMP,自动识别二维码内容</p>
|
||||
</Upload.Dragger>
|
||||
<p className="ant-upload-hint">当前版本保留入口,待后端提供图片识别接口后接入</p>
|
||||
</Dragger>
|
||||
</Card>
|
||||
|
||||
<Card title="最近查询记录">
|
||||
@@ -98,17 +127,8 @@ export default function MachineQueryPage() {
|
||||
rowKey="serialNumber"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
>
|
||||
<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>
|
||||
columns={columns}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
@@ -121,7 +141,7 @@ export default function MachineQueryPage() {
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setIsScanModalVisible(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</Button>,
|
||||
]}
|
||||
width={600}
|
||||
centered
|
||||
@@ -134,40 +154,32 @@ export default function MachineQueryPage() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
background: '#000000'
|
||||
background: '#000000',
|
||||
}}
|
||||
>
|
||||
<Flex vertical align="center" gap={16} style={{ color: '#ffffff', zIndex: 1 }}>
|
||||
<CameraOutlined style={{ fontSize: 48, opacity: 0.5 }} />
|
||||
<div>平板摄像头实时画面模拟</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsScanModalVisible(false);
|
||||
handleSearch('BG042110276');
|
||||
}}
|
||||
>
|
||||
模拟扫码成功 (BG042110276)
|
||||
</Button>
|
||||
<div>平板摄像头扫码入口占位</div>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.65)' }}>浏览器扫码与后端识别接口尚未接入</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 模拟扫码框 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 200,
|
||||
height: 200,
|
||||
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 style={{ position: 'absolute', top: -2, right: -2, width: 20, height: 20, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }}></div>
|
||||
<div style={{ position: 'absolute', bottom: -2, left: -2, width: 20, height: 20, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }}></div>
|
||||
<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={{ top: -2, left: -2, borderTop: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
|
||||
<div className="scanCorner" style={{ top: -2, right: -2, borderTop: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
|
||||
<div className="scanCorner" style={{ bottom: -2, left: -2, borderBottom: '4px solid #1890ff', borderLeft: '4px solid #1890ff' }} />
|
||||
<div className="scanCorner" style={{ bottom: -2, right: -2, borderBottom: '4px solid #1890ff', borderRight: '4px solid #1890ff' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#666666' }}>
|
||||
<BulbOutlined /> 将二维码对准框内,自动识别
|
||||
<BulbOutlined /> 可先使用序列号查询真实后端资料
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -1,23 +1,24 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
ProfileOutlined,
|
||||
RightOutlined,
|
||||
ScanOutlined,
|
||||
SearchOutlined,
|
||||
SyncOutlined,
|
||||
VideoCameraOutlined,
|
||||
RightOutlined,
|
||||
ClockCircleOutlined,
|
||||
ProfileOutlined
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MockApi } from '../services/mockApi';
|
||||
import { CustomsStats, ActivityItem, CustomsDeclaration } from '../types';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { BackendApi } from '@/services/backendApi';
|
||||
import type { ActivityItem, CustomsDeclaration, CustomsStats } from '@/types';
|
||||
import { StatusBadge } from '@/components/StatusBadge';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
@@ -25,6 +26,7 @@ export default function DashboardPage() {
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [pendingCustoms, setPendingCustoms] = useState<CustomsDeclaration[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -32,20 +34,20 @@ export default function DashboardPage() {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setErrorMessage('');
|
||||
const [statsData, actData, customsData] = await Promise.all([
|
||||
MockApi.getCustomsStats(),
|
||||
MockApi.getRecentActivities(),
|
||||
MockApi.getPendingCustoms()
|
||||
const [statsData, activityData, customsData] = await Promise.all([
|
||||
BackendApi.getCustomsStats(),
|
||||
BackendApi.getRecentActivities(),
|
||||
BackendApi.getCustomsList(1, 10),
|
||||
]);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setStats(statsData);
|
||||
setActivities(actData);
|
||||
setPendingCustoms(customsData);
|
||||
} catch {
|
||||
setActivities(activityData);
|
||||
setPendingCustoms(customsData.filter((item) => item.status === 'pending' || item.status === 'inspecting'));
|
||||
} catch (error) {
|
||||
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 = () => {
|
||||
if (inspectingCustoms) {
|
||||
router.push(`/inspection?customsId=${encodeURIComponent(inspectingCustoms.customsId)}`);
|
||||
return;
|
||||
const goToInspection = async () => {
|
||||
try {
|
||||
const inspection = await BackendApi.getCurrentInspection();
|
||||
if (inspection) {
|
||||
router.push(`/inspection?customsId=${encodeURIComponent(inspection.customsId)}`);
|
||||
return;
|
||||
}
|
||||
router.push('/customs');
|
||||
} catch {
|
||||
router.push('/customs');
|
||||
}
|
||||
|
||||
router.push('/customs');
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
@@ -72,84 +77,105 @@ export default function DashboardPage() {
|
||||
value: stats?.pendingCount || 0,
|
||||
icon: <FileTextOutlined />,
|
||||
suffix: '份报关单',
|
||||
valueStyle: { color: '#1890ff' },
|
||||
onClick: () => router.push('/customs')
|
||||
contentStyle: { color: '#1890ff' },
|
||||
onClick: () => router.push('/customs'),
|
||||
},
|
||||
{
|
||||
title: '今日已放行',
|
||||
value: stats?.releasedToday || 0,
|
||||
icon: <CheckCircleOutlined />,
|
||||
suffix: '份报关单',
|
||||
valueStyle: { color: '#52c41a' }
|
||||
contentStyle: { color: '#52c41a' },
|
||||
},
|
||||
{
|
||||
title: '查验进行中',
|
||||
value: stats?.inspectingCount || 0,
|
||||
icon: <SyncOutlined spin />,
|
||||
suffix: '个任务',
|
||||
valueStyle: { color: '#faad14' },
|
||||
onClick: goToInspection
|
||||
contentStyle: { color: '#faad14' },
|
||||
onClick: goToInspection,
|
||||
},
|
||||
{
|
||||
title: '异常',
|
||||
value: stats?.abnormalCount || 0,
|
||||
icon: <WarningOutlined />,
|
||||
suffix: '个异常',
|
||||
valueStyle: { color: '#ff4d4f' }
|
||||
contentStyle: { color: '#ff4d4f' },
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ title: '扫码查询机器', desc: '使用平板摄像头扫描设备二维码', icon: <ScanOutlined />, 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 (
|
||||
<div style={{ paddingBottom: 24 }}>
|
||||
{contextHolder}
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={errorMessage}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
action={<Button size="small" onClick={() => router.refresh()}>刷新</Button>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 统计卡片区域 */}
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((stat, idx) => (
|
||||
<Col span={6} key={idx}>
|
||||
<Card hoverable={!!stat.onClick} onClick={stat.onClick}>
|
||||
{statCards.map((stat) => (
|
||||
<Col span={6} key={stat.title}>
|
||||
<Card hoverable={Boolean(stat.onClick)} onClick={stat.onClick}>
|
||||
<Statistic
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
suffix={stat.suffix}
|
||||
prefix={stat.icon}
|
||||
valueStyle={stat.valueStyle}
|
||||
loading={!stats}
|
||||
styles={{ content: stat.contentStyle }}
|
||||
loading={!stats && !errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 快捷操作区域 */}
|
||||
<div style={{ marginBottom: 32, marginTop: 16 }}>
|
||||
<div style={{ fontSize: 18, marginBottom: 16, fontWeight: 600, color: '#333' }}>快捷功能板块</div>
|
||||
<Row gutter={24}>
|
||||
{quickActions.map((action, idx) => (
|
||||
<Col span={8} key={idx}>
|
||||
{quickActions.map((action) => (
|
||||
<Col span={8} key={action.title}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #f0f0f0',
|
||||
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
|
||||
@@ -162,7 +188,7 @@ export default function DashboardPage() {
|
||||
background: '#f0f5ff',
|
||||
color: action.color,
|
||||
fontSize: 28,
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
@@ -179,35 +205,38 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<Row gutter={24}>
|
||||
{/* 最近查验动态 */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title="最近查验动态"
|
||||
extra={<Button type="link" icon={<RightOutlined />}>查看全部</Button>}
|
||||
extra={<Button type="link" icon={<RightOutlined />} onClick={() => messageApi.info('后端暂未提供完整动态列表接口')}>查看全部</Button>}
|
||||
styles={{ body: { height: 'calc(100% - 57px)' } }}
|
||||
>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={activities}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
|
||||
{activities.length ? (
|
||||
<Flex vertical>
|
||||
{activities.map((item) => (
|
||||
<Flex
|
||||
key={item.id}
|
||||
align="center"
|
||||
gap={12}
|
||||
style={{ padding: '12px 0', borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
{item.type === 'start' ? <ClockCircleOutlined style={{ fontSize: 20 }} /> :
|
||||
item.type === 'success' ? <CheckCircleOutlined style={{ fontSize: 20 }} /> :
|
||||
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
|
||||
<ProfileOutlined style={{ fontSize: 20 }} />
|
||||
}
|
||||
title={<span style={{ fontWeight: 500 }}>{item.message}</span>}
|
||||
description={item.time}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
item.type === 'warning' ? <WarningOutlined style={{ fontSize: 20 }} /> :
|
||||
<ProfileOutlined style={{ fontSize: 20 }} />}
|
||||
<Flex vertical gap={2}>
|
||||
<span style={{ fontWeight: 500 }}>{item.message}</span>
|
||||
<span style={{ color: '#8c8c8c', fontSize: 13 }}>{item.time}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无后端动态" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 待查验报关单 */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title="待查验报关单"
|
||||
@@ -217,17 +246,14 @@ export default function DashboardPage() {
|
||||
<Table
|
||||
dataSource={pendingCustoms}
|
||||
rowKey="id"
|
||||
columns={pendingColumns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={() => ({
|
||||
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>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+12
-12
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Breadcrumb as AntdBreadcrumb } from 'antd';
|
||||
import { HomeOutlined } from '@ant-design/icons';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
const breadcrumbNameMap: Record<string, string> = {
|
||||
'/video': '视频监控',
|
||||
@@ -15,7 +15,7 @@ const breadcrumbNameMap: Record<string, string> = {
|
||||
|
||||
export const Breadcrumb: React.FC = () => {
|
||||
const pathname = usePathname();
|
||||
const pathSnippets = pathname.split('/').filter(i => i);
|
||||
const pathSnippets = pathname.split('/').filter(Boolean);
|
||||
|
||||
const extraBreadcrumbItems = pathSnippets.map((_, index) => {
|
||||
const url = `/${pathSnippets.slice(0, index + 1).join('/')}`;
|
||||
@@ -34,17 +34,17 @@ export const Breadcrumb: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
key: 'home',
|
||||
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||
},
|
||||
...extraBreadcrumbItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<AntdBreadcrumb items={breadcrumbItems} />
|
||||
<AntdBreadcrumb
|
||||
items={[
|
||||
{
|
||||
key: 'home',
|
||||
title: <Link href="/"><HomeOutlined /> 首页</Link>,
|
||||
},
|
||||
...extraBreadcrumbItems,
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+24
-15
@@ -1,17 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
MinusCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
QuestionCircleOutlined
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
} 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 {
|
||||
status: StatusType;
|
||||
@@ -19,7 +29,7 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge' }) => {
|
||||
const getStatusConfig = () => {
|
||||
const config = (() => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { color: 'warning', text: '待查验', icon: <ClockCircleOutlined style={{ color: '#faad14' }} /> };
|
||||
@@ -42,9 +52,7 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, type = 'badge'
|
||||
default:
|
||||
return { color: 'default', text: '未知', icon: <QuestionCircleOutlined style={{ color: '#d9d9d9' }} /> };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
})();
|
||||
|
||||
if (type === '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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
{config.icon} <span>{config.text}</span>
|
||||
{config.icon}
|
||||
<span>{config.text}</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 [];
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
+9
-18
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { User, Notification, CustomsDeclaration, InspectionState } from '../types';
|
||||
import type { CustomsDeclaration, InspectionState, Notification, User } from '@/types';
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
notifications: Notification[];
|
||||
selectedCustoms: CustomsDeclaration | null;
|
||||
inspection: InspectionState | null;
|
||||
|
||||
// Actions
|
||||
setUser: (user: User | null) => void;
|
||||
addNotification: (notification: Notification) => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
@@ -19,30 +19,21 @@ interface AppState {
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
user: { name: '张三', role: '海关查验员' },
|
||||
notifications: [
|
||||
{ id: '1', title: '异常告警', message: '入料口摄像头离线', time: '14:30', read: false },
|
||||
{ id: '2', title: '查验完成', message: '报关单 CD20260619002 查验完成', time: '14:15', read: false },
|
||||
{ id: '3', title: '系统通知', message: '新增 5 份待查验报关单', time: '14:00', read: true }
|
||||
{ id: '1', title: '系统通知', message: '远程查验前端已连接真实后端接口', time: '当前', read: false },
|
||||
],
|
||||
selectedCustoms: null,
|
||||
inspection: null,
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
addNotification: (notification) => set((state) => ({
|
||||
notifications: [notification, ...state.notifications]
|
||||
})),
|
||||
|
||||
addNotification: (notification) => set((state) => ({ notifications: [notification, ...state.notifications] })),
|
||||
markNotificationRead: (id) => set((state) => ({
|
||||
notifications: state.notifications.map(n =>
|
||||
n.id === id ? { ...n, read: true } : n
|
||||
)
|
||||
notifications: state.notifications.map((notification) => (
|
||||
notification.id === id ? { ...notification, read: true } : notification
|
||||
)),
|
||||
})),
|
||||
|
||||
setSelectedCustoms: (selectedCustoms) => set({ selectedCustoms }),
|
||||
|
||||
setInspection: (inspection) => set({ inspection }),
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface MachineDetail {
|
||||
serialNumber: string;
|
||||
modelName: string;
|
||||
modelId: string;
|
||||
customsId: string;
|
||||
customsName: string;
|
||||
status: 'pending' | 'inspecting' | 'released' | 'abnormal';
|
||||
specs: Record<string, string>;
|
||||
createdAt: string;
|
||||
images: {
|
||||
incomingInspection: ImageItem[];
|
||||
startupTestSample: ImageItem[];
|
||||
productionOrder: ImageItem[];
|
||||
robotInspection: ImageItem[];
|
||||
};
|
||||
inspectionRecords: InspectionRecord[];
|
||||
export type CustomsStatus = 'pending' | 'released' | 'abnormal' | 'inspecting';
|
||||
export type MissionRuntimeState = 'idle' | 'running' | 'paused' | 'completed';
|
||||
export type DeviceStatus = 'online' | 'offline';
|
||||
|
||||
export interface InspectionItem {
|
||||
inventoryCode: string;
|
||||
inventoryName: string;
|
||||
spec: string;
|
||||
quantify: number;
|
||||
inspected: number;
|
||||
}
|
||||
|
||||
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;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
@@ -45,66 +92,59 @@ export interface InspectionRecord {
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface CustomsStats {
|
||||
pendingCount: number;
|
||||
releasedToday: number;
|
||||
inspectingCount: number;
|
||||
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 {
|
||||
export interface MachineDetail {
|
||||
serialNumber: string;
|
||||
modelName: string;
|
||||
modelId: string;
|
||||
customsId: string;
|
||||
customsName: string;
|
||||
status: 'idle' | 'running' | 'paused' | 'completed';
|
||||
items: InspectionItem[];
|
||||
startedAt: number;
|
||||
currentMachine?: {
|
||||
machineId: string;
|
||||
serialNumber: string;
|
||||
step: string;
|
||||
status: CustomsStatus;
|
||||
specs: Record<string, string>;
|
||||
createdAt: string;
|
||||
images: {
|
||||
incomingInspection: MachineImageItem[];
|
||||
startupTestSample: MachineImageItem[];
|
||||
productionOrder: MachineImageItem[];
|
||||
robotInspection: MachineImageItem[];
|
||||
};
|
||||
inspectionRecords: InspectionRecord[];
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface InspectionItem {
|
||||
inventoryCode: string;
|
||||
inventoryName: string;
|
||||
spec: string;
|
||||
quantify: number;
|
||||
inspected: number;
|
||||
export interface SystemStatus {
|
||||
state: 'idle' | 'setting' | 'running' | 'paused';
|
||||
agvConnected: boolean;
|
||||
armConnected: boolean;
|
||||
cameraOpened: boolean;
|
||||
armCameraOpened: boolean;
|
||||
mapLoaded: boolean;
|
||||
pointsCount: number;
|
||||
modelsCount: number;
|
||||
machinesCount: number;
|
||||
hasAgvCamera: boolean;
|
||||
hasArmCamera: boolean;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
export interface MissionStateResponse {
|
||||
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;
|
||||
type: 'start' | 'success' | 'info' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -11,6 +11,9 @@ scripts/
|
||||
└── dev_start.sh ← 本地开发用(前台运行,不启动 ROS2)
|
||||
```
|
||||
|
||||
`scan_fixer/` 是生产启动链路的一部分:`clock_publisher.py` 发布 `/clock`,
|
||||
`fix_scan_timestamp_v6.py` 将 `/scan` 重发为 `/scan_corrected`,供 Nav2/AMCL 使用。
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 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_APP_DIR` | `$AGV_PROJECT_DIR/agv_app` | Flask 应用目录 |
|
||||
| `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 脚本名 |
|
||||
|
||||
## 日志位置(AGV 上)
|
||||
|
||||
@@ -8,7 +8,11 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
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 " 本地开发模式 - 仅启动 Flask"
|
||||
@@ -16,8 +20,8 @@ echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 切换到项目目录
|
||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
||||
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"
|
||||
|
||||
@@ -38,6 +42,6 @@ fi
|
||||
|
||||
# 使用前台模式运行(方便看日志和 Ctrl+C 停止)
|
||||
echo "启动 Flask (前台模式,Ctrl+C 停止)..."
|
||||
echo "访问: http://127.0.0.1:5000"
|
||||
echo "访问: http://127.0.0.1:$FLASK_PORT"
|
||||
echo ""
|
||||
exec uv run --locked python app.py
|
||||
|
||||
+20
-10
@@ -6,12 +6,22 @@
|
||||
# ============================================================
|
||||
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_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
|
||||
source "$AGV_ROS2_DIR/install/setup.bash" 2>/dev/null || true
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
source "$ROS_SETUP" 2>/dev/null || true
|
||||
source "$ROS_WORKSPACE_SETUP" 2>/dev/null || true
|
||||
|
||||
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 "uv run .*python app.py" 2>/dev/null || true
|
||||
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=$!
|
||||
echo " Flask PID: $FLASK_PID"
|
||||
|
||||
# 3. 验证
|
||||
echo "[3/3] 验证服务..."
|
||||
sleep 3
|
||||
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
|
||||
echo " ✅ 端口 5000 正常监听"
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$FLASK_PORT " || netstat -tlnp 2>/dev/null | grep -q ":$FLASK_PORT "; then
|
||||
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
|
||||
echo " ✅ arm_refresh 返回 JPEG"
|
||||
else
|
||||
echo " ⚠️ arm_refresh 返回异常(机械臂可能未连接)"
|
||||
fi
|
||||
else
|
||||
echo " ❌ 端口 5000 未监听,查看日志:"
|
||||
tail -10 /tmp/agv_flask.log
|
||||
echo " ❌ 端口 $FLASK_PORT 未监听,查看日志:"
|
||||
tail -10 "$FLASK_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+64
-47
@@ -10,13 +10,30 @@
|
||||
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_ROS2_DIR="${AGV_ROS2_DIR:-/home/elephant/agv_pro_ros2}"
|
||||
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-/home/elephant/work/scan_fixer}"
|
||||
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}"
|
||||
SCAN_FIXER_DIR="${SCAN_FIXER_DIR:-$AGV_PROJECT_DIR/scan_fixer}"
|
||||
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
|
||||
|
||||
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 " Robot AGV 全量启动 v4.0"
|
||||
echo "=========================================="
|
||||
@@ -56,16 +73,16 @@ sleep 2
|
||||
|
||||
# 【关键】清理 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
|
||||
rm -rf /dev/shm/fastrtps_*
|
||||
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_*
|
||||
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
|
||||
else
|
||||
echo " 无 FastRTPS 文件残留"
|
||||
fi
|
||||
|
||||
# 清理 scan_fixer 锁文件
|
||||
rm -f /tmp/scan_fixer.lock
|
||||
rm -f "$LOCK_DIR/scan_fixer.lock"
|
||||
|
||||
# 【关键】验证进程已全部停止
|
||||
echo " 验证进程停止..."
|
||||
@@ -86,19 +103,19 @@ echo " ✅ 清理完成"
|
||||
|
||||
# ---------- 2. 启动 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 前)
|
||||
rm -rf /dev/shm/fastrtps_* 2>/dev/null || true
|
||||
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_* 2>/dev/null || true
|
||||
|
||||
# 使用 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
|
||||
|
||||
# 验证 daemon 是否就绪(用简单的 topic list 测试)
|
||||
DAEMON_OK=0
|
||||
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
|
||||
DAEMON_OK=1
|
||||
echo " ✅ ros2 daemon 就绪"
|
||||
@@ -112,15 +129,15 @@ fi
|
||||
|
||||
# ---------- 3. 启动 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"
|
||||
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=$!
|
||||
echo " bringup PID: $BRINGUP_PID"
|
||||
|
||||
@@ -136,15 +153,15 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
if [ "$BRINGUP_OK" -eq 0 ]; then
|
||||
echo " ⚠️ bringup 未检测到 /odom,继续启动后续组件..."
|
||||
tail -5 /tmp/ros2_bringup.log 2>/dev/null || true
|
||||
tail -5 "$BRINGUP_LOG" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------- 3.5 启动系统时钟发布器 ----------
|
||||
echo "[3.5/8] 启动系统时钟发布器 (clock_publisher)..."
|
||||
|
||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/clock_publisher.py" \
|
||||
> /tmp/clock_publisher.log 2>&1 &
|
||||
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/clock_publisher.py\"" \
|
||||
> "$CLOCK_LOG" 2>&1 &
|
||||
CLOCK_PID=$!
|
||||
echo " clock_publisher PID: $CLOCK_PID"
|
||||
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 已上线"
|
||||
else
|
||||
echo " ⚠️ /clock 未上线,检查日志:"
|
||||
tail -5 /tmp/clock_publisher.log 2>/dev/null || true
|
||||
tail -5 "$CLOCK_LOG" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------- 4. 启动激光时间戳修正节点 ----------
|
||||
@@ -174,9 +191,9 @@ if [ "$SCAN_OK" -eq 0 ]; then
|
||||
echo " ⚠️ /scan 未上线,检查 bringup 日志"
|
||||
fi
|
||||
|
||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
|
||||
> /tmp/scan_fixer.log 2>&1 &
|
||||
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/$FIXER_SCRIPT\"" \
|
||||
> "$SCAN_FIXER_LOG" 2>&1 &
|
||||
FIXER_PID=$!
|
||||
echo " fix_scan_timestamp PID: $FIXER_PID"
|
||||
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
|
||||
echo " ⚠️ 发现 $FIXER_COUNT 个 fixer 进程,杀掉多余的..."
|
||||
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
|
||||
rm -f /tmp/scan_fixer.lock
|
||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 $SCAN_FIXER_DIR/$FIXER_SCRIPT" \
|
||||
> /tmp/scan_fixer.log 2>&1 &
|
||||
rm -f "$LOCK_DIR/scan_fixer.lock"
|
||||
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||
ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL python3 \"$SCAN_FIXER_DIR/$FIXER_SCRIPT\"" \
|
||||
> "$SCAN_FIXER_LOG" 2>&1 &
|
||||
FIXER_PID=$!
|
||||
sleep 3
|
||||
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 已上线"
|
||||
else
|
||||
echo " ⚠️ /scan_corrected 未上线,检查日志:"
|
||||
tail -5 /tmp/scan_fixer.log 2>/dev/null || true
|
||||
tail -5 "$SCAN_FIXER_LOG" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---------- 5. 启动 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"
|
||||
source install/setup.bash
|
||||
source "$ROS_WORKSPACE_SETUP"
|
||||
|
||||
nohup bash -c "source /opt/ros/humble/setup.bash && \
|
||||
source /home/elephant/agv_pro_ros2/install/setup.bash && \
|
||||
nohup bash -c "source \"$ROS_SETUP\" && \
|
||||
source \"$ROS_WORKSPACE_SETUP\" && \
|
||||
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL && \
|
||||
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=$!
|
||||
echo " Nav2 PID: $NAV2_PID"
|
||||
sleep 12
|
||||
@@ -237,9 +254,9 @@ fi
|
||||
|
||||
# ---------- 6. 设置精度参数 ----------
|
||||
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"
|
||||
source install/setup.bash
|
||||
source "$ROS_WORKSPACE_SETUP"
|
||||
|
||||
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
|
||||
@@ -252,9 +269,9 @@ echo " ✅ 精度参数已设置"
|
||||
|
||||
# ---------- 7. 启动 Flask ----------
|
||||
echo "[7/8] 启动 Flask API..."
|
||||
export ROS_DOMAIN_ID=1
|
||||
export ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VAL
|
||||
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=$!
|
||||
echo " Flask PID: $FLASK_PID"
|
||||
sleep 4
|
||||
@@ -268,13 +285,13 @@ echo "=========================================="
|
||||
# 8a. 验证 ros2 topic list(核心指标)
|
||||
echo ""
|
||||
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"
|
||||
if [ "$TOPIC_COUNT" -gt 10 ]; then
|
||||
echo " ✅ ros2 daemon 正常 (${TOPIC_COUNT} 个话题)"
|
||||
else
|
||||
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
|
||||
|
||||
# 8b. 验证关键话题
|
||||
@@ -304,7 +321,7 @@ fi
|
||||
# 8d. FastRTPS 共享内存状态
|
||||
echo ""
|
||||
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 (正常运行时会有一些)"
|
||||
|
||||
# 8e. Flask API 测试
|
||||
@@ -332,12 +349,12 @@ for PROC in "bringup:$BRINGUP_PID" "Nav2:$NAV2_PID" "fixer:$FIXER_PID" "Flask:$F
|
||||
done
|
||||
echo ""
|
||||
echo " 日志文件:"
|
||||
echo " bringup : /tmp/ros2_bringup.log"
|
||||
echo " Nav2 : /tmp/ros2_nav2.log"
|
||||
echo " fixer : /tmp/scan_fixer.log"
|
||||
echo " Flask : /tmp/agv_flask.log"
|
||||
echo " bringup : $BRINGUP_LOG"
|
||||
echo " Nav2 : $NAV2_LOG"
|
||||
echo " fixer : $SCAN_FIXER_LOG"
|
||||
echo " Flask : $FLASK_LOG"
|
||||
echo ""
|
||||
echo " 如果仍有问题,请依次执行:"
|
||||
echo " 1. ./stop_all.sh"
|
||||
echo " 2. rm -rf /dev/shm/fastrtps_*"
|
||||
echo " 3. ./start_all.sh"
|
||||
echo " 1. ./scripts/stop_all.sh"
|
||||
echo " 2. rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
|
||||
echo " 3. ./scripts/start_all.sh"
|
||||
|
||||
+18
-8
@@ -3,24 +3,34 @@
|
||||
# start_flask.sh - 仅启动/重启 Flask 服务(不启动 ROS2)
|
||||
# 适用于: 修改了前端/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_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 "uv run .*python app.py" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
source /opt/ros/humble/setup.bash 2>/dev/null || true
|
||||
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"
|
||||
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: $!"
|
||||
sleep 2
|
||||
|
||||
if ss -tlnp 2>/dev/null | grep -q 5000 || netstat -tlnp 2>/dev/null | grep -q 5000; then
|
||||
echo "✅ 端口 5000 正常"
|
||||
if ss -tlnp 2>/dev/null | grep -q ":$FLASK_PORT " || netstat -tlnp 2>/dev/null | grep -q ":$FLASK_PORT "; then
|
||||
echo "✅ 端口 $FLASK_PORT 正常"
|
||||
else
|
||||
echo "⚠️ 端口 5000 未监听,检查 /tmp/agv_flask.log"
|
||||
echo "⚠️ 端口 $FLASK_PORT 未监听,检查 $FLASK_LOG"
|
||||
fi
|
||||
|
||||
+12
-7
@@ -7,6 +7,11 @@
|
||||
# ============================================================
|
||||
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 " Robot AGV 全量停止"
|
||||
echo "=========================================="
|
||||
@@ -40,17 +45,17 @@ sleep 1
|
||||
|
||||
# ---------- 3. 【关键】清理 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
|
||||
rm -rf /dev/shm/fastrtps_*
|
||||
rm -rf "$FASTRTPS_SHM_DIR"/fastrtps_*
|
||||
echo " 已清理 $FASTRTPS_COUNT 个 FastRTPS 文件"
|
||||
else
|
||||
echo " 无 FastRTPS 文件残留"
|
||||
fi
|
||||
|
||||
# 清理 scan_fixer 锁文件
|
||||
rm -f /tmp/scan_fixer.lock
|
||||
rm -f /tmp/clock_publisher.lock
|
||||
rm -f "$LOCK_DIR/scan_fixer.lock"
|
||||
rm -f "$LOCK_DIR/clock_publisher.lock"
|
||||
echo " ✅ FastRTPS 清理完成"
|
||||
|
||||
# ---------- 4. 【关键】重置 ros2 daemon ----------
|
||||
@@ -58,14 +63,14 @@ echo "[4/5] 重置 ros2 daemon..."
|
||||
pkill -f "ros2-daemon" 2>/dev/null || true
|
||||
pkill -9 -f "ros2-daemon" 2>/dev/null || true
|
||||
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
|
||||
echo " ✅ ros2 daemon 已重置"
|
||||
|
||||
# ---------- 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)
|
||||
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 " FastRTPS 文件数: $FASTRTPS_LEFT"
|
||||
@@ -85,7 +90,7 @@ else
|
||||
echo " pkill -9 -f 'agv_pro_node|lslidar|component_container'"
|
||||
echo " pkill -9 -f 'fix_scan_timestamp|app.py'"
|
||||
echo " pkill -9 -f 'ros2-daemon'"
|
||||
echo " rm -rf /dev/shm/fastrtps_*"
|
||||
echo " rm -rf \"$FASTRTPS_SHM_DIR\"/fastrtps_*"
|
||||
fi
|
||||
echo ""
|
||||
echo " 现在可以安全运行 ./start_all.sh"
|
||||
|
||||
Reference in New Issue
Block a user