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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user