feat: expand device networking and management

This commit is contained in:
MengMengCode
2026-08-10 03:27:43 +08:00
parent d8828ff26a
commit 4df0ae0c7d
117 changed files with 7085 additions and 380 deletions
+4 -10
View File
@@ -1,9 +1,10 @@
import {
Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular,
RouterRegular, Wifi1Regular,
Wifi1Regular,
} from "@fluentui/react-icons";
import type { DashboardDevice } from "../types";
import { cx, signalBars, signalColor, isEC20Model } from "../lib/utils";
import { cx, signalBars, signalColor } from "../lib/utils";
import { deviceTypeImage } from "../lib/deviceTypes";
import { StatusDot } from "./ui/StatusDot";
import { useI18n } from "../lib/i18n";
@@ -29,7 +30,6 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen
const second = words.length > 1 ? words[1] : words[0] || "";
const isLte = second.toUpperCase() === "LTE";
const bars = signalBars(device.signalDbm);
const brandImg = isEC20Model(device.model);
return (
<button
@@ -41,13 +41,7 @@ export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen
<div className="relative z-10 p-6">
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
{brandImg ? (
<img src="/ec20.png" alt="" className="h-10 w-10 flex-shrink-0 object-contain" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-50 text-indigo-600 shadow-inner dark:bg-white/5 dark:text-indigo-400">
<RouterRegular className="h-5 w-5" />
</div>
)}
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-10 w-10 flex-shrink-0 object-contain" />
<div>
<h3 className="text-base font-bold text-gray-800 dark:text-gray-100">{device.name || device.id}</h3>
<div className="mt-0.5 flex items-center gap-1.5">
@@ -1,6 +1,7 @@
import { Button } from "../ui";
import type { OperatorCandidate } from "./types";
import { useI18n } from "../../lib/i18n";
import { flagEmoji } from "../../lib/carrier";
function ratsText(c: OperatorCandidate): string {
const list = (c.rats || []).filter(Boolean) as string[];
@@ -20,6 +21,7 @@ export function CandidateRow({ candidate, onLock }: { candidate: OperatorCandida
>
<div>
<div className="flex items-center gap-2 font-medium text-gray-900 dark:text-white">
{c.countryCode ? <span aria-hidden="true">{flagEmoji(c.countryCode)}</span> : null}
{c.operatorName || c.shortName || t("未知网络")}{" "}
{c.status === "current" ? (
<span className="rounded-full border border-emerald-200 bg-emerald-100 px-1.5 py-0.5 text-[10px] font-bold text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/20 dark:text-emerald-300">
@@ -7,6 +7,7 @@ import { DiscoveredDeviceRow } from "./DiscoveredDeviceRow";
import type { DiscoveredDevice } from "../../types";
import type { AddDeviceForm } from "./types";
import { useI18n } from "../../lib/i18n";
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceAddDialogProps {
open: boolean;
@@ -123,6 +124,22 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
</div>
) : null}
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label={t("设备类型")}>
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white p-1.5">
{addConfig.deviceType ? <img src={deviceTypeImage(addConfig.deviceType)} alt="" className="h-full w-full object-contain" /> : null}
</div>
<Select
value={addConfig.deviceType}
onChange={(v) => set({ deviceType: v as AddDeviceForm["deviceType"] })}
placeholder={t("请选择设备类型")}
size="large"
options={DEVICE_TYPES.map((item) => ({ value: item.value, label: t(item.label) }))}
/>
</div>
</Field>
</div>
<Field label="ID">
<Input value={addConfig.id} onChange={(e) => set({ id: e.target.value })} placeholder={t("例如 ec20_3")} />
</Field>
@@ -5,6 +5,7 @@ import { isQmiControl } from "./shared";
import type { DeviceConfig } from "../../types";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { DEVICE_TYPES, deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceConfigTabProps {
editConfig: DeviceConfig | null;
@@ -78,6 +79,18 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("名称")}>
<Input value={editConfig.name} onChange={(e) => onEditConfig({ ...editConfig, name: e.target.value })} placeholder={t("显示名称")} />
</Field>
<Field label={t("设备类型")}>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white p-1.5 dark:border-white/10 dark:bg-white/5">
<img src={deviceTypeImage(editConfig.deviceType)} alt="" className="h-full w-full object-contain" />
</div>
<Select
value={editConfig.deviceType}
onChange={(v) => onEditConfig({ ...editConfig, deviceType: v as DeviceConfig["deviceType"] })}
options={DEVICE_TYPES.map((item) => ({ value: item.value, label: t(item.label) }))}
/>
</div>
</Field>
<Field label={t("IMEI 绑定")}>
<Input value={editConfig.modemImei || ""} disabled placeholder={t("自动识别(添加时绑定)")} />
</Field>
@@ -1,16 +1,16 @@
import { ArrowSyncRegular, PowerRegular, ChatRegular } from "@fluentui/react-icons";
import { Button } from "../ui";
import { Button, Switch } from "../ui";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { isEC20Model } from "../../lib/utils";
import { deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceDetailHeaderProps {
device: DeviceDetail;
rotating: boolean;
dataToggling: boolean;
rebooting: boolean;
reconnectingVoWiFi: boolean;
onCopyText: (text: string) => void;
onRotateIp: () => void;
onToggleRoamingData: (enabled: boolean) => void;
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
@@ -19,23 +19,13 @@ export interface DeviceDetailHeaderProps {
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
const { t } = useI18n();
const { device } = props;
const vowifiInUse = device.vowifiEnabled || device.vowifiActive || device.vowifiRuntime?.smsReady;
const brandImg = isEC20Model(device.modem?.model);
const vowifiInUse = !!device.vowifiEnabled;
return (
<div className="ui-card p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
{brandImg ? (
<img src="/ec20.png" alt="" className="h-11 w-11 flex-shrink-0 object-contain" />
) : (
<div className="device-header-brand-icon">
<svg viewBox="0 0 1025 1024" width="200" height="200" className="device-header-brand-svg" aria-hidden="true">
<path d="M512.473172 1023.995242A511.814852 511.814852 0 0 1 313.545134 40.351073a512.244696 512.244696 0 0 1 398.855715 943.658633 508.815937 508.815937 0 0 1-199.927677 39.985536z m0-943.658634C274.559237 80.336608 80.629391 274.266455 80.629391 512.18039s193.929846 431.843781 431.843781 431.843781 431.843781-193.929846 431.843781-431.843781S751.386745 80.336608 512.473172 80.336608z" />
<path d="M506.475342 716.10662a39.985535 39.985535 0 0 1-39.985536-39.985535v-76.972156c0-79.971071 64.976495-144.947566 144.947566-144.947565a77.971794 77.971794 0 0 0 0-155.943588H445.4974a56.979388 56.979388 0 0 0-56.979387 56.979388 39.985535 39.985535 0 0 1-79.971071 0c0-74.972879 60.977941-136.950458 136.950458-136.950459h164.940333c86.968539 0 157.942864 70.974325 157.942865 157.942865s-69.974687 157.942864-157.942865 157.942864a64.976495 64.976495 0 0 0-64.976494 64.976495v76.972156a39.985535 39.985535 0 0 1-38.985897 39.985535zM505.475703 742.097218a48.982281 48.982281 0 1 0 48.982281 48.982281 48.982281 48.982281 0 0 0-48.982281-48.982281z" />
</svg>
</div>
)}
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-11 w-11 flex-shrink-0 object-contain" />
<div className="min-w-0">
<div className="truncate text-xl font-extrabold text-gray-900 dark:text-white">{device.name || device.id}</div>
<div className="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
@@ -51,17 +41,22 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : (
<Button
loading={props.rotating}
disabled={!device?.networkConnected}
onClick={props.onRotateIp}
className="ui-glass-border !border-0"
icon={<ArrowSyncRegular />}
>
{t("切换 IP")}
</Button>
)}
) : device.developerEnabled ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
>
<span>{t("漫游数据")}</span>
<Switch
checked={!!device.networkEnabled}
loading={props.dataToggling}
disabled={props.dataToggling || !device.interface}
onChange={props.onToggleRoamingData}
size="small"
ariaLabel={t("漫游数据")}
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
@@ -2,6 +2,7 @@ import type { DeviceListItem } from "../../types";
import { cx } from "../../lib/utils";
import { Tag, StatusDot } from "../ui";
import { deviceStatusMeta } from "./shared";
import { deviceTypeImage } from "../../lib/deviceTypes";
export interface DeviceListItemCardProps {
device: DeviceListItem;
@@ -25,7 +26,8 @@ export function DeviceListItemCard({ device, selected, statusText, onSelect }: D
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<img src={deviceTypeImage(device.deviceType)} alt="" className="h-10 w-10 shrink-0 object-contain" />
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-gray-800 dark:text-gray-100">{device.name || device.id}</div>
<div className="mt-0.5 truncate text-xs text-gray-500">
{device.id} · {device.interface || "--"}
@@ -3,6 +3,7 @@ import { OverviewNetworkCard } from "./OverviewNetworkCard";
import { OverviewVowifiCard } from "./OverviewVowifiCard";
import { OverviewSimPanel } from "./OverviewSimPanel";
import { OverviewNetworkPanel } from "./OverviewNetworkPanel";
import { OverviewTrafficChart } from "./OverviewTrafficChart";
import { OperatorSelectionDialog } from "./OperatorSelectionDialog";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
@@ -24,28 +25,31 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{device?.vowifiEnabled ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
)}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{device?.vowifiEnabled ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
)}
</div>
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
</div>
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
<OperatorSelectionDialog
open={operatorOpen}
@@ -124,6 +124,26 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
}
}
async function reRegister() {
const controller = new AbortController();
registerAbortRef.current = controller;
setRegistering(t("正在按当前选网配置重新驻网,请稍候..."));
setBusy(true);
try {
await api(`/devices/${deviceId}/operator_selection/reregister`, { method: "POST", signal: controller.signal });
message.success(t("已重新发起驻网"));
onUpdated();
await loadCurrent();
} catch (e) {
if (controller.signal.aborted) message.info(t("已取消"));
else message.error(apiMessage(e) || t("重新驻网失败"));
} finally {
setRegistering(null);
registerAbortRef.current = null;
setBusy(false);
}
}
async function lock(c: OperatorCandidate) {
const controller = new AbortController();
registerAbortRef.current = controller;
@@ -203,10 +223,13 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
</div>
) : null}
</div>
<div className="mb-4 flex gap-3">
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<Button variant="primary" plain onClick={startStream} loading={scanning} disabled={busy || !!scanBlockedReason} className="flex-1">
{scanning ? t("扫描中...") : t("扫描可用网络")}
</Button>
<Button onClick={reRegister} disabled={busy} className="flex-1">
{t("重新驻网")}
</Button>
<Button onClick={restoreAuto} disabled={busy || current?.mode === "automatic"} className="flex-1">
{t("恢复自动选网")}
</Button>
@@ -239,7 +262,7 @@ export function OperatorSelectionDialog({ open, deviceId, scanBlockedReason = ""
</div>
) : null}
{candidates.length > 0 ? (
<div className={cx("max-h-[300px] divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10", (!!registering || busy) && "pointer-events-none opacity-60")}>
<div className={cx("max-h-[min(55vh,440px)] divide-y divide-gray-200 overflow-y-auto rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10", (!!registering || busy) && "pointer-events-none opacity-60")}>
{candidates.map((c) => (
<CandidateRow key={`${c.plmn}-${ratsText(c)}`} candidate={c} onLock={lock} />
))}
@@ -5,6 +5,7 @@ import { FieldRow } from "./FieldRow";
import { isDeviceOnline, isRegistered, isRecoveringPhase, lifecycleLabel, signalLevel, signalTone } from "./shared";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { flagEmoji } from "../../lib/carrier";
const BAR_HEIGHTS = ["h-[28%]", "h-[46%]", "h-[64%]", "h-[82%]", "h-full"];
const TEXT_TONE = {
@@ -25,7 +26,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const vowifiRegistered = !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
@@ -45,7 +48,16 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
: device.registrationStateLabel === "searching"
? t("正在搜索网络")
: device.registrationStateLabel === "denied"
? t("驻网被拒")
: t("未驻网");
return (
<>
@@ -71,7 +83,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
<>{t("WiFi Calling 已注册")}</>
) : registered ? (
<>
{modem?.operator || "--"}{" "}
{modem?.operatorCountryCode ? `${flagEmoji(modem.operatorCountryCode)} ` : ""}{modem?.operator || "--"}{" "}
{modem?.networkMode ? <span className="opacity-70">· {netMode}</span> : null}
</>
) : (
@@ -116,7 +128,7 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
<FieldRow label={t("网络模式")} value={netMode || "--"} monospace />
<FieldRow label={t("频段")} value={modem?.radioBand || "--"} monospace />
<FieldRow label={t("信道")} value={modem?.radioChannel ? String(modem.radioChannel) : "--"} monospace />
<FieldRow label={t("注册状态")} value={vowifiRegistered ? t("WiFi Calling 已注册") : (modem?.regStatusText || "--")} monospace />
<FieldRow label={t("注册状态")} value={vowifiRegistered ? t("WiFi Calling 已注册") : cellularRegistrationText} monospace />
</div>
</>
);
@@ -1,6 +1,19 @@
import { useEffect, useState } from "react";
import { FieldRow } from "./FieldRow";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { api, apiMessage } from "../../api";
import { Button, message } from "../ui";
import { flagEmoji } from "../../lib/carrier";
interface PublicIPInfo {
detected?: boolean;
ip: string;
countryCode: string;
region?: string;
city?: string;
organization?: string;
}
export interface OverviewNetworkPanelProps {
device: DeviceDetail;
@@ -11,32 +24,90 @@ export interface OverviewNetworkPanelProps {
}
export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx, trafficSpeedRx, trafficSpeedTx }: OverviewNetworkPanelProps) {
const { t } = useI18n();
const { t, lang } = useI18n();
const developerActive = !!device.developerEnabled;
const [publicIP, setPublicIP] = useState<PublicIPInfo | null>(null);
const [detectingIP, setDetectingIP] = useState(false);
const traffic = device.traffic || {};
const metaStatus = device.trafficMeta?.status;
const sampleNote = metaStatus === "waiting_sample" ? t("等待采样") : metaStatus === "stale" ? t("采样中断") : "";
const off = t("数据未开启");
const off = !device.networkEnabled;
const minuteRx = trafficMinuteRx || sampleNote || traffic.rx;
const minuteTx = trafficMinuteTx || sampleNote || traffic.tx;
const speedRx = trafficSpeedRx || sampleNote || traffic.rate || "--";
const speedTx = trafficSpeedTx || sampleNote || "--";
const speedTx = trafficSpeedTx || sampleNote || traffic.rateTx || "--";
useEffect(() => {
let cancelled = false;
setPublicIP(null);
if (!developerActive) return () => { cancelled = true; };
api<PublicIPInfo>(`/devices/${encodeURIComponent(device.id)}/network/public-ip`)
.then((info) => {
if (!cancelled) setPublicIP(info.detected ? info : null);
})
.catch(() => {
if (!cancelled) setPublicIP(null);
});
return () => { cancelled = true; };
}, [developerActive, device.id, device.interface, device.networkEnabled, device.modem?.iccid]);
async function detectPublicIP() {
setDetectingIP(true);
try {
const info = await api<PublicIPInfo>(`/devices/${encodeURIComponent(device.id)}/network/public-ip`, { method: "POST", body: {} });
setPublicIP(info);
} catch (error) {
message.error(apiMessage(error) || t("公网 IP 检测失败"));
} finally {
setDetectingIP(false);
}
}
let countryName = publicIP?.countryCode || "";
if (publicIP?.countryCode) {
try {
countryName = new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" }).of(publicIP.countryCode) || publicIP.countryCode;
} catch {
countryName = publicIP.countryCode;
}
}
const location = publicIP
? [countryName, publicIP.region, publicIP.city].filter((value, index, values) => value && values.indexOf(value) === index).join(" · ")
: "";
if (!developerActive) {
return (
<div className="ui-panel-muted p-4">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
</div>
);
}
return (
<div className="ui-panel-muted p-4">
<div className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
{off ? (
<div className="flex items-center justify-center p-6 text-sm text-gray-400">{off}</div>
) : (
<div className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="flex w-full min-w-0 items-center justify-between gap-3">
<span className="shrink-0 whitespace-nowrap text-gray-500">{t("公网 IP")}</span>
<div className="flex min-w-0 items-center justify-end gap-2">
<span className="truncate font-mono" title={publicIP?.ip || ""}>{publicIP?.ip || "-"}</span>
<Button size="small" loading={detectingIP} disabled={off} onClick={() => void detectPublicIP()}>{t("检测")}</Button>
</div>
</div>
<FieldRow label={t("国家/地区")} value={publicIP ? `${flagEmoji(publicIP.countryCode)} ${location}`.trim() : "-"} />
{off ? (
<div className="flex items-center justify-center p-6 text-sm text-gray-400">{t("数据未开启")}</div>
) : (
<>
<FieldRow label={t("内网 IPv4")} value={device.privateIp} monospace copyable />
<FieldRow label={t("内网 IPv6")} value={device.privateIpv6} monospace copyable />
<FieldRow label={t("近1分钟上传")} value={minuteTx} monospace />
<FieldRow label={t("近1分钟下载")} value={minuteRx} monospace />
<FieldRow label={t("实时下载速率")} value={speedRx} monospace />
<FieldRow label={t("实时上传速率")} value={speedTx} monospace />
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from "react";
import { api, apiMessage } from "../../api";
import { EChart } from "../EChart";
import { useI18n } from "../../lib/i18n";
import { formatBytes } from "../../lib/utils";
interface TrafficBucket {
periodStart: string;
rxBytes: number;
txBytes: number;
}
interface TrafficAnalysis {
status?: string;
range?: string;
buckets?: TrafficBucket[];
}
interface DailyTraffic {
key: string;
label: string;
rxBytes: number;
txBytes: number;
}
function localDayKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function lastSevenDays(buckets: TrafficBucket[]): DailyTraffic[] {
const byDay = new Map<string, TrafficBucket>();
for (const bucket of buckets) {
const date = new Date(bucket.periodStart);
if (Number.isNaN(date.getTime())) continue;
const key = localDayKey(date);
const current = byDay.get(key);
byDay.set(key, {
periodStart: bucket.periodStart,
rxBytes: (current?.rxBytes || 0) + (Number(bucket.rxBytes) || 0),
txBytes: (current?.txBytes || 0) + (Number(bucket.txBytes) || 0),
});
}
const result: DailyTraffic[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let offset = 6; offset >= 0; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
const key = localDayKey(date);
const value = byDay.get(key);
result.push({
key,
label: date.toLocaleDateString(undefined, { month: "2-digit", day: "2-digit" }),
rxBytes: value?.rxBytes || 0,
txBytes: value?.txBytes || 0,
});
}
return result;
}
export function OverviewTrafficChart({ deviceId }: { deviceId: string }) {
const { t } = useI18n();
const [buckets, setBuckets] = useState<TrafficBucket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
const load = async (initial = false) => {
if (initial) setLoading(true);
try {
const result = await api<TrafficAnalysis>(`/traffic/analysis?range=week&device_id=${encodeURIComponent(deviceId)}`);
if (!cancelled) {
setBuckets(result.buckets || []);
setError("");
}
} catch (err) {
if (!cancelled) setError(apiMessage(err) || t("流量分析加载失败"));
} finally {
if (!cancelled && initial) setLoading(false);
}
};
void load(true);
const timer = window.setInterval(() => void load(), 30_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [deviceId, t]);
const days = useMemo(() => lastSevenDays(buckets), [buckets]);
const totals = useMemo(() => days.reduce(
(value, day) => ({ rx: value.rx + day.rxBytes, tx: value.tx + day.txBytes }),
{ rx: 0, tx: 0 },
), [days]);
const option = useMemo(() => ({
animationDuration: 350,
color: ["#0ea5e9", "#8b5cf6"],
tooltip: {
trigger: "axis",
formatter: (items: Array<{ marker?: string; seriesName?: string; value?: number; axisValueLabel?: string }>) => {
const title = items[0]?.axisValueLabel || "";
return [title, ...items.map((item) => `${item.marker || ""}${item.seriesName || ""}: ${formatBytes(Number(item.value) || 0)}`)].join("<br/>");
},
},
legend: {
top: 0,
right: 0,
textStyle: { color: "#64748b" },
data: [t("下载"), t("上传")],
},
grid: { top: 42, right: 18, bottom: 24, left: 62 },
xAxis: {
type: "category",
boundaryGap: false,
data: days.map((day) => day.label),
axisLine: { lineStyle: { color: "#cbd5e1" } },
axisTick: { show: false },
axisLabel: { color: "#64748b" },
},
yAxis: {
type: "value",
min: 0,
axisLabel: { color: "#64748b", formatter: (value: number) => formatBytes(value) },
splitLine: { lineStyle: { color: "rgba(148, 163, 184, 0.18)" } },
},
series: [
{
name: t("下载"),
type: "line",
smooth: true,
showSymbol: true,
symbolSize: 6,
data: days.map((day) => day.rxBytes),
lineStyle: { width: 3 },
areaStyle: { opacity: 0.1 },
},
{
name: t("上传"),
type: "line",
smooth: true,
showSymbol: true,
symbolSize: 6,
data: days.map((day) => day.txBytes),
lineStyle: { width: 3 },
areaStyle: { opacity: 0.08 },
},
],
}), [days, t]);
return (
<div className="ui-panel-muted p-4 lg:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">{t("最近 7 天流量")}</div>
<div className="mt-1 text-xs text-gray-500">{t("按天统计蜂窝数据上传与下载;从启用采样后开始累计")}</div>
</div>
<div className="flex gap-5 pr-1 text-xs">
<div><span className="text-gray-500">{t("下载")}</span><span className="ml-2 font-mono font-semibold text-sky-500">{formatBytes(totals.rx)}</span></div>
<div><span className="text-gray-500">{t("上传")}</span><span className="ml-2 font-mono font-semibold text-violet-500">{formatBytes(totals.tx)}</span></div>
</div>
</div>
{loading ? (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">{t("流量图表加载中...")}</div>
) : (
<EChart option={option} className="mt-2 h-64 w-full" />
)}
{error ? <div className="mt-1 text-xs text-red-500">{error}</div> : null}
</div>
);
}
+5 -2
View File
@@ -167,15 +167,18 @@ export function isQmiControl(controlDevice?: string): boolean {
/* ---------------------------------------------------------------------------
* Signal helpers (5-bar overview variant).
* Thresholds are RSSI-calibrated (AT+CSQ style dBm, range ~-113..-51), NOT
* RSRP — RSSI runs ~20 dB hotter than RSRP on LTE, so RSRP-scaled thresholds
* peg near-full for any real signal and the bars never move with strength.
* ------------------------------------------------------------------------- */
export function signalValid(dbm?: number | null): boolean {
return typeof dbm === "number" && Number.isFinite(dbm) && dbm !== 0 && dbm !== -999;
}
// 0..5 bars
// 0..5 bars. RSSI dBm bands: ≥-70 excellent · -70..-85 good · -85..-100 fair · -100..-110 poor · <-110 edge
export function signalLevel(dbm?: number | null): number {
if (!signalValid(dbm)) return 0;
const d = dbm as number;
return d >= -75 ? 5 : d >= -85 ? 4 : d >= -95 ? 3 : d >= -105 ? 2 : 1;
return d >= -70 ? 5 : d >= -85 ? 4 : d >= -100 ? 3 : d >= -110 ? 2 : 1;
}
export type SignalTone = "green" | "amber" | "red" | "gray";
export function signalTone(dbm?: number | null): SignalTone {
+4 -2
View File
@@ -1,4 +1,4 @@
import type { DeviceOverview, ModemSummary } from "../../types";
import type { DeviceOverview, DeviceType, ModemSummary } from "../../types";
// PNN record read from the modem (opl/pnn drive the SIM operator display).
export interface ModemPnn {
@@ -22,9 +22,9 @@ export interface DeviceModem extends ModemSummary {
// Device detail (`/devices/:id/overview` -> devices[0]) with the extra fields
// the reference page reads. All camelCase (api auto-converts).
export interface DeviceDetail extends Omit<DeviceOverview, "modem" | "traffic"> {
developerEnabled?: boolean;
modem: DeviceModem;
localPhone?: string;
privateIpv6?: string;
publicIpv6?: string;
e911SetupAvailable?: boolean;
activeEsimProfileName?: string;
@@ -36,6 +36,7 @@ export interface DeviceDetail extends Omit<DeviceOverview, "modem" | "traffic">
export interface AddDeviceForm {
id: string;
name: string;
deviceType: DeviceType | "";
interface: string;
modemImei: string;
usbPath: string;
@@ -57,6 +58,7 @@ export interface OperatorCandidate {
plmn?: string;
operatorName?: string;
shortName?: string;
countryCode?: string;
status?: string;
rats?: Array<string | null>;
includesPcsDigit?: boolean;
@@ -0,0 +1,58 @@
import { SettingsRegular } from "@fluentui/react-icons";
import type { DeveloperSettings } from "../../types";
import { useI18n } from "../../lib/i18n";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { CardDecor, CardIcon, CardTitle } from "./Cards";
export function DeviceQuotaCard({
value,
limit,
loading,
saving,
onLimitChange,
onSave,
}: {
value: DeveloperSettings | null;
limit: number;
loading: boolean;
saving: boolean;
onLimitChange: (limit: number) => void;
onSave: () => void;
}) {
const { lang } = useI18n();
const zh = lang === "zh";
return (
<div className="ui-card group relative overflow-hidden p-8">
<CardDecor />
<div className="relative z-10 mb-6 flex items-center gap-3">
<CardIcon>
<SettingsRegular className="text-[24px]" />
</CardIcon>
<CardTitle
title={zh ? "设备配额" : "Device quota"}
subtitle={zh ? "开发者模式下允许配置的设备数量" : "Configured device allowance in developer mode"}
/>
</div>
<div className="relative z-10 space-y-4">
<Input
type="number"
min={1}
max={value?.maxDeviceLimit ?? 128}
value={Number.isFinite(limit) ? limit : ""}
disabled={loading || saving}
onChange={(event) => onLimitChange(Number(event.target.value))}
suffix={zh ? "台" : "devices"}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{zh
? `关闭开发者模式后会自动恢复为 ${value?.defaultDeviceLimit ?? 5} 台,不会删除已经添加的设备。`
: `Disabling developer mode restores ${value?.defaultDeviceLimit ?? 5}; existing devices are not deleted.`}
</p>
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
{zh ? "保存设备配额" : "Save device quota"}
</Button>
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { LockClosedRegular } from "@fluentui/react-icons";
import type { HTTPSSettings } from "../../types";
import { useI18n } from "../../lib/i18n";
import { Button } from "../ui/Button";
import { Switch } from "../ui/Switch";
import { CardDecor, CardIcon, CardTitle } from "./Cards";
export function HTTPSCard({
value,
loading,
saving,
onToggle,
}: {
value: HTTPSSettings | null;
loading: boolean;
saving: boolean;
onToggle: (enabled: boolean) => void;
}) {
const { lang } = useI18n();
const zh = lang === "zh";
const enabled = !!value?.enabled;
return (
<div className="ui-card group relative overflow-hidden p-8">
<CardDecor />
<div className="relative z-10 mb-6 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CardIcon>
<LockClosedRegular className="text-[24px]" />
</CardIcon>
<CardTitle
title={zh ? "本机自签 HTTPS" : "Local self-signed HTTPS"}
subtitle={zh ? "为浏览器麦克风和安全连接提供 HTTPS" : "HTTPS for browser microphone and secure connections"}
/>
</div>
<Switch checked={enabled} disabled={loading || saving} loading={saving} onChange={onToggle} />
</div>
<div className="relative z-10 space-y-4 text-sm text-gray-600 dark:text-gray-300">
<p>
{enabled
? (zh ? "已强制 HTTPS;HTTP 请求会自动跳转。关闭后立即恢复 HTTP。" : "HTTPS is enforced and HTTP redirects automatically. Disable it to return to HTTP immediately.")
: (zh ? "当前使用 HTTP。启用后会生成并持久保存本机自签证书。" : "HTTP is active. Enabling generates and persists a local self-signed certificate.")}
</p>
{value?.fingerprint ? (
<div className="rounded-xl bg-gray-50 p-3 dark:bg-white/5">
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-gray-500">SHA-256</div>
<div className="break-all font-mono text-xs">{value.fingerprint}</div>
</div>
) : null}
<p className="text-xs text-amber-600 dark:text-amber-400">
{zh
? "自签证书需要在系统或浏览器中信任;否则浏览器可能继续拒绝麦克风权限。"
: "Trust the self-signed certificate in the operating system or browser; otherwise microphone access may still be rejected."}
</p>
<Button onClick={() => window.open("/api/settings/https/certificate", "_blank")} disabled={loading}>
{zh ? "下载自签证书" : "Download certificate"}
</Button>
</div>
</div>
);
}
@@ -22,6 +22,8 @@ import { cx } from "../../lib/utils";
import { BrandLogo } from "./BrandLogo";
import { VersionBadge } from "./VersionBadge";
import { listPlugins, type InstalledPlugin } from "../../extensions";
import { api } from "../../api";
import type { SystemInfo } from "../../types";
const NAV = [
{ to: "/", label: "仪表盘", icon: BoardRegular, end: true },
@@ -43,11 +45,24 @@ export function AuthenticatedShell({
const [isMobile, setIsMobile] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const [developer, setDeveloper] = useState(false);
const { logout, user } = useAuth();
const { t, lang } = useI18n();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
let active = true;
const load = () => api<SystemInfo>("/system/info").then((info) => {
if (active) setDeveloper(!!info.developer);
}).catch(() => {
if (active) setDeveloper(false);
});
void load();
const timer = window.setInterval(load, 10_000);
return () => { active = false; window.clearInterval(timer); };
}, []);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const update = () => {
@@ -99,15 +114,17 @@ export function AuthenticatedShell({
const navItems: Array<(typeof NAV)[number] | { to: string; label: string; icon: typeof GlobeRegular; pluginLabelZH?: string }> = [];
for (const item of NAV) {
navItems.push(item);
if (item.to === "/sms") {
for (const extension of sidebarPlugins.filter((entry) => !entry.contribution.after || entry.contribution.after === "sms")) {
navItems.push({
to: `/extensions/${encodeURIComponent(extension.plugin.id)}/${encodeURIComponent(extension.contribution.id)}`,
label: extension.contribution.label,
pluginLabelZH: extension.contribution.labelZh,
icon: GlobeRegular,
});
}
if (developer && item.to === "/proxy") {
navItems.push({ to: "/export-proxy", label: "导出代理", icon: GlobeRegular });
}
const itemKey = item.to.replace(/^\//, "") || "dashboard";
for (const extension of sidebarPlugins.filter((entry) => (entry.contribution.after || "sms") === itemKey)) {
navItems.push({
to: `/extensions/${encodeURIComponent(extension.plugin.id)}/${encodeURIComponent(extension.contribution.id)}`,
label: extension.contribution.label,
pluginLabelZH: extension.contribution.labelZh,
icon: GlobeRegular,
});
}
}
return (
+4 -4
View File
@@ -52,13 +52,13 @@ export function Modal({
role="dialog"
aria-modal="true"
className={cx(
"glass-modal relative w-full rounded-2xl shadow-2xl animate-[fade-slide-in_0.25s_cubic-bezier(0.4,0,0.2,1)]",
"glass-modal relative flex max-h-[calc(100dvh-2rem)] w-full flex-col rounded-2xl shadow-2xl animate-[fade-slide-in_0.25s_cubic-bezier(0.4,0,0.2,1)]",
width,
className,
)}
>
{(title || showClose) && (
<div className="flex items-center justify-between px-6 pt-5 pb-3">
<div className="flex shrink-0 items-center justify-between px-6 pt-5 pb-3">
<div className="text-base font-bold text-gray-900 dark:text-white">{title}</div>
{showClose && (
<button
@@ -72,8 +72,8 @@ export function Modal({
)}
</div>
)}
<div className={cx("px-6 pb-5", !title && "pt-5", bodyClassName)}>{children}</div>
{footer && <div className="flex items-center justify-end gap-3 px-6 pb-5">{footer}</div>}
<div className={cx("min-h-0 flex-1 overflow-y-auto px-6 pb-5", !title && "pt-5", bodyClassName)}>{children}</div>
{footer && <div className="flex shrink-0 items-center justify-end gap-3 px-6 pb-5">{footer}</div>}
</div>
</div>
);