diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts
new file mode 100644
index 0000000..11d861f
--- /dev/null
+++ b/vite-frontend/src/api/error-message.ts
@@ -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;
+};
diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts
new file mode 100644
index 0000000..b4ae2e0
--- /dev/null
+++ b/vite-frontend/src/api/types.ts
@@ -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;
+}
diff --git a/vite-frontend/src/components/page-state.tsx b/vite-frontend/src/components/page-state.tsx
new file mode 100644
index 0000000..0d00dce
--- /dev/null
+++ b/vite-frontend/src/components/page-state.tsx
@@ -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 (
+
+ );
+};
+
+export const PageEmptyState = ({
+ message,
+ className = "h-48",
+}: BaseStateProps) => {
+ return (
+
+ {message}
+
+ );
+};
+
+export const PageErrorState = ({
+ message,
+ className = "h-48",
+}: BaseStateProps) => {
+ return (
+
+ {message}
+
+ );
+};
diff --git a/vite-frontend/src/hooks/useH5Mode.ts b/vite-frontend/src/hooks/useH5Mode.ts
new file mode 100644
index 0000000..72958ed
--- /dev/null
+++ b/vite-frontend/src/hooks/useH5Mode.ts
@@ -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;
+};
diff --git a/vite-frontend/src/hooks/useMobileBreakpoint.ts b/vite-frontend/src/hooks/useMobileBreakpoint.ts
new file mode 100644
index 0000000..eec34f4
--- /dev/null
+++ b/vite-frontend/src/hooks/useMobileBreakpoint.ts
@@ -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;
+};
diff --git a/vite-frontend/src/hooks/useScrollTopOnPathChange.ts b/vite-frontend/src/hooks/useScrollTopOnPathChange.ts
new file mode 100644
index 0000000..9600189
--- /dev/null
+++ b/vite-frontend/src/hooks/useScrollTopOnPathChange.ts
@@ -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]);
+};
diff --git a/vite-frontend/src/hooks/useWebViewMode.ts b/vite-frontend/src/hooks/useWebViewMode.ts
new file mode 100644
index 0000000..2d65fa5
--- /dev/null
+++ b/vite-frontend/src/hooks/useWebViewMode.ts
@@ -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;
+};
diff --git a/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx b/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx
new file mode 100644
index 0000000..fa45614
--- /dev/null
+++ b/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx
@@ -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 (
+
+
+
+
+
+
+ 公告
+
+
+ {announcement.content}
+
+
+
+
+
+ );
+};
diff --git a/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx
new file mode 100644
index 0000000..9916b69
--- /dev/null
+++ b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx
@@ -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 (
+
+
+
+
+
+ {statisticsFlowsCount === 0 ? (
+
+ ) : (
+
+
+
+
+
+ {
+ 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}
+ />
+ ;
+ 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 (
+
+
{`时间: ${label ?? ""}`}
+
{`流量: ${formatFlow(flowValue)}`}
+
+ );
+ }
+
+ return null;
+ }}
+ />
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/vite-frontend/src/pages/dashboard/components/metric-card.tsx b/vite-frontend/src/pages/dashboard/components/metric-card.tsx
new file mode 100644
index 0000000..942ffb6
--- /dev/null
+++ b/vite-frontend/src/pages/dashboard/components/metric-card.tsx
@@ -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 (
+
+
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+ {value}
+
+ {bottomContent}
+
+
+
+ );
+};
diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts
new file mode 100644
index 0000000..e58e427
--- /dev/null
+++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts
@@ -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(
+ {} as DashboardUserInfo,
+ );
+ const [userTunnels, setUserTunnels] = useState([]);
+ const [forwardList, setForwardList] = useState([]);
+ const [statisticsFlows, setStatisticsFlows] = useState([]);
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [announcement, setAnnouncement] = useState(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,
+ };
+};
diff --git a/vite-frontend/src/pages/forward/address.ts b/vite-frontend/src/pages/forward/address.ts
new file mode 100644
index 0000000..b557824
--- /dev/null
+++ b/vite-frontend/src/pages/forward/address.ts
@@ -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,
+ })),
+ };
+};
diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts
new file mode 100644
index 0000000..66af192
--- /dev/null
+++ b/vite-frontend/src/pages/forward/batch-actions.ts
@@ -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;
+
+ return {
+ successCount: Number(raw.successCount ?? 0),
+ failCount: Number(raw.failCount ?? 0),
+ };
+};
+
+const buildBatchToast = (
+ result: BatchOperationResult,
+ successText: string,
+): Pick => {
+ if (result.failCount === 0) {
+ return {
+ toastVariant: "success",
+ toastMessage: successText,
+ };
+ }
+
+ return {
+ toastVariant: "error",
+ toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
+ };
+};
+
+export const executeForwardBatchDelete = async (
+ ids: number[],
+): Promise => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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,
+ };
+ }
+};
diff --git a/vite-frontend/src/pages/forward/diagnosis.ts b/vite-frontend/src/pages/forward/diagnosis.ts
new file mode 100644
index 0000000..7ebe9ad
--- /dev/null
+++ b/vite-frontend/src/pages/forward/diagnosis.ts
@@ -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" };
+};
diff --git a/vite-frontend/src/pages/forward/order.ts b/vite-frontend/src/pages/forward/order.ts
new file mode 100644
index 0000000..624460b
--- /dev/null
+++ b/vite-frontend/src/pages/forward/order.ts
@@ -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 = (
+ forwards: T[],
+ currentUserId: number | null,
+): T[] => {
+ if (currentUserId === null) {
+ return forwards;
+ }
+
+ return forwards.filter((item) => item.userId === currentUserId);
+};
+
+export const buildForwardOrder = (
+ 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,
+ };
+};
diff --git a/vite-frontend/src/pages/node/display.ts b/vite-frontend/src/pages/node/display.ts
new file mode 100644
index 0000000..e91a856
--- /dev/null
+++ b/vite-frontend/src/pages/node/display.ts
@@ -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}`;
+};
diff --git a/vite-frontend/src/pages/node/install-command.ts b/vite-frontend/src/pages/node/install-command.ts
new file mode 100644
index 0000000..9a4e7c6
--- /dev/null
+++ b/vite-frontend/src/pages/node/install-command.ts
@@ -0,0 +1,11 @@
+export const tryCopyInstallCommand = async (
+ command: string,
+): Promise => {
+ try {
+ await navigator.clipboard.writeText(command);
+
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/vite-frontend/src/pages/node/system-info.ts b/vite-frontend/src/pages/node/system-info.ts
new file mode 100644
index 0000000..4b5005f
--- /dev/null
+++ b/vite-frontend/src/pages/node/system-info.ts
@@ -0,0 +1,86 @@
+export interface NodeSystemInfo {
+ cpuUsage: number;
+ memoryUsage: number;
+ uploadTraffic: number;
+ downloadTraffic: number;
+ uploadSpeed: number;
+ downloadSpeed: number;
+ uptime: number;
+}
+
+type RawSystemInfo = Record;
+
+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,
+ };
+};
diff --git a/vite-frontend/src/pages/node/use-node-offline-timers.ts b/vite-frontend/src/pages/node/use-node-offline-timers.ts
new file mode 100644
index 0000000..f1aa54e
--- /dev/null
+++ b/vite-frontend/src/pages/node/use-node-offline-timers.ts
@@ -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