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
+2
View File
@@ -12,6 +12,7 @@ import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
import DevicesPage from "./pages/DevicesPage";
import ProxyPage from "./pages/ProxyPage";
import ExportProxyPage from "./pages/ExportProxyPage";
import SmsPage from "./pages/SmsPage";
import LogsPage from "./pages/LogsPage";
import SettingsPage from "./pages/SettingsPage";
@@ -110,6 +111,7 @@ function AppRoot() {
<Route index element={<DashboardPage />} />
<Route path="devices/*" element={<DevicesPage />} />
<Route path="proxy" element={<ProxyPage />} />
<Route path="export-proxy" element={<ExportProxyPage />} />
<Route path="sms" element={<SmsPage />} />
<Route path="extensions/:pluginId/:contributionId" element={<ExtensionPage />} />
<Route path="logs" element={<LogsPage />} />
+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>
);
+4 -3
View File
@@ -1,6 +1,7 @@
// MCC/MNC → carrier lookup. Data is the musalbas/mcc-mnc-table dataset (the same
// one vohive uses), slimmed to { c: plmn→[name,iso], i: mcc→iso, t: 3-digit-MNC MCCs }.
// Source: https://raw.githubusercontent.com/musalbas/mcc-mnc-table/master/mcc-mnc-table.json
// MCC/MNC → carrier lookup. The offline table combines Android's maintained
// carrier ID database with the legacy global table as a fallback. Refresh it
// with scripts/update-carriers.py; runtime registration never depends on an
// external lookup service.
import table from "./mccmnc.json";
interface MccMncTable {
+18
View File
@@ -0,0 +1,18 @@
import type { DeviceType } from "../types";
export const DEFAULT_DEVICE_TYPE: DeviceType = "pcie_ec20_ec25";
export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; image: string }> = [
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
return DEVICE_TYPES.some((item) => item.value === value) ? (value as DeviceType) : DEFAULT_DEVICE_TYPE;
}
export function deviceTypeImage(value?: string | null): string {
const normalized = normalizeDeviceType(value);
return DEVICE_TYPES.find((item) => item.value === normalized)?.image || "/ec20.png";
}
+48 -1
View File
@@ -433,6 +433,8 @@ export const EN_DICT: Record<string, string> = {
: "This Week",
: "This Month",
: "Traffic Analysis",
"最近 7 天流量": "Traffic in the Last 7 Days",
"按天统计蜂窝数据上传与下载;从启用采样后开始累计": "Daily cellular upload and download; totals start when metering is enabled",
: "This Period",
: "Current Device",
: "Traffic",
@@ -509,7 +511,6 @@ export const EN_DICT: Record<string, string> = {
"全部状态": "All Statuses",
"关闭": "Close",
"内网 IPv4": "LAN IPv4",
"内网 IPv6": "LAN IPv6",
"切换到中文": "切换到中文",
"切换浅色模式": "Switch to light mode",
"切换深色模式": "Switch to dark mode",
@@ -746,6 +747,45 @@ export const EN_DICT: Record<string, string> = {
: "Delete Device",
: "Save Config",
"切换 IP": "Rotate IP",
"漫游数据": "Roaming Data",
"公网 IP": "Public IP",
"检测": "Detect",
"国家/地区": "Country / Region",
"公网 IP 检测失败": "Public IP detection failed",
"导出代理": "Export Proxy",
"将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用": "Export modem roaming data as host HTTP or SOCKS5 proxies; available only in developer mode",
"添加代理": "Add Proxy",
"网络接口": "Network Interface",
"协议": "Protocol",
"认证": "Authentication",
"错误": "Error",
"已停用": "Stopped",
"暂无导出代理配置": "No export proxies configured",
"先在设备页面开启漫游数据,再创建代理": "Enable roaming data on the device page before creating a proxy",
"代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。": "Proxy traffic uses protected cellular routing and isolated DNS without becoming the host default network. Disabling developer mode stops roaming data and permanently deletes every configuration here.",
"编辑导出代理": "Edit Export Proxy",
"添加导出代理": "Add Export Proxy",
"例如:EC20 漫游出口": "For example: EC20 roaming exit",
"代理认证": "Proxy Authentication",
"保存后立即启用": "Enable after saving",
"启用认证后必须填写用户名": "A username is required when authentication is enabled",
"留空则保留原密码": "Leave empty to keep the current password",
"请输入有效端口": "Enter a valid port",
"导出代理已更新": "Export proxy updated",
"导出代理已创建": "Export proxy created",
"导出代理已删除": "Export proxy deleted",
"确定删除这个导出代理配置吗?": "Delete this export proxy configuration?",
"漫游数据已开启,仅供 Export Proxy 使用": "Roaming data enabled for Export Proxy only",
"漫游数据已关闭": "Roaming data disabled",
"开启漫游数据失败": "Failed to enable roaming data",
"关闭漫游数据失败": "Failed to disable roaming data",
"蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口": "Cellular data is routed only to Export Proxy and never becomes the host default route",
"重新驻网": "Re-register",
"正在按当前选网配置重新驻网,请稍候...": "Re-registering with the current network selection...",
"已重新发起驻网": "Network re-registration started",
"重新驻网失败": "Network re-registration failed",
"已驻网": "Registered",
"已驻网(漫游)": "Registered (roaming)",
: "SMS",
: "Session Active",
: "Cancel Session",
@@ -783,6 +823,13 @@ export const EN_DICT: Record<string, string> = {
"重启模组并自动复检": "Reboot modem & re-check",
"重新检测": "Re-detect",
// ---- Device type ----
"设备类型": "Device Type",
"请选择设备类型": "Select a device type",
"410 WiFi 棒(高通芯片)": "410 WiFi Dongle (Qualcomm)",
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)",
// ---- AT 快捷指令(按 group · item 分组翻译) ----
: "Basics",
: "Network Control",
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -18,10 +18,12 @@ export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
// Signal strength (dBm) -> 0..4 bars, matching VoHive thresholds.
// Signal strength (RSSI dBm, AT+CSQ) -> 0..4 bars, matching VoHive thresholds.
// RSSI-calibrated (not RSRP): RSSI sits ~20 dB above RSRP on LTE, so RSRP-scaled
// bands peg at full for any real signal and the bars never reflect strength.
export function signalBars(dbm?: number | null): number {
if (typeof dbm !== "number" || !Number.isFinite(dbm) || dbm === 0 || dbm === -999) return 0;
return dbm > -70 ? 4 : dbm > -85 ? 3 : dbm > -100 ? 2 : 1;
return dbm >= -70 ? 4 : dbm >= -85 ? 3 : dbm >= -100 ? 2 : 1;
}
export function signalValid(dbm?: number | null): boolean {
@@ -30,7 +32,7 @@ export function signalValid(dbm?: number | null): boolean {
export function signalColor(dbm?: number | null): string {
if (!signalValid(dbm)) return "bg-gray-300 dark:bg-gray-600";
return dbm! > -70 ? "bg-green-500" : dbm! > -90 ? "bg-yellow-500" : "bg-red-500";
return dbm! >= -85 ? "bg-green-500" : dbm! >= -100 ? "bg-yellow-500" : "bg-red-500";
}
export function formatBytes(value?: number | null): string {
+14 -23
View File
@@ -24,6 +24,7 @@ const VALID_TABS = new Set(["overview", "esim", "at", "ussd", "config", "card"])
const EMPTY_ADD: AddDeviceForm = {
id: "",
name: "",
deviceType: "",
interface: "",
modemImei: "",
usbPath: "",
@@ -49,7 +50,7 @@ export default function DevicesPage() {
const [configSnapshot, setConfigSnapshot] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [rotating, setRotating] = useState(false);
const [dataToggling, setDataToggling] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [reconnectingVoWiFi, setReconnectingVoWiFi] = useState(false);
const [rescanning, setRescanning] = useState(false);
@@ -227,33 +228,19 @@ export default function DevicesPage() {
},
[setSearchParams],
);
const listRef = useRef(list);
listRef.current = list;
const handleRotateIp = useCallback(async () => {
const handleToggleRoamingData = useCallback(async (enabled: boolean) => {
const id = selectedIdRef.current.trim();
if (!id) return;
const item = listRef.current.find((d) => d.id === id);
if (!item?.networkConnected) {
message.warning(t("设备网络未连接,请先启动网络"));
return;
}
const ok = await confirmDialog(tf("确定对设备 {id} 执行 IP 轮换?", { id }), t("确认操作"), {
confirmText: t("立即轮换"),
cancelText: t("取消"),
type: "warning",
});
if (!ok) return;
setRotating(true);
setDataToggling(true);
try {
await api("/rotateip", { method: "POST", body: { deviceId: id } });
message.success(t("轮换请求已发送"));
await api(`/devices/${id}/network`, { method: "PATCH", body: { enabled } });
message.success(enabled ? t("漫游数据已开启,仅供 Export Proxy 使用") : t("漫游数据已关闭"));
await refreshAll();
refreshSoon(1500);
} catch (e) {
message.error(apiMessage(e) || t("轮换失败"));
message.error(apiMessage(e) || (enabled ? t("开启漫游数据失败") : t("关闭漫游数据失败")));
} finally {
setRotating(false);
setDataToggling(false);
}
}, [refreshAll, refreshSoon]);
@@ -406,6 +393,10 @@ export default function DevicesPage() {
message.warning(t("请选择一个未配置设备"));
return;
}
if (!addConfig.deviceType) {
message.warning(t("请选择设备类型"));
return;
}
const res = await api<{ warning?: string; started?: boolean }>("/devices", { method: "POST", body: { config: addConfig } });
if (res?.warning) message.warning(res.warning);
else if (res?.started === true) message.success(t("设备已添加并开始接管"));
@@ -673,11 +664,11 @@ export default function DevicesPage() {
<>
<DeviceDetailHeader
device={detail}
rotating={rotating}
dataToggling={dataToggling}
rebooting={rebooting}
reconnectingVoWiFi={reconnectingVoWiFi}
onCopyText={handleCopyText}
onRotateIp={handleRotateIp}
onToggleRoamingData={handleToggleRoamingData}
onReconnectVowifi={handleReconnectVoWiFi}
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
+244
View File
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AddRegular, DeleteRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
import { api, apiMessage } from "../api";
import type { DeviceListItem, DevicesResponse } from "../types";
import { Button, Input, Modal, PageHeader, Select, Switch, Tag, confirmDialog, message } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface ExportProxyConfig {
id: string;
name: string;
deviceId: string;
interface: string;
mode: "http" | "socks5";
listenHost: string;
listenPort: number;
enabled: boolean;
authEnabled: boolean;
username: string;
password: string;
}
interface ExportProxyStatus {
id: string;
running: boolean;
listen?: string;
error?: string;
}
const emptyConfig = (): ExportProxyConfig => ({
id: "",
name: "",
deviceId: "",
interface: "",
mode: "socks5",
listenHost: "0.0.0.0",
listenPort: 1080,
enabled: true,
authEnabled: false,
username: "",
password: "",
});
export default function ExportProxyPage() {
const { t } = useI18n();
const [configs, setConfigs] = useState<ExportProxyConfig[]>([]);
const [statuses, setStatuses] = useState<ExportProxyStatus[]>([]);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [form, setForm] = useState<ExportProxyConfig>(emptyConfig);
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState("");
const load = useCallback(async (initial = false) => {
if (initial) setLoading(true);
try {
const [configData, statusData, deviceData] = await Promise.all([
api<{ configs?: ExportProxyConfig[] }>("/export-proxies"),
api<{ configs?: ExportProxyStatus[] }>("/export-proxies/status"),
api<DevicesResponse>("/devices"),
]);
setConfigs(configData.configs || []);
setStatuses(statusData.configs || []);
setDevices((deviceData.devices || []).filter((device) => !!device.interface));
setError("");
} catch (err) {
setError(apiMessage(err));
} finally {
if (initial) setLoading(false);
}
}, []);
useEffect(() => {
void load(true);
const timer = window.setInterval(() => void load(), 5000);
return () => window.clearInterval(timer);
}, [load]);
const statusByID = useMemo(() => new Map(statuses.map((status) => [status.id, status])), [statuses]);
const deviceByID = useMemo(() => new Map(devices.map((device) => [device.id, device])), [devices]);
const edit = (config?: ExportProxyConfig) => {
if (config) {
setForm({ ...config, password: "" });
} else {
const first = devices[0];
setForm({ ...emptyConfig(), deviceId: first?.id || "", interface: first?.interface || "" });
}
setOpen(true);
};
const chooseDevice = (deviceId: string) => {
const device = deviceByID.get(deviceId);
setForm((current) => ({ ...current, deviceId, interface: device?.interface || "" }));
};
const save = async () => {
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.listenPort || form.listenPort < 1 || form.listenPort > 65535) return message.warning(t("请输入有效端口"));
if (form.authEnabled && !form.username.trim()) return message.warning(t("启用认证后必须填写用户名"));
setSaving(true);
try {
if (form.id) {
await api(`/export-proxies/${encodeURIComponent(form.id)}`, { method: "PUT", body: form });
message.success(t("导出代理已更新"));
} else {
await api("/export-proxies", { method: "POST", body: form });
message.success(t("导出代理已创建"));
}
setOpen(false);
await load();
} catch (err) {
message.error(apiMessage(err) || t("保存失败"));
} finally {
setSaving(false);
}
};
const toggle = async (config: ExportProxyConfig) => {
setBusy(config.id);
try {
await api(`/export-proxies/${encodeURIComponent(config.id)}`, {
method: "PUT",
body: { ...config, enabled: !config.enabled },
});
await load();
} catch (err) {
message.error(apiMessage(err));
} finally {
setBusy("");
}
};
const remove = async (config: ExportProxyConfig) => {
if (!await confirmDialog(t("确定删除这个导出代理配置吗?"), t("确认删除"), { type: "warning", confirmText: t("删除"), cancelText: t("取消") })) return;
setBusy(config.id);
try {
await api(`/export-proxies/${encodeURIComponent(config.id)}`, { method: "DELETE" });
message.success(t("导出代理已删除"));
await load();
} catch (err) {
message.error(apiMessage(err));
} finally {
setBusy("");
}
};
return (
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("导出代理")}
subtitle={t("将模块漫游数据导出为主机 HTTP 或 SOCKS5 代理;仅在开发者模式下可用")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加代理")}</Button>}
/>
<div className="ui-card overflow-hidden">
{error ? <div className="border-b border-red-200 bg-red-50 p-3 text-sm text-red-600 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">{error}</div> : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("名称")}</th>
<th className="px-4 py-3">{t("设备")}</th>
<th className="px-4 py-3">{t("网络接口")}</th>
<th className="px-4 py-3">{t("协议")}</th>
<th className="px-4 py-3">{t("监听地址")}</th>
<th className="px-4 py-3">{t("认证")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{configs.map((config) => {
const status = statusByID.get(config.id);
const device = deviceByID.get(config.deviceId);
return (
<tr key={config.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3 font-semibold">{config.name}</td>
<td className="px-4 py-3">{device?.name || config.deviceId}</td>
<td className="px-4 py-3 font-mono text-xs">{config.interface}</td>
<td className="px-4 py-3"><Tag type="primary">{config.mode.toUpperCase()}</Tag></td>
<td className="px-4 py-3 font-mono text-xs">{status?.listen || `${config.listenHost}:${config.listenPort}`}</td>
<td className="px-4 py-3">{config.authEnabled ? config.username : t("无")}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Switch checked={config.enabled} loading={busy === config.id} onChange={() => void toggle(config)} size="small" />
<span className={status?.running ? "text-green-600" : status?.error ? "text-red-500" : "text-gray-400"}>
{status?.running ? t("运行中") : status?.error ? t("错误") : t("已停用")}
</span>
</div>
{status?.error ? <div className="mt-1 max-w-xs text-xs text-red-500">{status.error}</div> : null}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button size="small" icon={<EditRegular />} onClick={() => edit(config)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} loading={busy === config.id} onClick={() => void remove(config)}>{t("删除")}</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!loading && !configs.length ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无导出代理配置")}</div>
<div className="mt-1 text-xs">{t("先在设备页面开启漫游数据,再创建代理")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
</div>
<div className="ui-panel-muted mt-4 p-4 text-xs leading-6 text-gray-500">
{t("代理出口使用受保护的蜂窝路由和独立 DNS,不会把模块数据设为主机默认网络。关闭开发者模式会停止漫游数据并永久删除这里的全部配置。")}
</div>
<Modal
open={open}
onClose={() => setOpen(false)}
title={form.id ? t("编辑导出代理") : t("添加导出代理")}
width="max-w-2xl"
footer={<><Button onClick={() => setOpen(false)}>{t("取消")}</Button><Button variant="primary" loading={saving} onClick={() => void save()}>{t("保存")}</Button></>}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="space-y-1.5 text-sm"><span>{t("名称")}</span><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:EC20 漫游出口")} /></label>
<label className="space-y-1.5 text-sm"><span>{t("设备")}</span><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} · ${device.interface}` }))} /></label>
<label className="space-y-1.5 text-sm"><span>{t("网络接口")}</span><Input value={form.interface} readOnly disabled /></label>
<label className="space-y-1.5 text-sm"><span>{t("协议")}</span><Select value={form.mode} onChange={(value) => setForm({ ...form, mode: value as "http" | "socks5" })} options={[{ value: "socks5", label: "SOCKS5" }, { value: "http", label: "HTTP" }]} /></label>
<label className="space-y-1.5 text-sm"><span>{t("监听地址")}</span><Input value={form.listenHost} onChange={(event) => setForm({ ...form, listenHost: event.target.value })} /></label>
<label className="space-y-1.5 text-sm"><span>{t("端口")}</span><Input type="number" min={1} max={65535} value={form.listenPort} onChange={(event) => setForm({ ...form, listenPort: Number(event.target.value) })} /></label>
<div className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2 dark:border-white/10"><span className="text-sm">{t("代理认证")}</span><Switch checked={form.authEnabled} onChange={(authEnabled) => setForm({ ...form, authEnabled })} /></div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2 dark:border-white/10"><span className="text-sm">{t("保存后立即启用")}</span><Switch checked={form.enabled} onChange={(enabled) => setForm({ ...form, enabled })} /></div>
{form.authEnabled ? <>
<label className="space-y-1.5 text-sm"><span>{t("用户名")}</span><Input value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} autoComplete="off" /></label>
<label className="space-y-1.5 text-sm"><span>{t("密码")}</span><Input type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} autoComplete="new-password" placeholder={form.id ? t("留空则保留原密码") : ""} /></label>
</> : null}
</div>
</Modal>
</div>
);
}
+1
View File
@@ -40,6 +40,7 @@ export default function ExtensionPage() {
src={pluginAssetURL(selected.plugin, selected.contribution)}
className="h-[calc(100vh-10rem)] min-h-[560px] w-full rounded-xl border border-gray-200 bg-white dark:border-white/10 dark:bg-[#15151a]"
sandbox="allow-scripts allow-forms allow-same-origin"
allow="microphone; autoplay"
/>
</div>
);
+97 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { AlertRegular, CheckmarkRegular } from "@fluentui/react-icons";
import { api, apiMessage, getSecuritySettings, updateSecuritySettings } from "../api";
import type { NotificationSettings, SecuritySettings, SystemInfo } from "../types";
import type { DeveloperSettings, HTTPSSettings, NotificationSettings, SecuritySettings, SystemInfo } from "../types";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import { CardDecor, CardIcon, CardTitle, SecurityCard, SystemInfoCard } from "../components/settings/Cards";
import type { PasswordForm, UpdateInfo } from "../components/settings/Cards";
@@ -22,6 +22,8 @@ import {
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
const EMPTY_PASSWORD: PasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" };
@@ -58,6 +60,13 @@ export default function SettingsPage() {
const [clientAllowed, setClientAllowed] = useState(true);
const [loadingSecurity, setLoadingSecurity] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false);
const [httpsSettings, setHTTPSSettings] = useState<HTTPSSettings | null>(null);
const [loadingHTTPS, setLoadingHTTPS] = useState(false);
const [savingHTTPS, setSavingHTTPS] = useState(false);
const [developerSettings, setDeveloperSettings] = useState<DeveloperSettings | null>(null);
const [deviceLimit, setDeviceLimit] = useState(5);
const [loadingDeveloper, setLoadingDeveloper] = useState(false);
const [savingDeveloper, setSavingDeveloper] = useState(false);
const updateChannel = useCallback(<K extends keyof NotifyForms>(key: K, patch: Partial<NotifyForms[K]>) => {
setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
@@ -105,12 +114,80 @@ export default function SettingsPage() {
}
}, [applySecurity]);
const fetchHTTPS = useCallback(async () => {
setLoadingHTTPS(true);
try {
setHTTPSSettings(await api<HTTPSSettings>("/settings/https"));
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "HTTPS 配置加载失败" : "Failed to load HTTPS settings"));
} finally {
setLoadingHTTPS(false);
}
}, [lang]);
const fetchDeveloperSettings = useCallback(async () => {
setLoadingDeveloper(true);
try {
const data = await api<DeveloperSettings>("/settings/developer");
setDeveloperSettings(data);
setDeviceLimit(data.deviceLimit);
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "开发者配置加载失败" : "Failed to load developer settings"));
} finally {
setLoadingDeveloper(false);
}
}, [lang]);
useEffect(() => {
void fetchSystemInfo();
void fetchNotifications();
void fetchSecurity();
}, [fetchSystemInfo, fetchNotifications, fetchSecurity]);
useEffect(() => {
if (systemInfo.developer) {
void fetchHTTPS();
void fetchDeveloperSettings();
} else {
setHTTPSSettings(null);
setDeveloperSettings(null);
setDeviceLimit(5);
}
}, [systemInfo.developer, fetchHTTPS, fetchDeveloperSettings]);
const onToggleHTTPS = useCallback(async (enabled: boolean) => {
setSavingHTTPS(true);
try {
const data = await api<HTTPSSettings>("/settings/https", { method: "PUT", body: { enabled } });
setHTTPSSettings(data);
message.success(lang === "zh" ? (enabled ? "HTTPS 已开启,正在切换连接" : "HTTPS 已关闭,正在恢复 HTTP") : (enabled ? "HTTPS enabled; reconnecting" : "HTTPS disabled; returning to HTTP"));
const target = enabled ? data.httpsUrl : data.httpUrl;
window.setTimeout(() => window.location.replace(target + window.location.pathname + window.location.search + window.location.hash), 700);
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "HTTPS 配置保存失败" : "Failed to save HTTPS settings"));
setSavingHTTPS(false);
}
}, [lang]);
const onSaveDeviceLimit = useCallback(async () => {
const maximum = developerSettings?.maxDeviceLimit ?? 128;
if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) {
message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`);
return;
}
setSavingDeveloper(true);
try {
const data = await api<DeveloperSettings>("/settings/developer", { method: "PUT", body: { deviceLimit } });
setDeveloperSettings(data);
setDeviceLimit(data.deviceLimit);
message.success(lang === "zh" ? "设备配额已保存" : "Device quota saved");
} catch (error) {
message.error(apiMessage(error) || (lang === "zh" ? "设备配额保存失败" : "Failed to save device quota"));
} finally {
setSavingDeveloper(false);
}
}, [developerSettings, deviceLimit, lang]);
const onSaveSecurity = useCallback(async () => {
setSavingSecurity(true);
try {
@@ -313,7 +390,25 @@ export default function SettingsPage() {
onSave={onSaveSecurity}
/>
{systemInfo.developer ? <PluginsCard /> : null}
{systemInfo.developer ? (
<>
<HTTPSCard
value={httpsSettings}
loading={loadingHTTPS}
saving={savingHTTPS}
onToggle={onToggleHTTPS}
/>
<DeviceQuotaCard
value={developerSettings}
limit={deviceLimit}
loading={loadingDeveloper}
saving={savingDeveloper}
onLimitChange={setDeviceLimit}
onSave={onSaveDeviceLimit}
/>
<PluginsCard />
</>
) : null}
<div className="notify-card ui-card group relative overflow-hidden p-8 lg:col-span-2">
<CardDecor />
+21
View File
@@ -1,5 +1,7 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25";
export interface Session {
authenticated: boolean;
username: string;
@@ -55,6 +57,7 @@ export interface ModemSummary {
operator: string;
nativeMcc: string;
nativeMnc: string;
operatorCountryCode?: string;
nativeSpn?: string;
cardMcc?: string;
cardMnc?: string;
@@ -86,6 +89,7 @@ export interface ModemSummary {
export interface DeviceListItem {
id: string;
name: string;
deviceType: DeviceType;
running: boolean;
healthy: boolean;
controlOnline: boolean;
@@ -100,6 +104,7 @@ export interface DeviceListItem {
interface: string;
esimTransport: string;
smsEnabled: boolean;
networkEnabled: boolean;
vowifiEnabled: boolean;
vowifiActive?: boolean;
vowifiRuntime: VoWiFiRuntime;
@@ -117,6 +122,7 @@ export interface DevicesResponse {
export interface DashboardDevice {
id: string;
name: string;
deviceType: DeviceType;
interface: string;
proxyPort: number;
publicIp: string;
@@ -181,6 +187,7 @@ export interface DiscoveredDevice {
export interface DeviceConfig {
id: string;
name: string;
deviceType: DeviceType;
interface: string;
controlDevice: string;
atPort: string;
@@ -382,6 +389,20 @@ export interface SystemInfo {
developer?: boolean;
}
export interface HTTPSSettings {
enabled: boolean;
httpUrl: string;
httpsUrl: string;
fingerprint?: string;
notAfter?: string;
}
export interface DeveloperSettings {
deviceLimit: number;
defaultDeviceLimit: number;
maxDeviceLimit: number;
}
export type Notice = {
kind: "success" | "error" | "warning" | "info";
title: string;