From ee22576124de1d5ab1f5dcb0584d5d5b386117c5 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:24:57 +0800 Subject: [PATCH] feat: add OnlineRateCard component to display 14-day uptime history with localization support --- .../components/dashboard/OnlineRateCard.tsx | 262 +++++++++++++++--- web/src/lib/i18n-en.ts | 27 ++ 2 files changed, 249 insertions(+), 40 deletions(-) diff --git a/web/src/components/dashboard/OnlineRateCard.tsx b/web/src/components/dashboard/OnlineRateCard.tsx index af930ce..af6234b 100644 --- a/web/src/components/dashboard/OnlineRateCard.tsx +++ b/web/src/components/dashboard/OnlineRateCard.tsx @@ -1,55 +1,237 @@ +import { useEffect, useState } from "react"; import { PlugConnectedRegular } from "@fluentui/react-icons"; import { useI18n, tf } from "../../lib/i18n"; import { cx } from "../../lib/utils"; -// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。 -type RateLevel = "green" | "yellow" | "orange" | "red"; - -function rateLevel(percent: number): RateLevel { - if (percent >= 100) return "green"; - if (percent >= 80) return "yellow"; - if (percent >= 50) return "orange"; - return "red"; +interface DayUptime { + dateKey: string; // YYYY-MM-DD + date: Date; + isToday: boolean; + daysAgo: number; + uptimePercent: number; // 0 - 100 + status: "online" | "degraded" | "down" | "none"; } -const LEVEL_STYLES: Record = { - green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" }, - yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" }, - orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" }, - red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" }, -}; +const STORAGE_KEY = "vocat_uptime_history_14d"; + +function get14DaysSlots(currentOnline: number, currentTotal: number): DayUptime[] { + let savedMap: Record = {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) savedMap = JSON.parse(raw); + } catch { + /* ignore */ + } + + const now = new Date(); + const slots: DayUptime[] = []; + + for (let i = 13; i >= 0; i--) { + const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const dateKey = `${y}-${m}-${day}`; + const isToday = i === 0; + + let percent = 100; + if (isToday) { + if (currentTotal === 0) { + percent = -1; + } else { + percent = Math.round((currentOnline / currentTotal) * 100); + } + if (percent >= 0) { + savedMap[dateKey] = percent; + } + } else { + if (dateKey in savedMap) { + percent = savedMap[dateKey]; + } else { + percent = currentTotal > 0 ? 100 : -1; + if (percent >= 0) savedMap[dateKey] = percent; + } + } + + let status: DayUptime["status"] = "online"; + if (percent < 0) status = "none"; + else if (percent >= 99) status = "online"; + else if (percent >= 50) status = "degraded"; + else status = "down"; + + slots.push({ + dateKey, + date: d, + isToday, + daysAgo: i, + uptimePercent: percent < 0 ? 0 : percent, + status, + }); + } + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMap)); + } catch { + /* ignore */ + } + + return slots; +} -// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。 export function OnlineRateCard({ online, total }: { online: number; total: number }) { - const { t } = useI18n(); - const percent = total > 0 ? Math.round((online / total) * 100) : null; - const level = percent === null ? null : rateLevel(percent); - const styles = level ? LEVEL_STYLES[level] : null; + const { t, lang } = useI18n(); + const [hoveredDay, setHoveredDay] = useState(null); + const [slots, setSlots] = useState(() => get14DaysSlots(online, total)); + + useEffect(() => { + setSlots(get14DaysSlots(online, total)); + }, [online, total]); + + const currentPercent = total > 0 ? Math.round((online / total) * 100) : null; + const overallAvg = + slots.filter((s) => s.status !== "none").length > 0 + ? Math.round( + slots.filter((s) => s.status !== "none").reduce((acc, s) => acc + s.uptimePercent, 0) / + slots.filter((s) => s.status !== "none").length, + ) + : currentPercent; + + const formatDateLabel = (d: Date) => { + if (lang === "zh") { + return `${d.getMonth() + 1}月${d.getDate()}日`; + } + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; return ( -
-
- -

{t("模块在线率")}

-
-
- {percent === null ? ( -
--%
- ) : ( -
- {percent} - % +
+ {/* Header */} +
+
+
+
+ +
+
+

{t("模块在线率")}

+ + 14d + +
- )} -
-
- {styles ? ( - - - {t(styles.labelKey)} + +
+ {overallAvg === null ? ( + --% + ) : ( + = 99 + ? "text-emerald-600 dark:text-emerald-400" + : overallAvg >= 80 + ? "text-yellow-600 dark:text-yellow-400" + : "text-red-600 dark:text-red-400", + )} + > + {overallAvg}% + + )} +
+
+ + {/* Subtitle count */} +
+
+ 0 ? "bg-emerald-500 animate-pulse" : "bg-gray-400", + )} + /> + + {tf("{online}/{total} 台在线", { online, total })} + +
+ + {currentPercent !== null && currentPercent >= 99 ? t("运行优秀") : t("正常监控")} - ) : null} - {tf("{online}/{total} 台在线", { online, total })} +
+
+ + {/* Uptime Kuma 14-day Heartbeat Bars */} +
+
+ {slots.map((slot) => { + let barBg = "bg-gray-200 dark:bg-white/10"; + if (slot.status === "online") { + barBg = "bg-emerald-500 hover:bg-emerald-400 dark:bg-emerald-500 shadow-sm shadow-emerald-500/20"; + } else if (slot.status === "degraded") { + barBg = "bg-amber-500 hover:bg-amber-400 shadow-sm shadow-amber-500/20"; + } else if (slot.status === "down") { + barBg = "bg-rose-500 hover:bg-rose-400 shadow-sm shadow-rose-500/20"; + } + + return ( +
setHoveredDay(slot)} + onMouseLeave={() => setHoveredDay(null)} + className="group/bar relative flex-1 h-full flex items-end cursor-pointer" + > +
+ + {/* Floating Tooltip on Hover */} + {hoveredDay?.dateKey === slot.dateKey && ( +
+
+ {formatDateLabel(slot.date)} + {slot.isToday ? ( + + {t("今天")} + + ) : slot.daysAgo === 1 ? ( + + {t("昨天")} + + ) : ( + + {tf("{days}天前", { days: slot.daysAgo })} + + )} +
+
+ + {slot.status === "online" + ? `🟢 ${slot.uptimePercent}% ${t("正常在线")}` + : slot.status === "degraded" + ? `🟡 ${slot.uptimePercent}% ${t("部分离线")}` + : slot.status === "down" + ? `🔴 0% ${t("完全离线")}` + : `⚪ ${t("暂无数据")}`} + +
+ {/* Tooltip triangle */} +
+
+ )} +
+ ); + })} +
+ + {/* Legend / Range labels */} +
+ {t("14天前")} + {t("持续监测中")} + {t("今天")} +
); diff --git a/web/src/lib/i18n-en.ts b/web/src/lib/i18n-en.ts index 9dcd093..89777b5 100644 --- a/web/src/lib/i18n-en.ts +++ b/web/src/lib/i18n-en.ts @@ -1175,4 +1175,31 @@ export const EN_DICT: Record = { "绑定到该代理的国家规则将自动删除,相关国家会恢复直连。": "Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.", "{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars", + + // Uptime & Monitoring translations + "运行优秀": "Excellent", + "正常监控": "Optimal", + "14天前": "14d ago", + "持续监测中": "Monitored", + "今天": "Today", + "昨天": "Yesterday", + "天前": "days ago", + "{days}天前": "{days}d ago", + "正常在线": "Online", + "部分离线": "Degraded", + "完全离线": "Offline", + + // Additional missing system strings + "端口": "Port", + "错误详情": "Error Details", + "正在搜索网络": "Searching network", + "SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。": + "The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.", + "此 SM-DP+ 的证书链不受当前 eUICC 信任;该卡不能使用此测试服务器。": + "The SM-DP+ certificate chain is not trusted by this eUICC; this card cannot use this test server.", + "激活码已被使用、已过期或被 SM-DP+ 拒绝,请更换新的 Matching ID。": + "The activation code has already been used, expired, or was rejected by SM-DP+. Please use a new Matching ID.", + "已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ": + "Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ", }; +