mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-04 01:06:36 +08:00
refactor(frontend): extract reusable hooks and page helper modules
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface ErrorPayload {
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUnauthorizedError = (error: unknown): boolean => {
|
||||||
|
return axios.isAxiosError(error) && error.response?.status === 401;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractApiErrorMessage = (
|
||||||
|
error: unknown,
|
||||||
|
fallback = "网络请求失败",
|
||||||
|
): string => {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const payload = error.response?.data as ErrorPayload | undefined;
|
||||||
|
|
||||||
|
return payload?.msg || payload?.message || error.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
export interface NodeApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
inx?: number;
|
||||||
|
syncError?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserApiItem {
|
||||||
|
id: number;
|
||||||
|
user: string;
|
||||||
|
name?: string;
|
||||||
|
status: number;
|
||||||
|
flow: number;
|
||||||
|
num: number;
|
||||||
|
expTime?: number;
|
||||||
|
flowResetTime?: number;
|
||||||
|
inFlow?: number;
|
||||||
|
outFlow?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListQuery {
|
||||||
|
current?: number;
|
||||||
|
size?: number;
|
||||||
|
keyword?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
status: number;
|
||||||
|
entryNodeId: number;
|
||||||
|
exitNodeId: number;
|
||||||
|
inx?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
tunnelName?: string;
|
||||||
|
inIp?: string;
|
||||||
|
inPort?: number;
|
||||||
|
remoteAddr?: string;
|
||||||
|
inFlow?: number;
|
||||||
|
outFlow?: number;
|
||||||
|
userId?: number;
|
||||||
|
tunnelId?: number;
|
||||||
|
inx?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTunnelApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tunnelId?: number;
|
||||||
|
tunnelName?: string;
|
||||||
|
inNodePortSta?: number;
|
||||||
|
inNodePortEnd?: number;
|
||||||
|
speedId?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTunnelPermissionApiItem {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
tunnelId: number;
|
||||||
|
tunnelName: string;
|
||||||
|
status: number;
|
||||||
|
flow: number;
|
||||||
|
num: number;
|
||||||
|
expTime: number;
|
||||||
|
flowResetTime: number;
|
||||||
|
speedId?: number | null;
|
||||||
|
speedLimitName?: string;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
tunnelFlow?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatisticsFlowApiItem {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
flow: number;
|
||||||
|
totalFlow: number;
|
||||||
|
time: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeedLimitApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tunnelId: number;
|
||||||
|
speed: number;
|
||||||
|
status: number;
|
||||||
|
tunnelName: string;
|
||||||
|
createdTime: string;
|
||||||
|
updatedTime: string;
|
||||||
|
uploadSpeed?: number;
|
||||||
|
downloadSpeed?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelGroupApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
tunnelIds: number[];
|
||||||
|
tunnelNames: string[];
|
||||||
|
createdTime: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGroupApiItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
userIds: number[];
|
||||||
|
userNames: string[];
|
||||||
|
createdTime: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupPermissionApiItem {
|
||||||
|
id: number;
|
||||||
|
userGroupId: number;
|
||||||
|
userGroupName: string;
|
||||||
|
tunnelGroupId: number;
|
||||||
|
tunnelGroupName: string;
|
||||||
|
createdTime: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelDiagnosisApiItem {
|
||||||
|
success: boolean;
|
||||||
|
description: string;
|
||||||
|
nodeName: string;
|
||||||
|
nodeId: string;
|
||||||
|
targetIp: string;
|
||||||
|
targetPort?: number;
|
||||||
|
message?: string;
|
||||||
|
averageTime?: number;
|
||||||
|
packetLoss?: number;
|
||||||
|
fromChainType?: number;
|
||||||
|
fromInx?: number;
|
||||||
|
toChainType?: number;
|
||||||
|
toInx?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelDiagnosisApiData {
|
||||||
|
tunnelName: string;
|
||||||
|
tunnelType: string;
|
||||||
|
timestamp: number;
|
||||||
|
results: TunnelDiagnosisApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardDiagnosisApiData {
|
||||||
|
forwardName: string;
|
||||||
|
timestamp: number;
|
||||||
|
results: TunnelDiagnosisApiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeReleaseApiItem {
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPackageInfoApiData {
|
||||||
|
userInfo: {
|
||||||
|
flow: number;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
num: number;
|
||||||
|
expTime?: string;
|
||||||
|
flowResetTime?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
tunnelPermissions: UserTunnelPermissionApiItem[];
|
||||||
|
forwards: ForwardApiItem[];
|
||||||
|
statisticsFlows: StatisticsFlowApiItem[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOperationResult {
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMutationPayload {
|
||||||
|
id?: number;
|
||||||
|
user?: string;
|
||||||
|
name?: string;
|
||||||
|
password?: string;
|
||||||
|
status?: number;
|
||||||
|
flow?: number;
|
||||||
|
num?: number;
|
||||||
|
expTime?: number | string;
|
||||||
|
flowResetTime?: number;
|
||||||
|
tunnelFlow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeMutationPayload {
|
||||||
|
id?: number | null;
|
||||||
|
name?: string;
|
||||||
|
status?: number;
|
||||||
|
inx?: number;
|
||||||
|
serverIp?: string;
|
||||||
|
serverIpV4?: string;
|
||||||
|
serverIpV6?: string;
|
||||||
|
port?: string;
|
||||||
|
tcpListenAddr?: string;
|
||||||
|
udpListenAddr?: string;
|
||||||
|
interfaceName?: string;
|
||||||
|
http?: number;
|
||||||
|
tls?: number;
|
||||||
|
socks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelChainNodePayload {
|
||||||
|
nodeId: number;
|
||||||
|
protocol?: string;
|
||||||
|
strategy?: string;
|
||||||
|
chainType?: number;
|
||||||
|
inx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelMutationPayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
type?: number;
|
||||||
|
status?: number;
|
||||||
|
flow?: number;
|
||||||
|
trafficRatio?: number;
|
||||||
|
inIp?: string;
|
||||||
|
ipPreference?: string;
|
||||||
|
inNodeId?: TunnelChainNodePayload[];
|
||||||
|
outNodeId?: TunnelChainNodePayload[];
|
||||||
|
chainNodes?: TunnelChainNodePayload[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTunnelAssignPayload {
|
||||||
|
userId?: number;
|
||||||
|
id?: number;
|
||||||
|
tunnelId?: number;
|
||||||
|
flow?: number;
|
||||||
|
num?: number;
|
||||||
|
expTime?: number;
|
||||||
|
flowResetTime?: number;
|
||||||
|
status?: number;
|
||||||
|
speedId?: number | null;
|
||||||
|
tunnels?: Array<{ tunnelId: number; speedId?: number | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTunnelListQuery {
|
||||||
|
userId?: number;
|
||||||
|
tunnelId?: number;
|
||||||
|
current?: number;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTunnelRemovePayload {
|
||||||
|
id?: number;
|
||||||
|
userId?: number;
|
||||||
|
tunnelId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardMutationPayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
status?: number;
|
||||||
|
tunnelId?: number | null;
|
||||||
|
inIp?: string;
|
||||||
|
inPort?: number | null;
|
||||||
|
remoteAddr?: string;
|
||||||
|
strategy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeedLimitMutationPayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
speed?: number;
|
||||||
|
status?: number;
|
||||||
|
tunnelId?: number | null;
|
||||||
|
tunnelName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePasswordPayload {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
newUsername?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportPayload {
|
||||||
|
types: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||||
|
|
||||||
|
interface BaseStateProps {
|
||||||
|
message: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageLoadingState = ({
|
||||||
|
message,
|
||||||
|
className = "h-64",
|
||||||
|
}: BaseStateProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<span className="text-default-600">{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageEmptyState = ({
|
||||||
|
message,
|
||||||
|
className = "h-48",
|
||||||
|
}: BaseStateProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
|
<span className="text-default-500">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageErrorState = ({
|
||||||
|
message,
|
||||||
|
className = "h-48",
|
||||||
|
}: BaseStateProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
|
<span className="text-danger">{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { DEFAULT_MOBILE_BREAKPOINT } from "@/hooks/useMobileBreakpoint";
|
||||||
|
|
||||||
|
const detectH5Mode = (): boolean => {
|
||||||
|
const isMobile = window.innerWidth <= DEFAULT_MOBILE_BREAKPOINT;
|
||||||
|
const isMobileBrowser =
|
||||||
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent,
|
||||||
|
);
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const isH5Param = urlParams.get("h5") === "true";
|
||||||
|
|
||||||
|
return isMobile || isMobileBrowser || isH5Param;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useH5Mode = (): boolean => {
|
||||||
|
const [isH5, setIsH5] = useState(detectH5Mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkH5Mode = () => {
|
||||||
|
setIsH5(detectH5Mode());
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", checkH5Mode);
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", checkH5Mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isH5;
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export const useMobileBreakpoint = (
|
||||||
|
breakpoint = DEFAULT_MOBILE_BREAKPOINT,
|
||||||
|
): boolean => {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.innerWidth < breakpoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < breakpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
export const useScrollTopOnPathChange = (): void => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||||
|
} catch {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.scrollTop = 0;
|
||||||
|
document.documentElement.scrollTop = 0;
|
||||||
|
}, [pathname]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { isWebViewFunc } from "@/utils/panel";
|
||||||
|
|
||||||
|
export const useWebViewMode = (): boolean => {
|
||||||
|
const [isWebView, setIsWebView] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsWebView(isWebViewFunc());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isWebView;
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { AnnouncementData } from "@/api";
|
||||||
|
|
||||||
|
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||||
|
|
||||||
|
interface AnnouncementBannerProps {
|
||||||
|
announcement: AnnouncementData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnnouncementBanner = ({
|
||||||
|
announcement,
|
||||||
|
}: AnnouncementBannerProps) => {
|
||||||
|
if (!announcement.content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||||
|
<CardBody className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||||
|
公告
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||||
|
{announcement.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
import { PageEmptyState } from "@/components/page-state";
|
||||||
|
|
||||||
|
interface FlowChartPoint {
|
||||||
|
time: string;
|
||||||
|
flow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowChartCardProps {
|
||||||
|
statisticsFlowsCount: number;
|
||||||
|
chartData: FlowChartPoint[];
|
||||||
|
formatFlow: (value: number, unit?: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlowChartCard = ({
|
||||||
|
statisticsFlowsCount,
|
||||||
|
chartData,
|
||||||
|
formatFlow,
|
||||||
|
}: FlowChartCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="w-5 h-5 text-primary"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||||
|
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||||
|
24小时流量统计
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="pt-0">
|
||||||
|
{statisticsFlowsCount === 0 ? (
|
||||||
|
<PageEmptyState className="h-48" message="暂无流量统计数据" />
|
||||||
|
) : (
|
||||||
|
<div className="h-64 lg:h-80 w-full">
|
||||||
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value: number | string) => {
|
||||||
|
const v = typeof value === "number" ? value : Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(v)) return String(value);
|
||||||
|
if (v === 0) return "0";
|
||||||
|
if (v < 1024) return `${v}B`;
|
||||||
|
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||||
|
if (v < 1024 * 1024 * 1024)
|
||||||
|
return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||||
|
|
||||||
|
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||||
|
}}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value?: number | string }>;
|
||||||
|
label?: string | number;
|
||||||
|
}) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const firstValue = payload[0]?.value;
|
||||||
|
const numericValue =
|
||||||
|
typeof firstValue === "number"
|
||||||
|
? firstValue
|
||||||
|
: Number(firstValue);
|
||||||
|
const flowValue = Number.isFinite(numericValue)
|
||||||
|
? numericValue
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||||
|
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||||
|
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
activeDot={{
|
||||||
|
r: 4,
|
||||||
|
stroke: "#8b5cf6",
|
||||||
|
strokeWidth: 2,
|
||||||
|
fill: "#fff",
|
||||||
|
}}
|
||||||
|
dataKey="flow"
|
||||||
|
dot={false}
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={3}
|
||||||
|
type="monotone"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
iconClassName: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
bottomContent?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetricCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
iconClassName,
|
||||||
|
icon,
|
||||||
|
bottomContent,
|
||||||
|
}: MetricCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<CardBody className="p-3 lg:p-4">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={`p-1.5 lg:p-2 rounded-lg flex-shrink-0 ${iconClassName}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{bottomContent}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAnnouncement,
|
||||||
|
getUserPackageInfo,
|
||||||
|
type AnnouncementData,
|
||||||
|
} from "@/api";
|
||||||
|
import type { ForwardApiItem } from "@/api/types";
|
||||||
|
import { getAdminFlag } from "@/utils/session";
|
||||||
|
|
||||||
|
export interface DashboardUserInfo {
|
||||||
|
flow: number;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
num: number;
|
||||||
|
expTime?: string | number;
|
||||||
|
flowResetTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserTunnel {
|
||||||
|
id: number;
|
||||||
|
tunnelId: number;
|
||||||
|
tunnelName: string;
|
||||||
|
flow: number;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
num: number;
|
||||||
|
expTime?: number;
|
||||||
|
flowResetTime?: number;
|
||||||
|
tunnelFlow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardForward {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tunnelId: number;
|
||||||
|
tunnelName: string;
|
||||||
|
inIp: string;
|
||||||
|
inPort: number;
|
||||||
|
remoteAddr: string;
|
||||||
|
inFlow: number;
|
||||||
|
outFlow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStatisticsFlow {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
flow: number;
|
||||||
|
totalFlow: number;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardDataState {
|
||||||
|
loading: boolean;
|
||||||
|
userInfo: DashboardUserInfo;
|
||||||
|
userTunnels: DashboardUserTunnel[];
|
||||||
|
forwardList: DashboardForward[];
|
||||||
|
statisticsFlows: DashboardStatisticsFlow[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
announcement: AnnouncementData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkExpirationNotifications = (
|
||||||
|
userInfo: DashboardUserInfo,
|
||||||
|
tunnels: DashboardUserTunnel[],
|
||||||
|
) => {
|
||||||
|
const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`;
|
||||||
|
const lastNotified = localStorage.getItem("lastNotified");
|
||||||
|
|
||||||
|
if (lastNotified === notificationKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasNotification = false;
|
||||||
|
|
||||||
|
if (userInfo.expTime) {
|
||||||
|
const expDate = new Date(userInfo.expTime);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||||
|
const diffTime = expDate.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 7 && diffDays > 0) {
|
||||||
|
hasNotification = true;
|
||||||
|
if (diffDays === 1) {
|
||||||
|
toast("账户将于明天过期,请及时续费", {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 6000,
|
||||||
|
style: { background: "#f59e0b", color: "#fff" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast(`账户将于${diffDays}天后过期,请及时续费`, {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 6000,
|
||||||
|
style: { background: "#f59e0b", color: "#fff" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (diffDays <= 0) {
|
||||||
|
hasNotification = true;
|
||||||
|
toast("账户已过期,请立即续费", {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 8000,
|
||||||
|
style: { background: "#ef4444", color: "#fff" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnels.forEach((tunnel) => {
|
||||||
|
if (!tunnel.expTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expDate = new Date(tunnel.expTime);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (!isNaN(expDate.getTime()) && expDate > now) {
|
||||||
|
const diffTime = expDate.getTime() - now.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays <= 7 && diffDays > 0) {
|
||||||
|
hasNotification = true;
|
||||||
|
if (diffDays === 1) {
|
||||||
|
toast(`隧道"${tunnel.tunnelName}"将于明天过期`, {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 5000,
|
||||||
|
style: { background: "#f59e0b", color: "#fff" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 5000,
|
||||||
|
style: { background: "#f59e0b", color: "#fff" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (diffDays <= 0) {
|
||||||
|
hasNotification = true;
|
||||||
|
toast(`隧道"${tunnel.tunnelName}"已过期`, {
|
||||||
|
icon: "⚠️",
|
||||||
|
duration: 6000,
|
||||||
|
style: { background: "#ef4444", color: "#fff" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasNotification) {
|
||||||
|
localStorage.setItem("lastNotified", notificationKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeForwards = (items: ForwardApiItem[]) => {
|
||||||
|
return (items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name || "",
|
||||||
|
tunnelId: item.tunnelId ?? 0,
|
||||||
|
tunnelName: item.tunnelName || "",
|
||||||
|
inIp: item.inIp || "",
|
||||||
|
inPort: item.inPort || 0,
|
||||||
|
remoteAddr: item.remoteAddr || "",
|
||||||
|
inFlow: item.inFlow || 0,
|
||||||
|
outFlow: item.outFlow || 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => {
|
||||||
|
return (items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
inFlow: item.inFlow ?? 0,
|
||||||
|
outFlow: item.outFlow ?? 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDashboardData = (): DashboardDataState => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [userInfo, setUserInfo] = useState<DashboardUserInfo>(
|
||||||
|
{} as DashboardUserInfo,
|
||||||
|
);
|
||||||
|
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
|
||||||
|
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
|
||||||
|
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getAnnouncement();
|
||||||
|
|
||||||
|
if (res.code === 0 && res.data && res.data.enabled === 1) {
|
||||||
|
setAnnouncement(res.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load announcement:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPackageData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getUserPackageInfo();
|
||||||
|
|
||||||
|
if (res.code === 0) {
|
||||||
|
const data = res.data;
|
||||||
|
const normalizedTunnelPermissions = normalizeTunnelPermissions(
|
||||||
|
data.tunnelPermissions || [],
|
||||||
|
);
|
||||||
|
const normalizedForwards = normalizeForwards(data.forwards || []);
|
||||||
|
|
||||||
|
setUserInfo(data.userInfo || ({} as DashboardUserInfo));
|
||||||
|
setUserTunnels(normalizedTunnelPermissions);
|
||||||
|
setForwardList(normalizedForwards);
|
||||||
|
setStatisticsFlows(data.statisticsFlows || []);
|
||||||
|
|
||||||
|
checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions);
|
||||||
|
} else {
|
||||||
|
toast.error(res.msg || "获取套餐信息失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("获取套餐信息失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setUserInfo({} as DashboardUserInfo);
|
||||||
|
setUserTunnels([]);
|
||||||
|
setForwardList([]);
|
||||||
|
setStatisticsFlows([]);
|
||||||
|
setIsAdmin(getAdminFlag());
|
||||||
|
|
||||||
|
loadPackageData();
|
||||||
|
loadAnnouncement();
|
||||||
|
localStorage.setItem("e", "/dashboard");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
userInfo,
|
||||||
|
userTunnels,
|
||||||
|
forwardList,
|
||||||
|
statisticsFlows,
|
||||||
|
isAdmin,
|
||||||
|
announcement,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export interface ForwardAddressItem {
|
||||||
|
id: number;
|
||||||
|
address: string;
|
||||||
|
copying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForwardAddressAction =
|
||||||
|
| { type: "none" }
|
||||||
|
| { type: "copy"; text: string; label: string }
|
||||||
|
| { type: "modal"; title: string; items: ForwardAddressItem[] };
|
||||||
|
|
||||||
|
const splitAddressEntries = (value: string): string[] => {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAddressWithPort = (ip: string, port: number): string => {
|
||||||
|
if (ip.includes(":") && !ip.startsWith("[")) {
|
||||||
|
return `[${ip}]:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ip}:${port}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatInAddress = (ipString: string, port: number): string => {
|
||||||
|
if (!ipString) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = splitAddressEntries(ipString);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPort = /:\d+$/.test(items[0]);
|
||||||
|
|
||||||
|
if (hasPort) {
|
||||||
|
if (items.length === 1) {
|
||||||
|
return items[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${items[0]} (+${items.length - 1}个)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!port) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 1) {
|
||||||
|
return formatAddressWithPort(items[0], port);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatAddressWithPort(items[0], port)} (+${items.length - 1}个)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRemoteAddress = (addressString: string): string => {
|
||||||
|
if (!addressString) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const addresses = splitAddressEntries(addressString);
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 1) {
|
||||||
|
return addresses[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${addresses[0]} (+${addresses.length - 1})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasMultipleAddresses = (addressString: string): boolean => {
|
||||||
|
if (!addressString) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return splitAddressEntries(addressString).length > 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveForwardAddressAction = (
|
||||||
|
addressString: string,
|
||||||
|
port: number | null,
|
||||||
|
title: string,
|
||||||
|
): ForwardAddressAction => {
|
||||||
|
if (!addressString) {
|
||||||
|
return { type: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let addresses: string[];
|
||||||
|
|
||||||
|
if (port !== null) {
|
||||||
|
const items = splitAddressEntries(addressString);
|
||||||
|
|
||||||
|
if (items.length <= 1) {
|
||||||
|
return {
|
||||||
|
type: "copy",
|
||||||
|
text: formatInAddress(addressString, port),
|
||||||
|
label: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPort = /:\d+$/.test(items[0]);
|
||||||
|
|
||||||
|
addresses = hasPort
|
||||||
|
? items
|
||||||
|
: items.map((ip) => formatAddressWithPort(ip, port));
|
||||||
|
} else {
|
||||||
|
addresses = splitAddressEntries(addressString);
|
||||||
|
|
||||||
|
if (addresses.length <= 1) {
|
||||||
|
return {
|
||||||
|
type: "copy",
|
||||||
|
text: addressString,
|
||||||
|
label: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "modal",
|
||||||
|
title: `${title} (${addresses.length}个)`,
|
||||||
|
items: addresses.map((address, index) => ({
|
||||||
|
id: index,
|
||||||
|
address,
|
||||||
|
copying: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { BatchOperationResult } from "@/api/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
batchChangeTunnel,
|
||||||
|
batchDeleteForwards,
|
||||||
|
batchPauseForwards,
|
||||||
|
batchRedeployForwards,
|
||||||
|
batchResumeForwards,
|
||||||
|
} from "@/api";
|
||||||
|
import { extractApiErrorMessage } from "@/api/error-message";
|
||||||
|
|
||||||
|
export interface ForwardBatchActionOutcome {
|
||||||
|
toastVariant: "success" | "error";
|
||||||
|
toastMessage: string;
|
||||||
|
shouldRefresh: boolean;
|
||||||
|
closeDeleteModal?: boolean;
|
||||||
|
closeChangeTunnelModal?: boolean;
|
||||||
|
resetTargetTunnel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBatchResult = (value: unknown): BatchOperationResult => {
|
||||||
|
const raw = (value ?? {}) as Partial<BatchOperationResult>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
successCount: Number(raw.successCount ?? 0),
|
||||||
|
failCount: Number(raw.failCount ?? 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBatchToast = (
|
||||||
|
result: BatchOperationResult,
|
||||||
|
successText: string,
|
||||||
|
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
|
||||||
|
if (result.failCount === 0) {
|
||||||
|
return {
|
||||||
|
toastVariant: "success",
|
||||||
|
toastMessage: successText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeForwardBatchDelete = async (
|
||||||
|
ids: number[],
|
||||||
|
): Promise<ForwardBatchActionOutcome> => {
|
||||||
|
try {
|
||||||
|
const response = await batchDeleteForwards(ids);
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: response.msg || "删除失败",
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
||||||
|
shouldRefresh: true,
|
||||||
|
closeDeleteModal: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: extractApiErrorMessage(error, "删除失败"),
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeForwardBatchToggleService = async (
|
||||||
|
ids: number[],
|
||||||
|
enable: boolean,
|
||||||
|
): Promise<ForwardBatchActionOutcome> => {
|
||||||
|
const fallback = enable ? "启用失败" : "停用失败";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = enable
|
||||||
|
? await batchResumeForwards(ids)
|
||||||
|
: await batchPauseForwards(ids);
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: response.msg || fallback,
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
enable
|
||||||
|
? `成功启用 ${summary.successCount} 项`
|
||||||
|
: `成功停用 ${summary.successCount} 项`,
|
||||||
|
),
|
||||||
|
shouldRefresh: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: extractApiErrorMessage(error, fallback),
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeForwardBatchRedeploy = async (
|
||||||
|
ids: number[],
|
||||||
|
): Promise<ForwardBatchActionOutcome> => {
|
||||||
|
try {
|
||||||
|
const response = await batchRedeployForwards(ids);
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: response.msg || "下发失败",
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
||||||
|
shouldRefresh: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: extractApiErrorMessage(error, "下发失败"),
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeForwardBatchChangeTunnel = async (
|
||||||
|
ids: number[],
|
||||||
|
targetTunnelId: number,
|
||||||
|
): Promise<ForwardBatchActionOutcome> => {
|
||||||
|
try {
|
||||||
|
const response = await batchChangeTunnel({
|
||||||
|
forwardIds: ids,
|
||||||
|
targetTunnelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code !== 0) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: response.msg || "隧道失败",
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
||||||
|
shouldRefresh: true,
|
||||||
|
closeChangeTunnelModal: true,
|
||||||
|
resetTargetTunnel: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
toastVariant: "error",
|
||||||
|
toastMessage: extractApiErrorMessage(error, "隧道失败"),
|
||||||
|
shouldRefresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
export interface ForwardDiagnosisEntry {
|
||||||
|
success: boolean;
|
||||||
|
description: string;
|
||||||
|
nodeName: string;
|
||||||
|
nodeId: string;
|
||||||
|
targetIp: string;
|
||||||
|
targetPort?: number;
|
||||||
|
message?: string;
|
||||||
|
averageTime?: number;
|
||||||
|
packetLoss?: number;
|
||||||
|
fromChainType?: number;
|
||||||
|
fromInx?: number;
|
||||||
|
toChainType?: number;
|
||||||
|
toInx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardDiagnosisResult {
|
||||||
|
forwardName: string;
|
||||||
|
timestamp: number;
|
||||||
|
results: ForwardDiagnosisEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardDiagnosisFallbackInput {
|
||||||
|
forwardName: string;
|
||||||
|
remoteAddr: string;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickPrimaryTargetIp = (remoteAddr: string): string => {
|
||||||
|
return remoteAddr.split(",")[0] || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildForwardDiagnosisFallbackResult = ({
|
||||||
|
forwardName,
|
||||||
|
remoteAddr,
|
||||||
|
description,
|
||||||
|
message,
|
||||||
|
}: ForwardDiagnosisFallbackInput): ForwardDiagnosisResult => {
|
||||||
|
return {
|
||||||
|
forwardName,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
description,
|
||||||
|
nodeName: "-",
|
||||||
|
nodeId: "-",
|
||||||
|
targetIp: pickPrimaryTargetIp(remoteAddr),
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getForwardDiagnosisQualityDisplay = (
|
||||||
|
averageTime?: number,
|
||||||
|
packetLoss?: number,
|
||||||
|
): {
|
||||||
|
text: string;
|
||||||
|
color: "success" | "primary" | "warning" | "danger";
|
||||||
|
} | null => {
|
||||||
|
if (averageTime === undefined || packetLoss === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 30 && packetLoss === 0) {
|
||||||
|
return { text: "🚀 优秀", color: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 50 && packetLoss === 0) {
|
||||||
|
return { text: "✨ 很好", color: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 100 && packetLoss < 1) {
|
||||||
|
return { text: "👍 良好", color: "primary" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 150 && packetLoss < 2) {
|
||||||
|
return { text: "😐 一般", color: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 200 && packetLoss < 5) {
|
||||||
|
return { text: "😟 较差", color: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: "😵 很差", color: "danger" };
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { loadStoredOrder } from "@/utils/order-storage";
|
||||||
|
|
||||||
|
export interface ForwardOrderItem {
|
||||||
|
id: number;
|
||||||
|
userId?: number;
|
||||||
|
inx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FORWARD_ORDER_KEY = "forward-order";
|
||||||
|
|
||||||
|
export const getUserScopedForwards = <T extends ForwardOrderItem>(
|
||||||
|
forwards: T[],
|
||||||
|
currentUserId: number | null,
|
||||||
|
): T[] => {
|
||||||
|
if (currentUserId === null) {
|
||||||
|
return forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
return forwards.filter((item) => item.userId === currentUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildForwardOrder = <T extends ForwardOrderItem>(
|
||||||
|
forwards: T[],
|
||||||
|
currentUserId: number | null,
|
||||||
|
): { order: number[]; fromDatabase: boolean } => {
|
||||||
|
const userForwards = getUserScopedForwards(forwards, currentUserId);
|
||||||
|
|
||||||
|
const hasDbOrdering = userForwards.some(
|
||||||
|
(item) => item.inx !== undefined && item.inx !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDbOrdering) {
|
||||||
|
const dbOrder = [...userForwards]
|
||||||
|
.sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))
|
||||||
|
.map((item) => item.id);
|
||||||
|
|
||||||
|
return { order: dbOrder, fromDatabase: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: loadStoredOrder(
|
||||||
|
FORWARD_ORDER_KEY,
|
||||||
|
userForwards.map((item) => item.id),
|
||||||
|
),
|
||||||
|
fromDatabase: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const getConnectionStatusMeta = (
|
||||||
|
status: "online" | "offline",
|
||||||
|
): { color: "success" | "danger"; text: string } => {
|
||||||
|
if (status === "online") {
|
||||||
|
return { color: "success", text: "在线" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { color: "danger", text: "离线" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRemoteSyncErrorMessage = (syncError: string): string => {
|
||||||
|
if (syncError === "provider_share_deleted") {
|
||||||
|
return "提供方已删除该分享";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncError === "provider_share_disabled") {
|
||||||
|
return "提供方已禁用该分享";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncError === "provider_share_expired") {
|
||||||
|
return "提供方分享已过期";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `远程同步失败: ${syncError}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const tryCopyInstallCommand = async (
|
||||||
|
command: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface NodeSystemInfo {
|
||||||
|
cpuUsage: number;
|
||||||
|
memoryUsage: number;
|
||||||
|
uploadTraffic: number;
|
||||||
|
downloadTraffic: number;
|
||||||
|
uploadSpeed: number;
|
||||||
|
downloadSpeed: number;
|
||||||
|
uptime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawSystemInfo = Record<string, string | number | undefined>;
|
||||||
|
|
||||||
|
const toInteger = (value: string | number | undefined): number => {
|
||||||
|
return Number.parseInt(String(value ?? 0), 10) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toFloat = (value: string | number | undefined): number => {
|
||||||
|
return Number.parseFloat(String(value ?? 0)) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
|
||||||
|
if (typeof messageData === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(messageData);
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed as RawSystemInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageData && typeof messageData === "object") {
|
||||||
|
return messageData as RawSystemInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildNodeSystemInfo = (
|
||||||
|
messageData: unknown,
|
||||||
|
previous: NodeSystemInfo | null | undefined,
|
||||||
|
): NodeSystemInfo | null => {
|
||||||
|
const raw = parseRawSystemInfo(messageData);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadTraffic = toInteger(raw.bytes_transmitted);
|
||||||
|
const downloadTraffic = toInteger(raw.bytes_received);
|
||||||
|
const uptime = toInteger(raw.uptime);
|
||||||
|
|
||||||
|
let uploadSpeed = 0;
|
||||||
|
let downloadSpeed = 0;
|
||||||
|
|
||||||
|
if (previous && previous.uptime) {
|
||||||
|
const timeDiff = uptime - previous.uptime;
|
||||||
|
|
||||||
|
if (timeDiff > 0 && timeDiff <= 10) {
|
||||||
|
const uploadDiff = uploadTraffic - previous.uploadTraffic;
|
||||||
|
const downloadDiff = downloadTraffic - previous.downloadTraffic;
|
||||||
|
|
||||||
|
if (uploadTraffic >= previous.uploadTraffic && uploadDiff >= 0) {
|
||||||
|
uploadSpeed = uploadDiff / timeDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadTraffic >= previous.downloadTraffic && downloadDiff >= 0) {
|
||||||
|
downloadSpeed = downloadDiff / timeDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuUsage: toFloat(raw.cpu_usage),
|
||||||
|
memoryUsage: toFloat(raw.memory_usage),
|
||||||
|
uploadTraffic,
|
||||||
|
downloadTraffic,
|
||||||
|
uploadSpeed,
|
||||||
|
downloadSpeed,
|
||||||
|
uptime,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseNodeOfflineTimersOptions {
|
||||||
|
delayMs?: number;
|
||||||
|
onNodeOffline: (nodeId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNodeOfflineTimers = ({
|
||||||
|
delayMs = 3000,
|
||||||
|
onNodeOffline,
|
||||||
|
}: UseNodeOfflineTimersOptions) => {
|
||||||
|
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearOfflineTimer = useCallback((nodeId: number) => {
|
||||||
|
const timer = timersRef.current.get(nodeId);
|
||||||
|
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
timersRef.current.delete(nodeId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllOfflineTimers = useCallback(() => {
|
||||||
|
timersRef.current.forEach((timer) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
timersRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleNodeOffline = useCallback(
|
||||||
|
(nodeId: number) => {
|
||||||
|
if (timersRef.current.has(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timersRef.current.delete(nodeId);
|
||||||
|
onNodeOffline(nodeId);
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
timersRef.current.set(nodeId, timer);
|
||||||
|
},
|
||||||
|
[delayMs, onNodeOffline],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearAllOfflineTimers();
|
||||||
|
};
|
||||||
|
}, [clearAllOfflineTimers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearOfflineTimer,
|
||||||
|
scheduleNodeOffline,
|
||||||
|
clearAllOfflineTimers,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { getToken } from "@/utils/session";
|
||||||
|
|
||||||
|
interface NodeRealtimeMessage {
|
||||||
|
id?: string | number;
|
||||||
|
type?: string;
|
||||||
|
data?: unknown;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNodeRealtimeOptions {
|
||||||
|
onMessage: (message: NodeRealtimeMessage) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRealtimeWsUrl = (): string => {
|
||||||
|
const baseUrl =
|
||||||
|
axios.defaults.baseURL ||
|
||||||
|
(import.meta.env.VITE_API_BASE
|
||||||
|
? `${import.meta.env.VITE_API_BASE}/api/v1/`
|
||||||
|
: "/api/v1/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
|
||||||
|
`/system-info?type=0&secret=${getToken() || ""}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNodeRealtime = ({
|
||||||
|
onMessage,
|
||||||
|
enabled = true,
|
||||||
|
}: UseNodeRealtimeOptions) => {
|
||||||
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
|
const [wsConnecting, setWsConnecting] = useState(false);
|
||||||
|
|
||||||
|
const websocketRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
const clearReconnectTimer = useCallback(() => {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
clearReconnectTimer();
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
|
||||||
|
if (!websocketRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websocketRef.current.onopen = null;
|
||||||
|
websocketRef.current.onmessage = null;
|
||||||
|
websocketRef.current.onerror = null;
|
||||||
|
websocketRef.current.onclose = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
websocketRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
websocketRef.current.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
websocketRef.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
websocketRef.current = null;
|
||||||
|
}, [clearReconnectTimer]);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
websocketRef.current &&
|
||||||
|
(websocketRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
websocketRef.current.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocketRef.current) {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setWsConnecting(true);
|
||||||
|
websocketRef.current = new WebSocket(getRealtimeWsUrl());
|
||||||
|
|
||||||
|
websocketRef.current.onopen = () => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
setWsConnected(true);
|
||||||
|
setWsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocketRef.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
onMessageRef.current(parsed as NodeRealtimeMessage);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
websocketRef.current.onerror = () => {};
|
||||||
|
|
||||||
|
websocketRef.current.onclose = () => {
|
||||||
|
websocketRef.current = null;
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
|
||||||
|
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttemptsRef.current += 1;
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
connect();
|
||||||
|
}, 3000 * reconnectAttemptsRef.current);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
setWsConnected(false);
|
||||||
|
setWsConnecting(false);
|
||||||
|
}
|
||||||
|
}, [disconnect, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [connect, disconnect, enabled]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wsConnected,
|
||||||
|
wsConnecting,
|
||||||
|
reconnectRealtime: connect,
|
||||||
|
disconnectRealtime: disconnect,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export interface DiagnosisEntry {
|
||||||
|
success: boolean;
|
||||||
|
description: string;
|
||||||
|
nodeName: string;
|
||||||
|
nodeId: string;
|
||||||
|
targetIp: string;
|
||||||
|
targetPort?: number;
|
||||||
|
message?: string;
|
||||||
|
averageTime?: number;
|
||||||
|
packetLoss?: number;
|
||||||
|
fromChainType?: number;
|
||||||
|
fromInx?: number;
|
||||||
|
toChainType?: number;
|
||||||
|
toInx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosisResult {
|
||||||
|
tunnelName: string;
|
||||||
|
tunnelType: string;
|
||||||
|
timestamp: number;
|
||||||
|
results: DiagnosisEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosisFallbackInput {
|
||||||
|
tunnelName: string;
|
||||||
|
tunnelType: number;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildDiagnosisFallbackResult = ({
|
||||||
|
tunnelName,
|
||||||
|
tunnelType,
|
||||||
|
description,
|
||||||
|
message,
|
||||||
|
}: DiagnosisFallbackInput): DiagnosisResult => {
|
||||||
|
return {
|
||||||
|
tunnelName,
|
||||||
|
tunnelType: tunnelType === 1 ? "端口转发" : "隧道转发",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
description,
|
||||||
|
nodeName: "-",
|
||||||
|
nodeId: "-",
|
||||||
|
targetIp: "-",
|
||||||
|
targetPort: 443,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDiagnosisQualityDisplay = (
|
||||||
|
averageTime?: number,
|
||||||
|
packetLoss?: number,
|
||||||
|
): {
|
||||||
|
text: string;
|
||||||
|
color: "success" | "primary" | "warning" | "danger";
|
||||||
|
} | null => {
|
||||||
|
if (averageTime === undefined || packetLoss === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 30 && packetLoss === 0) {
|
||||||
|
return { text: "🚀 优秀", color: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 50 && packetLoss === 0) {
|
||||||
|
return { text: "✨ 很好", color: "success" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 100 && packetLoss < 1) {
|
||||||
|
return { text: "👍 良好", color: "primary" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 150 && packetLoss < 2) {
|
||||||
|
return { text: "😐 一般", color: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (averageTime < 200 && packetLoss < 5) {
|
||||||
|
return { text: "😟 较差", color: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: "😵 很差", color: "danger" };
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
interface TunnelChainNode {
|
||||||
|
nodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelFormInput {
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
inNodeId: TunnelChainNode[];
|
||||||
|
outNodeId?: TunnelChainNode[];
|
||||||
|
trafficRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TunnelNodeInput {
|
||||||
|
id: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTunnelFormDefaults = () => {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
type: 1,
|
||||||
|
inNodeId: [],
|
||||||
|
outNodeId: [],
|
||||||
|
chainNodes: [],
|
||||||
|
flow: 1,
|
||||||
|
trafficRatio: 1.0,
|
||||||
|
inIp: "",
|
||||||
|
ipPreference: "",
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTunnelForm = (
|
||||||
|
form: TunnelFormInput,
|
||||||
|
nodes: TunnelNodeInput[],
|
||||||
|
): Record<string, string> => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
errors.name = "请输入隧道名称";
|
||||||
|
} else if (form.name.length < 2 || form.name.length > 50) {
|
||||||
|
errors.name = "隧道名称长度应在2-50个字符之间";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||||
|
errors.inNodeId = "请至少选择一个入口节点";
|
||||||
|
} else {
|
||||||
|
const offlineInNodes = form.inNodeId.filter((item) => {
|
||||||
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
|
return node && node.status !== 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offlineInNodes.length > 0) {
|
||||||
|
errors.inNodeId = "所有入口节点必须在线";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) {
|
||||||
|
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.type === 2) {
|
||||||
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||||
|
errors.outNodeId = "请至少选择一个出口节点";
|
||||||
|
} else {
|
||||||
|
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||||
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
|
return node && node.status !== 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offlineOutNodes.length > 0) {
|
||||||
|
errors.outNodeId = "所有出口节点必须在线";
|
||||||
|
}
|
||||||
|
|
||||||
|
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||||
|
const outNodeIds = form.outNodeId.map((item) => item.nodeId);
|
||||||
|
const overlap = inNodeIds.filter((id) => outNodeIds.includes(id));
|
||||||
|
|
||||||
|
if (overlap.length > 0) {
|
||||||
|
errors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTunnelTypeDisplay = (type: number) => {
|
||||||
|
switch (type) {
|
||||||
|
case 1:
|
||||||
|
return { text: "端口转发", color: "primary" };
|
||||||
|
case 2:
|
||||||
|
return { text: "隧道转发", color: "secondary" };
|
||||||
|
default:
|
||||||
|
return { text: "未知", color: "default" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTunnelFlowDisplay = (flow: number) => {
|
||||||
|
switch (flow) {
|
||||||
|
case 1:
|
||||||
|
return "单向计算";
|
||||||
|
case 2:
|
||||||
|
return "双向计算";
|
||||||
|
default:
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const parseOrderIds = (rawValue: string | null): number[] | null => {
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.map((id) => Number(id))
|
||||||
|
.filter((id) => Number.isInteger(id) && id >= 0);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadStoredOrder = (
|
||||||
|
storageKey: string,
|
||||||
|
sourceIds: number[],
|
||||||
|
): number[] => {
|
||||||
|
const parsed = parseOrderIds(localStorage.getItem(storageKey));
|
||||||
|
|
||||||
|
if (!parsed || parsed.length === 0) {
|
||||||
|
return sourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idSet = new Set(sourceIds);
|
||||||
|
const validIds: number[] = [];
|
||||||
|
|
||||||
|
parsed.forEach((id) => {
|
||||||
|
if (idSet.has(id) && !validIds.includes(id)) {
|
||||||
|
validIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validIds.length === 0) {
|
||||||
|
return sourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceIds.forEach((id) => {
|
||||||
|
if (!validIds.includes(id)) {
|
||||||
|
validIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return validIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveOrder = (storageKey: string, ids: number[]): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(ids));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export const SESSION_STORAGE_KEYS = {
|
||||||
|
token: "token",
|
||||||
|
roleId: "role_id",
|
||||||
|
name: "name",
|
||||||
|
admin: "admin",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
token: string | null;
|
||||||
|
roleId: number | null;
|
||||||
|
name: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginSessionPayload {
|
||||||
|
token: string;
|
||||||
|
role_id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_EVENT_NAME = "sessionUpdated";
|
||||||
|
|
||||||
|
const parseRoleId = (value: string | null): number | null => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleId = Number.parseInt(value, 10);
|
||||||
|
|
||||||
|
return Number.isNaN(roleId) ? null : roleId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getToken = (): string | null => {
|
||||||
|
return localStorage.getItem(SESSION_STORAGE_KEYS.token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRoleId = (): number | null => {
|
||||||
|
return parseRoleId(localStorage.getItem(SESSION_STORAGE_KEYS.roleId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionName = (): string | null => {
|
||||||
|
return localStorage.getItem(SESSION_STORAGE_KEYS.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAdminFlag = (): boolean => {
|
||||||
|
const adminValue = localStorage.getItem(SESSION_STORAGE_KEYS.admin);
|
||||||
|
|
||||||
|
if (adminValue !== null) {
|
||||||
|
return adminValue === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleId = getRoleId();
|
||||||
|
const isAdmin = roleId === 0;
|
||||||
|
|
||||||
|
if (roleId !== null) {
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEYS.admin, String(isAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAdmin;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readSession = (): SessionData => {
|
||||||
|
return {
|
||||||
|
token: getToken(),
|
||||||
|
roleId: getRoleId(),
|
||||||
|
name: getSessionName(),
|
||||||
|
isAdmin: getAdminFlag(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeLoginSession = (payload: LoginSessionPayload): void => {
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEYS.token, payload.token);
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEYS.roleId, String(payload.role_id));
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEYS.name, payload.name);
|
||||||
|
localStorage.setItem(
|
||||||
|
SESSION_STORAGE_KEYS.admin,
|
||||||
|
String(payload.role_id === 0),
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new Event(SESSION_EVENT_NAME));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearSession = (): void => {
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEYS.token);
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEYS.roleId);
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEYS.name);
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEYS.admin);
|
||||||
|
window.dispatchEvent(new Event(SESSION_EVENT_NAME));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SESSION_UPDATED_EVENT = SESSION_EVENT_NAME;
|
||||||
Reference in New Issue
Block a user