mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-19 22:33:43 +08:00
feat: expand device networking and management
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user