From 08d62d4f77bcc65969519063bd3b5e853fc141f0 Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 17 Jun 2026 16:02:46 +0800 Subject: [PATCH] label printer monitor --- .../ClientMonitor/ClientMonitorPage.tsx | 15 +- .../LabelPrinterMonitorPanel.tsx | 322 ++++++++++++++++++ .../DoSearch/DoReplenishmentTab.tsx | 1 + src/i18n/en/clientMonitor.json | 15 +- src/i18n/zh/clientMonitor.json | 15 +- 5 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx diff --git a/src/components/ClientMonitor/ClientMonitorPage.tsx b/src/components/ClientMonitor/ClientMonitorPage.tsx index 0dc6da1..efba56c 100644 --- a/src/components/ClientMonitor/ClientMonitorPage.tsx +++ b/src/components/ClientMonitor/ClientMonitorPage.tsx @@ -51,6 +51,7 @@ function formatApiDateTime(value: unknown): string { return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—"; } import PrinterMonitorTab from "@/components/ClientMonitor/PrinterMonitorTab"; +import LabelPrinterMonitorPanel from "@/components/ClientMonitor/LabelPrinterMonitorPanel"; import DeviceConnectivityHistoryChart from "@/components/ClientMonitor/DeviceConnectivityHistoryChart"; import { formatClientIpDisplay } from "@/lib/devicePresence"; @@ -515,10 +516,16 @@ export default function ClientMonitorPage() { )} {tab === "printers" && ( - + <> + + + )} {tab === "history" && ( diff --git a/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx b/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx new file mode 100644 index 0000000..7edf93f --- /dev/null +++ b/src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx @@ -0,0 +1,322 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Chip, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import InfoOutlined from "@mui/icons-material/InfoOutlined"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { arrayToDateTimeString } from "@/app/utils/formatUtil"; +import dayjs from "dayjs"; + +function formatApiDateTime(value: unknown): string { + if (value == null) return "—"; + if (Array.isArray(value)) { + return arrayToDateTimeString(value as (number | undefined)[]); + } + const d = dayjs(value as string | number); + return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "—"; +} + +type LabelPrinterRow = { + id: number; + code?: string; + name?: string; + type?: string; + brand?: string; + ip?: string; + port?: number; + status: "online" | "offline" | "unconfigured" | "unchecked"; + latencyMs?: number; + errorMessage?: string; + odometerTotal?: number | null; + deltaSincePrevious?: number | null; + hostStatusSnippet?: string; + zebraOdometerEnabled?: boolean; + lastCheckAt?: string; +}; + +type LabelPrinterSummary = { + total: number; + online: number; + offline: number; + unconfigured: number; + unchecked: number; +}; + +type LabelSubmitRow = { + id?: number; + jobOrderId?: number; + qty?: number; + created?: unknown; + jobCode?: string; +}; + +type LabelStats = { + todayTotal?: number; + rangeTotal?: number; + recentSubmits?: LabelSubmitRow[]; +}; + +const STATUS_LABEL: Record< + string, + { label: string; color: "success" | "warning" | "error" | "default" } +> = { + online: { label: "連線正常", color: "success" }, + offline: { label: "離線", color: "error" }, + unconfigured: { label: "未設定 IP", color: "warning" }, + unchecked: { label: "未檢查", color: "default" }, +}; + +type Props = { + active: boolean; + refreshAt?: number; +}; + +export default function LabelPrinterMonitorPanel({ active, refreshAt = 0 }: Props) { + const [printers, setPrinters] = useState([]); + const [summary, setSummary] = useState(null); + const [labelStats, setLabelStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + + const runCheck = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setLoading(true); + setError(null); + try { + const from = dayjs().startOf("day").format("YYYY-MM-DDTHH:mm:ss"); + const to = dayjs().format("YYYY-MM-DDTHH:mm:ss"); + const [checkRes, statsRes] = await Promise.all([ + clientAuthFetch(`${NEXT_PUBLIC_API_URL}/label-printer-monitor/check`, { + method: "POST", + }), + clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/label-printer-monitor/label-stats?fromDateTime=${encodeURIComponent(from)}&toDateTime=${encodeURIComponent(to)}`, + { method: "GET", cache: "no-store" }, + ), + ]); + if (checkRes.status === 401 || checkRes.status === 403) return; + if (!checkRes.ok) { + throw new Error(`check HTTP ${checkRes.status}`); + } + const checkData = await checkRes.json(); + setPrinters(Array.isArray(checkData.printers) ? checkData.printers : []); + setSummary(checkData.summary ?? null); + if (checkData.labelStats) { + setLabelStats(checkData.labelStats); + } else if (statsRes.ok) { + setLabelStats(await statsRes.json()); + } + } catch (e) { + console.error("label printer monitor check", e); + setError("無法檢查標籤印表機狀態"); + } finally { + setLoading(false); + inFlightRef.current = false; + } + }, []); + + useEffect(() => { + if (!active) return; + void runCheck(); + const id = window.setInterval(() => void runCheck(), 120_000); + return () => window.clearInterval(id); + }, [active, runCheck]); + + useEffect(() => { + if (!active || refreshAt <= 0) return; + void runCheck(); + }, [refreshAt, active, runCheck]); + + const offlineCount = summary?.offline ?? 0; + const isZebra = (brand?: string) => (brand ?? "").toLowerCase().includes("zebra"); + + return ( +
+ + 標籤印表機監控(In Development) + + + 監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。 + 應用層列印統計來自 Bag3 標籤機提交(LABEL channel),不含網頁直接列印。 + + + {offlineCount > 0 && ( + + 有 {offlineCount} 台標籤印表機無法連線,請檢查電源、網路或 IP/Port。 + + )} + + {summary && ( + + + + + + {summary.unchecked > 0 && ( + + )} + + )} + + {error && {error}} + + + 標籤印表機狀態 + + + + + + 狀態 + 印表機 + 品牌 + IP:Port + + + 累計里程 + + + + + + + + 自上次檢查 + + + + + + 延遲 (ms) + 最後檢查 + + + + {loading && printers.length === 0 ? ( + + + + + + ) : printers.length === 0 ? ( + + + 沒有 Label 類型印表機 + + + ) : ( + printers.map((row) => { + const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.unchecked; + const showOdometer = isZebra(row.brand); + return ( + + + + + + + {row.name || row.code || `#${row.id}`} + + {row.code && row.name && ( + + {row.code} + + )} + {row.errorMessage && ( + + {row.errorMessage} + + )} + + {row.brand ?? "—"} + + {row.ip ? `${row.ip}:${row.port ?? 9100}` : "—"} + + + {showOdometer + ? row.odometerTotal != null + ? row.odometerTotal.toLocaleString() + : "—" + : "—"} + + + {showOdometer && row.deltaSincePrevious != null + ? row.deltaSincePrevious.toLocaleString() + : "—"} + + + {row.latencyMs != null ? row.latencyMs : "—"} + + {formatApiDateTime(row.lastCheckAt)} + + ); + }) + )} + +
+
+ + + 應用層標籤列印(LABEL) + + {labelStats && ( + + + + + )} + + + + + 時間 + 工單 + 數量 + + + + {!labelStats?.recentSubmits?.length ? ( + + + 尚無 LABEL 列印提交記錄 + + + ) : ( + labelStats.recentSubmits.map((row) => ( + + {formatApiDateTime(row.created)} + + {row.jobCode ?? (row.jobOrderId != null ? `#${row.jobOrderId}` : "—")} + + {row.qty ?? "—"} + + )) + )} + +
+
+ + {loading && printers.length > 0 && ( + + + + )} +
+ ); +} diff --git a/src/components/DoSearch/DoReplenishmentTab.tsx b/src/components/DoSearch/DoReplenishmentTab.tsx index 3e77785..c69bbdd 100644 --- a/src/components/DoSearch/DoReplenishmentTab.tsx +++ b/src/components/DoSearch/DoReplenishmentTab.tsx @@ -1172,6 +1172,7 @@ const DoReplenishmentTab: React.FC = () => { )} + {sourceDo && (