diff --git a/vite-frontend/package.json b/vite-frontend/package.json index aea3441..e695e36 100644 --- a/vite-frontend/package.json +++ b/vite-frontend/package.json @@ -13,45 +13,31 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@heroui/accordion": "^2.2.21", - "@heroui/alert": "^2.2.24", - "@heroui/autocomplete": "^2.3.25", - "@heroui/avatar": "^2.2.19", - "@heroui/badge": "^2.2.15", - "@heroui/button": "2.2.23", - "@heroui/card": "^2.2.22", - "@heroui/chip": "^2.2.19", - "@heroui/code": "2.2.17", - "@heroui/date-picker": "^2.3.22", - "@heroui/dropdown": "2.3.23", - "@heroui/image": "^2.2.15", - "@heroui/input": "2.4.24", - "@heroui/kbd": "2.2.18", - "@heroui/link": "2.2.20", - "@heroui/modal": "^2.2.21", - "@heroui/navbar": "2.2.21", - "@heroui/pagination": "^2.2.21", - "@heroui/progress": "^2.2.20", - "@heroui/radio": "^2.3.24", - "@heroui/select": "^2.4.22", - "@heroui/snippet": "2.2.24", - "@heroui/spinner": "^2.2.20", - "@heroui/switch": "^2.2.21", - "@heroui/system": "2.4.19", - "@heroui/table": "^2.2.24", - "@heroui/tabs": "^2.2.27", - "@heroui/theme": "2.4.24", - "@heroui/use-theme": "2.1.10", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0", - "@nextui-org/system": "^2.4.6", + "@internationalized/date": "^3.10.0", + "@react-aria/i18n": "^3.12.12", "@react-aria/visually-hidden": "3.8.25", "@react-types/shared": "3.30.0", "@tailwindcss/postcss": "4.1.11", "@tailwindcss/vite": "^4.1.18", "@types/react-beautiful-dnd": "^13.1.8", "axios": "^1.11.0", + "class-variance-authority": "^0.7.1", "clsx": "2.1.1", "framer-motion": "11.18.2", + "lucide-react": "^0.542.0", "react": "18.3.1", "react-beautiful-dnd": "^13.1.1", "react-dom": "18.3.1", @@ -60,8 +46,10 @@ "react-router-dom": "6.30.3", "recharts": "^3.1.1", "sonner": "^2.0.6", + "tailwind-merge": "^2.5.5", "tailwind-variants": "1.0.0", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.11", + "tw-animate-css": "^1.3.0" }, "devDependencies": { "@eslint/compat": "1.2.8", diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index d7c1528..a882f13 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -1,5 +1,5 @@ import { Route, Routes, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import IndexPage from "@/pages/index"; import ChangePasswordPage from "@/pages/change-password"; @@ -19,50 +19,7 @@ import H5Layout from "@/layouts/h5"; import H5SimpleLayout from "@/layouts/h5-simple"; import { isLoggedIn } from "@/utils/auth"; import { siteConfig } from "@/config/site"; - -// 检测是否为H5模式 -const useH5Mode = () => { - // 立即检测H5模式,避免初始渲染时的闪屏 - const getInitialH5Mode = () => { - // 检测移动设备或小屏幕 - const isMobile = window.innerWidth <= 768; - // 检测是否为移动端浏览器 - const isMobileBrowser = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - // 检测URL参数是否包含h5模式 - const urlParams = new URLSearchParams(window.location.search); - const isH5Param = urlParams.get("h5") === "true"; - - return isMobile || isMobileBrowser || isH5Param; - }; - - const [isH5, setIsH5] = useState(getInitialH5Mode); - - useEffect(() => { - const checkH5Mode = () => { - // 检测移动设备或小屏幕 - const isMobile = window.innerWidth <= 768; - // 检测是否为移动端浏览器 - const isMobileBrowser = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); - // 检测URL参数是否包含h5模式 - const urlParams = new URLSearchParams(window.location.search); - const isH5Param = urlParams.get("h5") === "true"; - - setIsH5(isMobile || isMobileBrowser || isH5Param); - }; - - window.addEventListener("resize", checkH5Mode); - - return () => window.removeEventListener("resize", checkH5Mode); - }, []); - - return isH5; -}; +import { useH5Mode } from "@/hooks/useH5Mode"; // 简化的路由保护组件 - 使用 React Router 导航避免循环 const ProtectedRoute = ({ @@ -99,15 +56,8 @@ const ProtectedRoute = ({ } // 根据模式和页面类型选择布局 - let Layout; - - if (isH5 && useSimpleLayout) { - Layout = H5SimpleLayout; - } else if (isH5) { - Layout = H5Layout; - } else { - Layout = AdminLayout; - } + const Layout = + isH5 && useSimpleLayout ? H5SimpleLayout : isH5 ? H5Layout : AdminLayout; return {children}; }; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 5cdba72..bea1164 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -1,3 +1,32 @@ +import type { + BatchOperationResult, + ForwardDiagnosisApiData, + ForwardApiItem, + GroupPermissionApiItem, + NodeReleaseApiItem, + NodeApiItem, + SpeedLimitApiItem, + TunnelDiagnosisApiData, + TunnelGroupApiItem, + UserApiItem, + UserGroupApiItem, + UserListQuery, + UserPackageInfoApiData, + UserTunnelPermissionApiItem, + TunnelApiItem, + UserTunnelApiItem, + UserMutationPayload, + NodeMutationPayload, + TunnelMutationPayload, + UserTunnelAssignPayload, + UserTunnelListQuery, + UserTunnelRemovePayload, + ForwardMutationPayload, + SpeedLimitMutationPayload, + UpdatePasswordPayload, + BackupImportPayload, +} from "./types"; + import axios from "axios"; import Network from "./network"; @@ -20,20 +49,25 @@ export const login = (data: LoginData) => Network.post("/user/login", data); // 用户CRUD操作 - 全部使用POST请求 -export const createUser = (data: any) => Network.post("/user/create", data); -export const getAllUsers = (pageData: any = {}) => - Network.post("/user/list", pageData); -export const updateUser = (data: any) => Network.post("/user/update", data); +export const createUser = (data: UserMutationPayload) => + Network.post("/user/create", data); +export const getAllUsers = (pageData: UserListQuery = {}) => + Network.post("/user/list", pageData); +export const updateUser = (data: UserMutationPayload) => + Network.post("/user/update", data); export const deleteUser = (id: number) => Network.post("/user/delete", { id }); -export const getUserPackageInfo = () => Network.post("/user/package"); +export const getUserPackageInfo = () => + Network.post("/user/package"); // 节点CRUD操作 - 全部使用POST请求 -export const createNode = (data: any) => Network.post("/node/create", data); -export const getNodeList = () => Network.post("/node/list"); -export const updateNode = (data: any) => Network.post("/node/update", data); +export const createNode = (data: NodeMutationPayload) => + Network.post("/node/create", data); +export const getNodeList = () => Network.post("/node/list"); +export const updateNode = (data: NodeMutationPayload) => + Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); export const getNodeInstallCommand = (id: number) => - Network.post("/node/install", { id }); + Network.post("/node/install", { id }); export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }>; }) => Network.post("/node/update-order", data); @@ -55,44 +89,50 @@ export const batchUpgradeNodes = (ids: number[], version?: string) => { ids, version: version || "" }, { timeout: 15 * 60 * 1000 }, ); -export const getNodeReleases = () => Network.post("/node/releases"); +export const getNodeReleases = () => + Network.post("/node/releases"); export const rollbackNode = (id: number) => Network.post("/node/rollback", { id }); // 隧道CRUD操作 - 全部使用POST请求 -export const createTunnel = (data: any) => Network.post("/tunnel/create", data); -export const getTunnelList = () => Network.post("/tunnel/list"); +export const createTunnel = (data: TunnelMutationPayload) => + Network.post("/tunnel/create", data); +export const getTunnelList = () => + Network.post("/tunnel/list"); export const getTunnelById = (id: number) => - Network.post("/tunnel/get", { id }); -export const updateTunnel = (data: any) => Network.post("/tunnel/update", data); + Network.post("/tunnel/get", { id }); +export const updateTunnel = (data: TunnelMutationPayload) => + Network.post("/tunnel/update", data); export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); export const diagnoseTunnel = (tunnelId: number) => - Network.post("/tunnel/diagnose", { tunnelId }); + Network.post("/tunnel/diagnose", { tunnelId }); export const updateTunnelOrder = (data: { tunnels: Array<{ id: number; inx: number }>; }) => Network.post("/tunnel/update-order", data); // 用户隧道权限管理操作 - 全部使用POST请求 -export const assignUserTunnel = (data: any) => +export const assignUserTunnel = (data: UserTunnelAssignPayload) => Network.post("/tunnel/user/assign", data); export const batchAssignUserTunnel = (data: { userId: number; tunnels: Array<{ tunnelId: number; speedId?: number | null }>; }) => Network.post("/tunnel/user/batch-assign", data); -export const getUserTunnelList = (queryData: any = {}) => - Network.post("/tunnel/user/list", queryData); -export const removeUserTunnel = (params: any) => +export const getUserTunnelList = (queryData: UserTunnelListQuery = {}) => + Network.post("/tunnel/user/list", queryData); +export const removeUserTunnel = (params: UserTunnelRemovePayload) => Network.post("/tunnel/user/remove", params); -export const updateUserTunnel = (data: any) => +export const updateUserTunnel = (data: UserTunnelAssignPayload) => Network.post("/tunnel/user/update", data); -export const userTunnel = () => Network.post("/tunnel/user/tunnel"); +export const userTunnel = () => + Network.post("/tunnel/user/tunnel"); // 转发CRUD操作 - 全部使用POST请求 -export const createForward = (data: any) => +export const createForward = (data: ForwardMutationPayload) => Network.post("/forward/create", data); -export const getForwardList = () => Network.post("/forward/list"); -export const updateForward = (data: any) => +export const getForwardList = () => + Network.post("/forward/list"); +export const updateForward = (data: ForwardMutationPayload) => Network.post("/forward/update", data); export const deleteForward = (id: number) => Network.post("/forward/delete", { id }); @@ -107,7 +147,7 @@ export const resumeForwardService = (forwardId: number) => // 转发诊断操作 export const diagnoseForward = (forwardId: number) => - Network.post("/forward/diagnose", { forwardId }); + Network.post("/forward/diagnose", { forwardId }); // 转发排序操作 export const updateForwardOrder = (data: { @@ -115,16 +155,17 @@ export const updateForwardOrder = (data: { }) => Network.post("/forward/update-order", data); // 限速规则CRUD操作 - 全部使用POST请求 -export const createSpeedLimit = (data: any) => +export const createSpeedLimit = (data: SpeedLimitMutationPayload) => Network.post("/speed-limit/create", data); -export const getSpeedLimitList = () => Network.post("/speed-limit/list"); -export const updateSpeedLimit = (data: any) => +export const getSpeedLimitList = () => + Network.post("/speed-limit/list"); +export const updateSpeedLimit = (data: SpeedLimitMutationPayload) => Network.post("/speed-limit/update", data); export const deleteSpeedLimit = (id: number) => Network.post("/speed-limit/delete", { id }); // 修改密码接口 -export const updatePassword = (data: any) => +export const updatePassword = (data: UpdatePasswordPayload) => Network.post("/user/updatePassword", data); // 重置流量接口 @@ -135,18 +176,19 @@ export const getUserGroups = (id: number) => Network.post("/user/groups", { id }); // 网站配置相关接口 -export const getConfigs = () => Network.post("/config/list"); +export const getConfigs = () => + Network.post>("/config/list"); export const getConfigByName = (name: string) => - Network.post("/config/get", { name }); + Network.post<{ name: string; value: string }>("/config/get", { name }); export const updateConfigs = (configMap: Record) => Network.post("/config/update", configMap); export const updateConfig = (name: string, value: string) => Network.post("/config/update-single", { name, value }); export const exportBackupData = () => Network.post("/backup/export"); -export const importBackupData = (data: any) => +export const importBackupData = (data: BackupImportPayload) => Network.post("/backup/import", data); -export const restoreBackupData = (data: any) => +export const restoreBackupData = (data: BackupImportPayload) => Network.post("/backup/restore", data); // 验证码相关接口 @@ -157,26 +199,27 @@ export const verifyCaptcha = (data: { captchaId: string; trackData: string }) => // 批量操作接口 export const batchDeleteForwards = (ids: number[]) => - Network.post("/forward/batch-delete", { ids }); + Network.post("/forward/batch-delete", { ids }); export const batchPauseForwards = (ids: number[]) => - Network.post("/forward/batch-pause", { ids }); + Network.post("/forward/batch-pause", { ids }); export const batchResumeForwards = (ids: number[]) => - Network.post("/forward/batch-resume", { ids }); + Network.post("/forward/batch-resume", { ids }); export const batchDeleteTunnels = (ids: number[]) => - Network.post("/tunnel/batch-delete", { ids }); + Network.post("/tunnel/batch-delete", { ids }); export const batchDeleteNodes = (ids: number[]) => - Network.post("/node/batch-delete", { ids }); + Network.post("/node/batch-delete", { ids }); export const batchRedeployForwards = (ids: number[]) => - Network.post("/forward/batch-redeploy", { ids }); + Network.post("/forward/batch-redeploy", { ids }); export const batchRedeployTunnels = (ids: number[]) => - Network.post("/tunnel/batch-redeploy", { ids }); + Network.post("/tunnel/batch-redeploy", { ids }); export const batchChangeTunnel = (data: { forwardIds: number[]; targetTunnelId: number; -}) => Network.post("/forward/batch-change-tunnel", data); +}) => Network.post("/forward/batch-change-tunnel", data); // 分组与权限分配接口 -export const getTunnelGroupList = () => Network.post("/group/tunnel/list"); +export const getTunnelGroupList = () => + Network.post("/group/tunnel/list"); export const createTunnelGroup = (data: { name: string; status?: number }) => Network.post("/group/tunnel/create", data); export const updateTunnelGroup = (data: { @@ -191,7 +234,8 @@ export const assignTunnelsToGroup = (data: { tunnelIds: number[]; }) => Network.post("/group/tunnel/assign", data); -export const getUserGroupList = () => Network.post("/group/user/list"); +export const getUserGroupList = () => + Network.post("/group/user/list"); export const createUserGroup = (data: { name: string; status?: number }) => Network.post("/group/user/create", data); export const updateUserGroup = (data: { @@ -207,7 +251,7 @@ export const assignUsersToGroup = (data: { }) => Network.post("/group/user/assign", data); export const getGroupPermissionList = () => - Network.post("/group/permission/list"); + Network.post("/group/permission/list"); export const assignGroupPermission = (data: { userGroupId: number; tunnelGroupId: number; @@ -216,7 +260,8 @@ export const removeGroupPermission = (id: number) => Network.post("/group/permission/remove", { id }); // 面板共享 (Federation) 接口 -export const getPeerShareList = () => Network.post("/federation/share/list"); +export const getPeerShareList = () => + Network.post>>("/federation/share/list"); export const createPeerShare = (data: { name: string; nodeId: number; @@ -242,7 +287,9 @@ export const deletePeerShare = (id: number) => export const resetPeerShareFlow = (id: number) => Network.post("/federation/share/reset-flow", { id }); export const getPeerRemoteUsageList = () => - Network.post("/federation/share/remote-usage/list"); + Network.post>>( + "/federation/share/remote-usage/list", + ); export const importRemoteNode = (data: { remoteUrl: string; token: string }) => Network.post("/federation/node/import", data); @@ -288,7 +335,7 @@ export const exportBackup = async (types: string[] = []) => { window.URL.revokeObjectURL(url); }; -export const importBackup = (data: { types: string[]; [key: string]: any }) => +export const importBackup = (data: BackupImportPayload) => Network.post("/backup/import", data); export interface AnnouncementData { diff --git a/vite-frontend/src/api/network.ts b/vite-frontend/src/api/network.ts index d7af375..0c00dc5 100644 --- a/vite-frontend/src/api/network.ts +++ b/vite-frontend/src/api/network.ts @@ -1,6 +1,11 @@ import axios, { AxiosResponse } from "axios"; +import { + extractApiErrorMessage, + isUnauthorizedError, +} from "@/api/error-message"; import { getPanelAddresses, isWebViewFunc } from "@/utils/panel"; +import { clearSession, getToken } from "@/utils/session"; interface PanelAddress { name: string; @@ -37,7 +42,7 @@ export const reinitializeBaseURL = () => { reinitializeBaseURL(); -interface ApiResponse { +interface ApiResponse { code: number; msg: string; data: T; @@ -49,10 +54,7 @@ interface RequestOptions { // 处理token失效的逻辑 function handleTokenExpired() { - // 清除localStorage中的token - window.localStorage.removeItem("token"); - window.localStorage.removeItem("role_id"); - window.localStorage.removeItem("name"); + clearSession(); // 跳转到登录页面 if (window.location.pathname !== "/") { @@ -61,7 +63,7 @@ function handleTokenExpired() { } // 检查响应是否为token失效 -function isTokenExpired(response: ApiResponse) { +function isTokenExpired(response: ApiResponse) { return ( response && response.code === 401 && @@ -72,9 +74,9 @@ function isTokenExpired(response: ApiResponse) { } const Network = { - get: function ( + get: function ( path: string = "", - data: any = {}, + data: unknown = {}, options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { @@ -90,38 +92,45 @@ const Network = { params: data, timeout: options.timeout ?? 30000, headers: { - Authorization: window.localStorage.getItem("token"), + Authorization: getToken(), }, }) .then(function (response: AxiosResponse>) { // 检查是否token失效 if (isTokenExpired(response.data)) { handleTokenExpired(); - - return; } + resolve(response.data); }) - .catch(function (error: any) { + .catch(function (error: unknown) { + const errorMessage = extractApiErrorMessage(error); + // 检查是否是401错误(token失效) - if (error.response && error.response.status === 401) { + if (isUnauthorizedError(error)) { handleTokenExpired(); + resolve({ + code: 401, + msg: "未登录或token已过期", + data: null as T, + }); + return; } resolve({ code: -1, - msg: error.message || "网络请求失败", + msg: errorMessage, data: null as T, }); }); }); }, - post: function ( + post: function ( path: string = "", - data: any = {}, + data: unknown = {}, options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { @@ -136,7 +145,7 @@ const Network = { .post(path, data, { timeout: options.timeout ?? 30000, headers: { - Authorization: window.localStorage.getItem("token"), + Authorization: getToken(), "Content-Type": "application/json", }, }) @@ -144,22 +153,29 @@ const Network = { // 检查是否token失效 if (isTokenExpired(response.data)) { handleTokenExpired(); - - return; } + resolve(response.data); }) - .catch(function (error: any) { + .catch(function (error: unknown) { + const errorMessage = extractApiErrorMessage(error); + // 检查是否是401错误(token失效) - if (error.response && error.response.status === 401) { + if (isUnauthorizedError(error)) { handleTokenExpired(); + resolve({ + code: 401, + msg: "未登录或token已过期", + data: null as T, + }); + return; } resolve({ code: -1, - msg: error.message || "网络请求失败", + msg: errorMessage, data: null as T, }); }); diff --git a/vite-frontend/src/components/navbar.tsx b/vite-frontend/src/components/navbar.tsx index b7b0c02..78d7c7a 100644 --- a/vite-frontend/src/components/navbar.tsx +++ b/vite-frontend/src/components/navbar.tsx @@ -1,26 +1,21 @@ -import { useState, useEffect } from "react"; -import { Link } from "@heroui/link"; +import { useEffect, useState } from "react"; +import { Link } from "@/shadcn-bridge/heroui/link"; import { Navbar as HeroUINavbar, NavbarBrand, NavbarContent, -} from "@heroui/navbar"; +} from "@/shadcn-bridge/heroui/navbar"; import { useNavigate } from "react-router-dom"; -import { isWebViewFunc } from "@/utils/panel"; import { Logo } from "@/components/icons"; import { siteConfig, getCachedConfig } from "@/config/site"; +import { useWebViewMode } from "@/hooks/useWebViewMode"; export const Navbar = () => { const navigate = useNavigate(); // 初始状态使用siteConfig中已经从缓存读取的值,避免闪烁 const [appName, setAppName] = useState(siteConfig.name); - const [isWebView, setIsWebView] = useState(false); - - // 检测是否在WebView中运行 - useEffect(() => { - setIsWebView(isWebViewFunc()); - }, []); + const isWebView = useWebViewMode(); useEffect(() => { // 异步检查是否有更新的配置 diff --git a/vite-frontend/src/components/theme-provider.tsx b/vite-frontend/src/components/theme-provider.tsx index 66cdecf..72c60c6 100644 --- a/vite-frontend/src/components/theme-provider.tsx +++ b/vite-frontend/src/components/theme-provider.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { useTheme } from "@heroui/use-theme"; +import { useTheme } from "@/shadcn-bridge/heroui/use-theme"; interface ThemeProviderProps { children: React.ReactNode; diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 5147725..508b083 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -import { Button } from "@heroui/button"; +import { Button } from "@/shadcn-bridge/heroui/button"; import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, -} from "@heroui/dropdown"; +} from "@/shadcn-bridge/heroui/dropdown"; import { Modal, ModalContent, @@ -14,14 +14,16 @@ import { ModalBody, ModalFooter, useDisclosure, -} from "@heroui/modal"; -import { Input } from "@heroui/input"; +} from "@/shadcn-bridge/heroui/modal"; +import { Input } from "@/shadcn-bridge/heroui/input"; import { toast } from "react-hot-toast"; import { Logo } from "@/components/icons"; import { updatePassword } from "@/api"; import { safeLogout } from "@/utils/logout"; import { siteConfig } from "@/config/site"; +import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; +import { getAdminFlag, getSessionName } from "@/utils/session"; interface MenuItem { path: string; @@ -46,7 +48,6 @@ export default function AdminLayout({ const location = useLocation(); const { isOpen, onOpen, onOpenChange } = useDisclosure(); - const [isMobile, setIsMobile] = useState(false); const [mobileMenuVisible, setMobileMenuVisible] = useState(false); const [username, setUsername] = useState(""); const [isAdmin, setIsAdmin] = useState(false); @@ -57,6 +58,7 @@ export default function AdminLayout({ newPassword: "", confirmPassword: "", }); + const isMobile = useMobileBreakpoint(); // 菜单项配置 const menuItems: MenuItem[] = [ @@ -170,41 +172,21 @@ export default function AdminLayout({ }, ]; - // 检查移动端 - const checkMobile = () => { - setIsMobile(window.innerWidth <= 768); - if (window.innerWidth > 768) { - setMobileMenuVisible(false); - } - }; - useEffect(() => { // 获取用户信息 - const name = localStorage.getItem("name") || "Admin"; - - // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) - let adminFlag = localStorage.getItem("admin") === "true"; - - if (localStorage.getItem("admin") === null) { - const roleId = parseInt(localStorage.getItem("role_id") || "1", 10); - - adminFlag = roleId === 0; - // 补充设置admin字段,避免下次再次判断 - localStorage.setItem("admin", adminFlag.toString()); - } + const name = getSessionName() || "Admin"; + const adminFlag = getAdminFlag(); setUsername(name); setIsAdmin(adminFlag); - - // 响应式检查 - checkMobile(); - window.addEventListener("resize", checkMobile); - - return () => { - window.removeEventListener("resize", checkMobile); - }; }, []); + useEffect(() => { + if (!isMobile) { + setMobileMenuVisible(false); + } + }, [isMobile]); + // 退出登录 const handleLogout = () => { safeLogout(); diff --git a/vite-frontend/src/layouts/h5-simple.tsx b/vite-frontend/src/layouts/h5-simple.tsx index 7f77735..9943ade 100644 --- a/vite-frontend/src/layouts/h5-simple.tsx +++ b/vite-frontend/src/layouts/h5-simple.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { useNavigate, useLocation } from "react-router-dom"; -import { Button } from "@heroui/button"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/shadcn-bridge/heroui/button"; import { Logo } from "@/components/icons"; import { siteConfig } from "@/config/site"; +import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange"; export default function H5SimpleLayout({ children, @@ -11,18 +12,8 @@ export default function H5SimpleLayout({ children: React.ReactNode; }) { const navigate = useNavigate(); - const location = useLocation(); - // 路由切换时回到顶部,避免上一页滚动位置保留 - React.useEffect(() => { - try { - window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - } catch { - window.scrollTo(0, 0); - } - document.body.scrollTop = 0; - document.documentElement.scrollTop = 0; - }, [location.pathname]); + useScrollTopOnPathChange(); const handleBack = () => { navigate("/profile"); diff --git a/vite-frontend/src/layouts/h5.tsx b/vite-frontend/src/layouts/h5.tsx index db3f202..468d5e3 100644 --- a/vite-frontend/src/layouts/h5.tsx +++ b/vite-frontend/src/layouts/h5.tsx @@ -3,6 +3,8 @@ import { useNavigate, useLocation } from "react-router-dom"; import { Logo } from "@/components/icons"; import { siteConfig } from "@/config/site"; +import { getAdminFlag } from "@/utils/session"; +import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange"; interface TabItem { path: string; @@ -16,6 +18,8 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); const [isAdmin, setIsAdmin] = useState(false); + useScrollTopOnPathChange(); + // Tabbar配置 const tabItems: TabItem[] = [ { @@ -80,18 +84,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { ]; useEffect(() => { - // 兼容处理:如果没有admin字段,根据role_id判断(0为管理员) - let adminFlag = localStorage.getItem("admin") === "true"; - - if (localStorage.getItem("admin") === null) { - const roleId = parseInt(localStorage.getItem("role_id") || "1", 10); - - adminFlag = roleId === 0; - // 补充设置admin字段,避免下次再次判断 - localStorage.setItem("admin", adminFlag.toString()); - } - - setIsAdmin(adminFlag); + setIsAdmin(getAdminFlag()); }, []); // Tab点击处理 @@ -104,17 +97,6 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { (item) => !item.adminOnly || isAdmin, ); - // 路由切换时回到页面顶部,避免上一页的滚动位置遗留 - useEffect(() => { - try { - window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - } catch { - window.scrollTo(0, 0); - } - document.body.scrollTop = 0; - document.documentElement.scrollTop = 0; - }, [location.pathname]); - return (
{/* 顶部导航栏 */} diff --git a/vite-frontend/src/pages/change-password.tsx b/vite-frontend/src/pages/change-password.tsx index 99d09cb..907ec1e 100644 --- a/vite-frontend/src/pages/change-password.tsx +++ b/vite-frontend/src/pages/change-password.tsx @@ -1,6 +1,6 @@ -import { Button } from "@heroui/button"; -import { Input } from "@heroui/input"; -import { Card, CardBody, CardHeader } from "@heroui/card"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Input } from "@/shadcn-bridge/heroui/input"; +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; @@ -115,6 +115,7 @@ export default function ChangePasswordPage() {
-
-
-
- 正在加载数据... -
-
+
); } return (
- {announcement && announcement.content && ( - - -
-
- - - -
-
-

- 公告 -

-

- {announcement.content} -

-
-
-
-
- )} + {announcement && }
- - -
-
-

- 总流量 -

-
- - - -
-
-

- {formatFlow(userInfo.flow, "gb")} -

-
-
-
- - - -
-
-

- 已用流量 -

-
- - - -
-
-

- {formatFlow(calculateUserTotalUsedFlow())} -

-
- {renderProgressBar( - calculateUsagePercentage("flow"), - "sm", - userInfo.flow === 99999, - )} -
-

- {userInfo.flow === 99999 - ? "无限制" - : `${calculateUsagePercentage("flow").toFixed(1)}%`} -

- {userInfo.flowResetTime !== undefined && - userInfo.flowResetTime !== null && ( -
- - - - - {formatResetTime(userInfo.flowResetTime)} - -
- )} -
-
-
-
-
- - - -
-
-

- 转发配额 -

-
- - - -
-
-

- {formatNumber(userInfo.num || 0)} -

-
-
-
- - - -
-
-

- 已用转发 -

-
- - - -
-
-

- {forwardList.length} -

-
- {renderProgressBar( - calculateUsagePercentage("forwards"), - "sm", - userInfo.num === 99999, - )} -

- {userInfo.num === 99999 - ? "无限制" - : `${calculateUsagePercentage("forwards").toFixed(1)}%`} -

-
-
-
-
-
- - {/* 24小时流量统计图表 */} - - -
+
-
- - {statisticsFlows.length === 0 ? ( -
- - - -

暂无流量统计数据

-
- ) : ( -
- {/* 流量趋势图 */} -
- - - - - { - const v = - typeof value === "number" ? value : Number(value); + } + iconClassName="bg-blue-100 dark:bg-blue-500/20" + title="总流量" + value={formatFlow(userInfo.flow, "gb")} + /> - 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; - }} - /> - -
-
+ + {renderProgressBar( + calculateUsagePercentage("flow"), + "sm", + userInfo.flow === 99999, + )} +
+

+ {userInfo.flow === 99999 + ? "无限制" + : `${calculateUsagePercentage("flow").toFixed(1)}%`} +

+ {userInfo.flowResetTime !== undefined && + userInfo.flowResetTime !== null && ( +
+ + + {formatResetTime(userInfo.flowResetTime)} + +
+ )}
- )} - - + } + icon={ + + } + iconClassName="bg-green-100 dark:bg-green-500/20" + title="已用流量" + value={formatFlow(calculateUserTotalUsedFlow())} + /> + +
+ } + icon={ + + } + iconClassName="bg-orange-100 dark:bg-orange-500/20" + title="已用转发" + value={forwardList.length} + /> +
+ + {/* 隧道权限 - 管理员不显示 */} {!isAdmin && ( @@ -1034,6 +661,7 @@ export default function DashboardPage() {
- - - -

暂无隧道权限

-
+ ) : (
{userTunnels.map((tunnel) => { @@ -1170,6 +783,7 @@ export default function DashboardPage() {
-
@@ -1348,7 +963,7 @@ export default function DashboardPage() { isLoading={item.copying} size="sm" variant="light" - onClick={() => copyAddress(item)} + onPress={() => copyAddress(item)} > 复制 diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 4f3f846..12aa1e9 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1,22 +1,22 @@ import { useState, useEffect, useMemo } from "react"; -import { Card, CardBody, CardHeader } from "@heroui/card"; -import { Button } from "@heroui/button"; -import { Input } from "@heroui/input"; -import { Textarea } from "@heroui/input"; -import { Select, SelectItem } from "@heroui/select"; +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Input } from "@/shadcn-bridge/heroui/input"; +import { Textarea } from "@/shadcn-bridge/heroui/input"; +import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, -} from "@heroui/modal"; -import { Chip } from "@heroui/chip"; -import { Spinner } from "@heroui/spinner"; -import { Switch } from "@heroui/switch"; -import { Alert } from "@heroui/alert"; -import { Accordion, AccordionItem } from "@heroui/accordion"; -import { Checkbox } from "@heroui/checkbox"; +} from "@/shadcn-bridge/heroui/modal"; +import { Chip } from "@/shadcn-bridge/heroui/chip"; +import { Spinner } from "@/shadcn-bridge/heroui/spinner"; +import { Switch } from "@/shadcn-bridge/heroui/switch"; +import { Alert } from "@/shadcn-bridge/heroui/alert"; +import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion"; +import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import toast from "react-hot-toast"; import { DndContext, @@ -48,12 +48,29 @@ import { resumeForwardService, diagnoseForward, updateForwardOrder, - batchDeleteForwards, - batchPauseForwards, - batchResumeForwards, - batchRedeployForwards, - batchChangeTunnel, } from "@/api"; +import { + type ForwardAddressItem, + formatInAddress, + formatRemoteAddress, + hasMultipleAddresses, + resolveForwardAddressAction, +} from "@/pages/forward/address"; +import { + buildForwardDiagnosisFallbackResult, + getForwardDiagnosisQualityDisplay, + type ForwardDiagnosisResult, +} from "@/pages/forward/diagnosis"; +import { + executeForwardBatchChangeTunnel, + executeForwardBatchDelete, + executeForwardBatchRedeploy, + executeForwardBatchToggleService, +} from "@/pages/forward/batch-actions"; +import { buildForwardOrder, FORWARD_ORDER_KEY } from "@/pages/forward/order"; +import { PageLoadingState } from "@/components/page-state"; +import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; +import { saveOrder } from "@/utils/order-storage"; import { JwtUtil } from "@/utils/jwt"; interface Forward { @@ -94,32 +111,6 @@ interface ForwardForm { strategy: string; } -interface AddressItem { - id: number; - address: string; - copying: boolean; -} - -interface DiagnosisResult { - forwardName: string; - timestamp: number; - results: Array<{ - success: boolean; - description: string; - nodeName: string; - nodeId: string; - targetIp: string; - targetPort?: number; - message?: string; - averageTime?: number; - packetLoss?: number; - fromChainType?: number; // 1: 入口, 2: 链, 3: 出口 - fromInx?: number; - toChainType?: number; - toInx?: number; - }>; -} - // 添加分组接口 interface UserGroup { userId: number | null; @@ -137,20 +128,7 @@ export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); const [tunnels, setTunnels] = useState([]); - - // 检测是否为移动端 - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - - return () => window.removeEventListener("resize", checkMobile); - }, []); + const isMobile = useMobileBreakpoint(); // 显示模式状态 - 从localStorage读取,默认为平铺显示 const [viewMode, setViewMode] = useState<"grouped" | "direct">(() => { @@ -179,9 +157,9 @@ export default function ForwardPage() { const [currentDiagnosisForward, setCurrentDiagnosisForward] = useState(null); const [diagnosisResult, setDiagnosisResult] = - useState(null); + useState(null); const [addressModalTitle, setAddressModalTitle] = useState(""); - const [addressList, setAddressList] = useState([]); + const [addressList, setAddressList] = useState([]); // 导出相关状态 const [exportModalOpen, setExportModalOpen] = useState(false); @@ -245,56 +223,16 @@ export default function ForwardPage() { // 切换到直接显示模式时,初始化拖拽排序顺序 if (newMode === "direct") { - // 在平铺模式下,只对当前用户的转发进行排序 const currentUserId = JwtUtil.getUserIdFromToken(); - let userForwards = forwards; - - if (currentUserId !== null) { - userForwards = forwards.filter( - (f: Forward) => f.userId === currentUserId, - ); - } - - // 检查数据库中是否有排序信息 - const hasDbOrdering = userForwards.some( - (f: Forward) => f.inx !== undefined && f.inx !== 0, + const { order, fromDatabase } = buildForwardOrder( + forwards, + currentUserId, ); - if (hasDbOrdering) { - // 使用数据库中的排序信息 - const dbOrder = userForwards - .sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0)) - .map((f: Forward) => f.id); + setForwardOrder(order); - setForwardOrder(dbOrder); - - // 同步到localStorage - try { - localStorage.setItem("forward-order", JSON.stringify(dbOrder)); - } catch {} - } else { - // 使用本地存储的顺序 - const savedOrder = localStorage.getItem("forward-order"); - - if (savedOrder) { - try { - const orderIds = JSON.parse(savedOrder); - const validOrder = orderIds.filter((id: number) => - userForwards.some((f: Forward) => f.id === id), - ); - - userForwards.forEach((forward: Forward) => { - if (!validOrder.includes(forward.id)) { - validOrder.push(forward.id); - } - }); - setForwardOrder(validOrder); - } catch { - setForwardOrder(userForwards.map((f: Forward) => f.id)); - } - } else { - setForwardOrder(userForwards.map((f: Forward) => f.id)); - } + if (fromDatabase) { + saveOrder(FORWARD_ORDER_KEY, order); } } } catch {} @@ -320,58 +258,16 @@ export default function ForwardPage() { // 初始化拖拽排序顺序 if (viewMode === "direct") { - // 在平铺模式下,只对当前用户的转发进行排序 const currentUserId = JwtUtil.getUserIdFromToken(); - let userForwards = forwardsData; - - if (currentUserId !== null) { - userForwards = forwardsData.filter( - (f: Forward) => f.userId === currentUserId, - ); - } - - // 检查数据库中是否有排序信息 - const hasDbOrdering = userForwards.some( - (f: Forward) => f.inx !== undefined && f.inx !== 0, + const { order, fromDatabase } = buildForwardOrder( + forwardsData, + currentUserId, ); - if (hasDbOrdering) { - // 使用数据库中的排序信息 - const dbOrder = userForwards - .sort((a: Forward, b: Forward) => (a.inx ?? 0) - (b.inx ?? 0)) - .map((f: Forward) => f.id); + setForwardOrder(order); - setForwardOrder(dbOrder); - - // 同步到localStorage - try { - localStorage.setItem("forward-order", JSON.stringify(dbOrder)); - } catch {} - } else { - // 使用本地存储的顺序 - const savedOrder = localStorage.getItem("forward-order"); - - if (savedOrder) { - try { - const orderIds = JSON.parse(savedOrder); - // 验证保存的顺序是否仍然有效(只包含当前用户的转发) - const validOrder = orderIds.filter((id: number) => - userForwards.some((f: Forward) => f.id === id), - ); - - // 添加新的转发ID(如果存在) - userForwards.forEach((forward: Forward) => { - if (!validOrder.includes(forward.id)) { - validOrder.push(forward.id); - } - }); - setForwardOrder(validOrder); - } catch { - setForwardOrder(userForwards.map((f: Forward) => f.id)); - } - } else { - setForwardOrder(userForwards.map((f: Forward) => f.id)); - } + if (fromDatabase) { + saveOrder(FORWARD_ORDER_KEY, order); } } } else { @@ -587,7 +483,7 @@ export default function ForwardPage() { const addressCount = processedRemoteAddr.split(",").length; - let res; + let res: { code: number; msg: string }; if (isEdit) { // 更新时确保包含必要字段 @@ -647,7 +543,7 @@ export default function ForwardPage() { ), ); - let res; + let res: { code: number; msg: string }; if (targetState) { res = await resumeForwardService(forward.id); @@ -694,63 +590,33 @@ export default function ForwardPage() { const response = await diagnoseForward(forward.id); if (response.code === 0) { - setDiagnosisResult(response.data); + setDiagnosisResult(response.data as ForwardDiagnosisResult); } else { toast.error(response.msg || "诊断失败"); - setDiagnosisResult({ - forwardName: forward.name, - timestamp: Date.now(), - results: [ - { - success: false, - description: "诊断失败", - nodeName: "-", - nodeId: "-", - targetIp: forward.remoteAddr.split(",")[0] || "-", - message: response.msg || "诊断过程中发生错误", - }, - ], - }); + setDiagnosisResult( + buildForwardDiagnosisFallbackResult({ + forwardName: forward.name, + remoteAddr: forward.remoteAddr, + description: "诊断失败", + message: response.msg || "诊断过程中发生错误", + }), + ); } } catch { toast.error("网络错误,请重试"); - setDiagnosisResult({ - forwardName: forward.name, - timestamp: Date.now(), - results: [ - { - success: false, - description: "网络错误", - nodeName: "-", - nodeId: "-", - targetIp: forward.remoteAddr.split(",")[0] || "-", - message: "无法连接到服务器", - }, - ], - }); + setDiagnosisResult( + buildForwardDiagnosisFallbackResult({ + forwardName: forward.name, + remoteAddr: forward.remoteAddr, + description: "网络错误", + message: "无法连接到服务器", + }), + ); } finally { setDiagnosisLoading(false); } }; - // 获取连接质量 - const getQualityDisplay = (averageTime?: number, packetLoss?: number) => { - 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" }; - }; - // 格式化流量 const formatFlow = (value: number): string => { if (value === 0) return "0 B"; @@ -762,141 +628,26 @@ export default function ForwardPage() { return (value / (1024 * 1024 * 1024)).toFixed(2) + " GB"; }; - // 格式化入口地址 - const formatInAddress = (ipString: string, port: number): string => { - if (!ipString) return ""; - - const items = ipString - .split(",") - .map((item) => item.trim()) - .filter((item) => item); - - if (items.length === 0) return ""; - - // 检查第一项是否已经包含端口(格式:IP:端口) - const firstItem = items[0]; - const hasPort = /:\d+$/.test(firstItem); - - if (hasPort) { - // inIp 已经包含完整的 IP:Port 组合 - if (items.length === 1) { - return items[0]; - } - - return `${items[0]} (+${items.length - 1}个)`; - } - - // inIp 只包含IP,需要添加端口(兼容旧数据) - if (!port) return ""; - - if (items.length === 1) { - const ip = items[0]; - - if (ip.includes(":") && !ip.startsWith("[")) { - return `[${ip}]:${port}`; - } else { - return `${ip}:${port}`; - } - } - - const firstIp = items[0]; - let formattedFirstIp; - - if (firstIp.includes(":") && !firstIp.startsWith("[")) { - formattedFirstIp = `[${firstIp}]`; - } else { - formattedFirstIp = firstIp; - } - - return `${formattedFirstIp}:${port} (+${items.length - 1}个)`; - }; - - // 格式化远程地址 - const formatRemoteAddress = (addressString: string): string => { - if (!addressString) return ""; - - const addresses = addressString - .split(",") - .map((addr) => addr.trim()) - .filter((addr) => addr); - - if (addresses.length === 0) return ""; - if (addresses.length === 1) return addresses[0]; - - return `${addresses[0]} (+${addresses.length - 1})`; - }; - - // 检查是否有多个地址 - const hasMultipleAddresses = (addressString: string): boolean => { - if (!addressString) return false; - const addresses = addressString - .split(",") - .map((addr) => addr.trim()) - .filter((addr) => addr); - - return addresses.length > 1; - }; - // 显示地址列表弹窗 const showAddressModal = ( addressString: string, port: number | null, title: string, ) => { - if (!addressString) return; + const action = resolveForwardAddressAction(addressString, port, title); - let addresses: string[]; - - if (port !== null) { - // 入口地址处理 - const items = addressString - .split(",") - .map((item) => item.trim()) - .filter((item) => item); - - if (items.length <= 1) { - copyToClipboard(formatInAddress(addressString, port), title); - - return; - } - - // 检查是否已经包含端口 - const hasPort = /:\d+$/.test(items[0]); - - if (hasPort) { - // 已经包含完整的 IP:Port 组合,直接使用 - addresses = items; - } else { - // 只包含IP,需要添加端口 - addresses = items.map((ip) => { - if (ip.includes(":") && !ip.startsWith("[")) { - return `[${ip}]:${port}`; - } else { - return `${ip}:${port}`; - } - }); - } - } else { - // 远程地址处理 - addresses = addressString - .split(",") - .map((addr) => addr.trim()) - .filter((addr) => addr); - if (addresses.length <= 1) { - copyToClipboard(addressString, title); - - return; - } + if (action.type === "none") { + return; } - setAddressList( - addresses.map((address, index) => ({ - id: index, - address, - copying: false, - })), - ); - setAddressModalTitle(`${title} (${addresses.length}个)`); + if (action.type === "copy") { + copyToClipboard(action.text, action.label); + + return; + } + + setAddressList(action.items); + setAddressModalTitle(action.title); setAddressModalOpen(true); }; @@ -911,7 +662,7 @@ export default function ForwardPage() { }; // 复制地址 - const copyAddress = async (addressItem: AddressItem) => { + const copyAddress = async (addressItem: ForwardAddressItem) => { try { setAddressList((prev) => prev.map((item) => @@ -1220,10 +971,7 @@ export default function ForwardPage() { setForwardOrder(newOrder); - // 保存到localStorage - try { - localStorage.setItem("forward-order", JSON.stringify(newOrder)); - } catch {} + saveOrder(FORWARD_ORDER_KEY, newOrder); // 持久化到数据库 try { @@ -1292,27 +1040,22 @@ export default function ForwardPage() { if (selectedIds.size === 0) return; setBatchLoading(true); try { - const res = await batchDeleteForwards(Array.from(selectedIds)); + const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); - if (res.code === 0) { - const result = res.data; + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } - if (result.failCount === 0) { - toast.success(`成功删除 ${result.successCount} 项`); - } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); - } + if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); - setBatchDeleteModalOpen(false); + if (outcome.closeDeleteModal) { + setBatchDeleteModalOpen(false); + } loadData(false); - } else { - toast.error(res.msg || "删除失败"); } - } catch (e: any) { - toast.error(e.message || "删除失败"); } finally { setBatchLoading(false); } @@ -1322,33 +1065,22 @@ export default function ForwardPage() { if (selectedIds.size === 0) return; setBatchLoading(true); try { - const ids = Array.from(selectedIds); - const res = enable - ? await batchResumeForwards(ids) - : await batchPauseForwards(ids); + const outcome = await executeForwardBatchToggleService( + Array.from(selectedIds), + enable, + ); - if (res.code === 0) { - const result = res.data; + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } - if (result.failCount === 0) { - toast.success( - enable - ? `成功启用 ${result.successCount} 项` - : `成功停用 ${result.successCount} 项`, - ); - } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); - } + if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); loadData(false); - } else { - toast.error(res.msg || (enable ? "启用失败" : "停用失败")); } - } catch (e: any) { - toast.error(e.message || (enable ? "启用失败" : "停用失败")); } finally { setBatchLoading(false); } @@ -1358,26 +1090,21 @@ export default function ForwardPage() { if (selectedIds.size === 0) return; setBatchLoading(true); try { - const res = await batchRedeployForwards(Array.from(selectedIds)); + const outcome = await executeForwardBatchRedeploy( + Array.from(selectedIds), + ); - if (res.code === 0) { - const result = res.data; + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } - if (result.failCount === 0) { - toast.success(`成功重新下发 ${result.successCount} 项`); - } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); - } + if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); loadData(false); - } else { - toast.error(res.msg || "下发失败"); } - } catch (e: any) { - toast.error(e.message || "下发失败"); } finally { setBatchLoading(false); } @@ -1387,31 +1114,28 @@ export default function ForwardPage() { if (selectedIds.size === 0 || !batchTargetTunnelId) return; setBatchLoading(true); try { - const res = await batchChangeTunnel({ - forwardIds: Array.from(selectedIds), - targetTunnelId: batchTargetTunnelId, - }); + const outcome = await executeForwardBatchChangeTunnel( + Array.from(selectedIds), + batchTargetTunnelId, + ); - if (res.code === 0) { - const result = res.data; + if (outcome.toastVariant === "success") { + toast.success(outcome.toastMessage); + } else { + toast.error(outcome.toastMessage); + } - if (result.failCount === 0) { - toast.success(`成功换隧道 ${result.successCount} 项`); - } else { - toast.error( - `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, - ); - } + if (outcome.shouldRefresh) { setSelectedIds(new Set()); setSelectMode(false); - setBatchChangeTunnelModalOpen(false); - setBatchTargetTunnelId(null); + if (outcome.closeChangeTunnelModal) { + setBatchChangeTunnelModalOpen(false); + } + if (outcome.resetTargetTunnel) { + setBatchTargetTunnelId(null); + } loadData(false); - } else { - toast.error(res.msg || "隧道失败"); } - } catch (e: any) { - toast.error(e.message || "隧道失败"); } finally { setBatchLoading(false); } @@ -1568,6 +1292,7 @@ export default function ForwardPage() { title={isMobile ? "长按拖拽排序" : "拖拽排序"} >
- - 正在加载... -
-
- ); + return ; } const userGroups = groupForwardsByUserAndTunnel(); @@ -1804,6 +1527,9 @@ export default function ForwardPage() { {/* 显示模式切换按钮 */}
@@ -2327,7 +2067,7 @@ export default function ForwardPage() { isLoading={item.copying} size="sm" variant="light" - onClick={() => copyAddress(item)} + onPress={() => copyAddress(item)} > 复制 @@ -2401,6 +2141,7 @@ export default function ForwardPage() { size="sm" startContent={
{results.map((result, index) => { - const quality = getQualityDisplay( - result.averageTime, - result.packetLoss, - ); + const quality = + getForwardDiagnosisQualityDisplay( + result.averageTime, + result.packetLoss, + ); return (
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */} -
{ setShowCaptcha(false); setLoading(false); diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index 3766d56..09b4fda 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,17 +1,16 @@ import { useState, useEffect } from "react"; -import { Card, CardBody, CardHeader } from "@heroui/card"; -import { Button } from "@heroui/button"; -import { Input } from "@heroui/input"; -import { Select, SelectItem } from "@heroui/select"; +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Input } from "@/shadcn-bridge/heroui/input"; +import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, -} from "@heroui/modal"; -import { Chip } from "@heroui/chip"; -import { Spinner } from "@heroui/spinner"; +} from "@/shadcn-bridge/heroui/modal"; +import { Chip } from "@/shadcn-bridge/heroui/chip"; import toast from "react-hot-toast"; import { @@ -21,6 +20,7 @@ import { deleteSpeedLimit, getTunnelList, } from "@/api"; +import { PageLoadingState } from "@/components/page-state"; interface SpeedLimitRule { id: number; @@ -188,7 +188,7 @@ export default function LimitPage() { setSubmitLoading(true); try { - let res; + let res: { code: number; msg: string }; if (isEdit) { res = await updateSpeedLimit(form); @@ -215,14 +215,7 @@ export default function LimitPage() { }; if (loading) { - return ( -
-
- - 正在加载... -
-
- ); + return ; } return ( @@ -293,6 +286,7 @@ export default function LimitPage() { size="sm" startContent={
-
- - 正在加载... -
-
+ ) : nodeList.length === 0 ? ( - - -
-
- - - -
-
-

- 暂无节点配置 -

-

- 还没有创建任何节点配置,点击上方按钮开始创建 -

-
-
-
-
+ ) : (
@@ -1353,13 +1146,7 @@ export default function NodePage() { {isRemoteNode && node.syncError && (
- {node.syncError === "provider_share_deleted" - ? "提供方已删除该分享" - : node.syncError === "provider_share_disabled" - ? "提供方已禁用该分享" - : node.syncError === "provider_share_expired" - ? "提供方分享已过期" - : `远程同步失败: ${node.syncError}`} + {getRemoteSyncErrorMessage(node.syncError)}
)} {/* 基础信息 */} @@ -1816,6 +1603,7 @@ export default function NodePage() {
-
@@ -182,7 +184,7 @@ export const SettingsPage = () => { color="primary" size="sm" variant="flat" - onClick={() => setCurrentPanel(panel.name)} + onPress={() => setCurrentPanel(panel.name)} > 设为当前 @@ -191,7 +193,7 @@ export const SettingsPage = () => { color="danger" size="sm" variant="light" - onClick={() => handleDeletePanelAddress(panel.name)} + onPress={() => handleDeletePanelAddress(panel.name)} > 删除 diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index dad3c9d..a5f02cf 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1,20 +1,20 @@ import { useState, useEffect, useMemo } from "react"; -import { Card, CardBody, CardHeader } from "@heroui/card"; -import { Button } from "@heroui/button"; -import { Input, Textarea } from "@heroui/input"; -import { Select, SelectItem } from "@heroui/select"; +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Input, Textarea } from "@/shadcn-bridge/heroui/input"; +import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, -} from "@heroui/modal"; -import { Chip } from "@heroui/chip"; -import { Spinner } from "@heroui/spinner"; -import { Divider } from "@heroui/divider"; -import { Alert } from "@heroui/alert"; -import { Checkbox } from "@heroui/checkbox"; +} from "@/shadcn-bridge/heroui/modal"; +import { Chip } from "@/shadcn-bridge/heroui/chip"; +import { Spinner } from "@/shadcn-bridge/heroui/spinner"; +import { Divider } from "@/shadcn-bridge/heroui/divider"; +import { Alert } from "@/shadcn-bridge/heroui/alert"; +import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import toast from "react-hot-toast"; import { DndContext, @@ -45,6 +45,20 @@ import { batchDeleteTunnels, batchRedeployTunnels, } from "@/api"; +import { PageLoadingState } from "@/components/page-state"; +import { + buildDiagnosisFallbackResult, + getDiagnosisQualityDisplay, + type DiagnosisResult, +} from "@/pages/tunnel/diagnosis"; +import { + createTunnelFormDefaults, + getTunnelFlowDisplay, + getTunnelTypeDisplay, + validateTunnelForm, +} from "@/pages/tunnel/form"; +import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; +import { extractApiErrorMessage } from "@/api/error-message"; interface ChainTunnel { nodeId: number; @@ -92,27 +106,6 @@ interface TunnelForm { status: number; } -interface DiagnosisResult { - tunnelName: string; - tunnelType: string; - timestamp: number; - results: Array<{ - success: boolean; - description: string; - nodeName: string; - nodeId: string; - targetIp: string; - targetPort?: number; - message?: string; - averageTime?: number; - packetLoss?: number; - fromChainType?: number; // 1: 入口, 2: 链, 3: 出口 - fromInx?: number; - toChainType?: number; - toInx?: number; - }>; -} - export default function TunnelPage() { const [loading, setLoading] = useState(true); const [tunnels, setTunnels] = useState([]); @@ -134,18 +127,7 @@ export default function TunnelPage() { useState(null); // 表单状态 - const [form, setForm] = useState({ - name: "", - type: 1, - inNodeId: [], - outNodeId: [], - chainNodes: [], - flow: 1, - trafficRatio: 1.0, - inIp: "", - ipPreference: "", - status: 1, - }); + const [form, setForm] = useState(createTunnelFormDefaults()); // 表单验证错误 const [errors, setErrors] = useState<{ [key: string]: string }>({}); @@ -189,32 +171,12 @@ export default function TunnelPage() { setTunnelOrder(dbOrder); } else { - try { - const stored = localStorage.getItem("tunnel-order"); - - if (stored) { - const parsed = JSON.parse(stored); - - if (Array.isArray(parsed)) { - const existingIds = new Set(tunnelsData.map((t) => t.id)); - const validOrder = parsed - .map((id: any) => Number(id)) - .filter((id: number) => existingIds.has(id)); - - if (validOrder.length > 0) { - setTunnelOrder(validOrder); - } else { - setTunnelOrder(tunnelsData.map((t) => t.id)); - } - } else { - setTunnelOrder(tunnelsData.map((t) => t.id)); - } - } else { - setTunnelOrder(tunnelsData.map((t) => t.id)); - } - } catch { - setTunnelOrder(tunnelsData.map((t) => t.id)); - } + setTunnelOrder( + loadStoredOrder( + "tunnel-order", + tunnelsData.map((t) => t.id), + ), + ); } } else { toast.error(tunnelsRes.msg || "获取隧道列表失败"); @@ -233,59 +195,7 @@ export default function TunnelPage() { // 表单验证 const validateForm = (): boolean => { - const newErrors: { [key: string]: string } = {}; - - if (!form.name.trim()) { - newErrors.name = "请输入隧道名称"; - } else if (form.name.length < 2 || form.name.length > 50) { - newErrors.name = "隧道名称长度应在2-50个字符之间"; - } - - if (!form.inNodeId || form.inNodeId.length === 0) { - newErrors.inNodeId = "请至少选择一个入口节点"; - } else { - // 验证所有选择的节点都在线 - const offlineNodes = form.inNodeId.filter((item) => { - const node = nodes.find((n) => n.id === item.nodeId); - - return node && node.status !== 1; - }); - - if (offlineNodes.length > 0) { - newErrors.inNodeId = "所有入口节点必须在线"; - } - } - - if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) { - newErrors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)"; - } - - // 隧道转发时的验证 - if (form.type === 2) { - if (!form.outNodeId || form.outNodeId.length === 0) { - newErrors.outNodeId = "请至少选择一个出口节点"; - } else { - // 验证所有选择的节点都在线 - const offlineNodes = form.outNodeId.filter((item) => { - const node = nodes.find((n) => n.id === item.nodeId); - - return node && node.status !== 1; - }); - - if (offlineNodes.length > 0) { - newErrors.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) { - newErrors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点"; - } - } - } + const newErrors = validateTunnelForm(form, nodes); setErrors(newErrors); @@ -295,18 +205,7 @@ export default function TunnelPage() { // 新增隧道 const handleAdd = () => { setIsEdit(false); - setForm({ - name: "", - type: 1, - inNodeId: [], - outNodeId: [], - chainNodes: [], - flow: 1, - trafficRatio: 1.0, - inIp: "", - ipPreference: "", - status: 1, - }); + setForm(createTunnelFormDefaults()); setErrors({}); setModalOpen(true); }; @@ -518,91 +417,33 @@ export default function TunnelPage() { const response = await diagnoseTunnel(tunnel.id); if (response.code === 0) { - setDiagnosisResult(response.data); + setDiagnosisResult(response.data as DiagnosisResult); } else { toast.error(response.msg || "诊断失败"); - setDiagnosisResult({ - tunnelName: tunnel.name, - tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", - timestamp: Date.now(), - results: [ - { - success: false, - description: "诊断失败", - nodeName: "-", - nodeId: "-", - targetIp: "-", - targetPort: 443, - message: response.msg || "诊断过程中发生错误", - }, - ], - }); + setDiagnosisResult( + buildDiagnosisFallbackResult({ + tunnelName: tunnel.name, + tunnelType: tunnel.type, + description: "诊断失败", + message: response.msg || "诊断过程中发生错误", + }), + ); } } catch { toast.error("网络错误,请重试"); - setDiagnosisResult({ - tunnelName: tunnel.name, - tunnelType: tunnel.type === 1 ? "端口转发" : "隧道转发", - timestamp: Date.now(), - results: [ - { - success: false, - description: "网络错误", - nodeName: "-", - nodeId: "-", - targetIp: "-", - targetPort: 443, - message: "无法连接到服务器", - }, - ], - }); + setDiagnosisResult( + buildDiagnosisFallbackResult({ + tunnelName: tunnel.name, + tunnelType: tunnel.type, + description: "网络错误", + message: "无法连接到服务器", + }), + ); } finally { setDiagnosisLoading(false); } }; - // 获取类型显示 - const getTypeDisplay = (type: number) => { - switch (type) { - case 1: - return { text: "端口转发", color: "primary" }; - case 2: - return { text: "隧道转发", color: "secondary" }; - default: - return { text: "未知", color: "default" }; - } - }; - - // 获取流量计算显示 - const getFlowDisplay = (flow: number) => { - switch (flow) { - case 1: - return "单向计算"; - case 2: - return "双向计算"; - default: - return "未知"; - } - }; - - // 获取连接质量 - const getQualityDisplay = (averageTime?: number, packetLoss?: number) => { - 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" }; - }; - // 处理拖拽结束 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; @@ -624,10 +465,7 @@ export default function TunnelPage() { setTunnelOrder(newOrder); - // 保存到 localStorage - try { - localStorage.setItem("tunnel-order", JSON.stringify(newOrder)); - } catch {} + saveOrder("tunnel-order", newOrder); // 持久化到数据库 try { @@ -701,8 +539,8 @@ export default function TunnelPage() { } else { toast.error(res.msg || "删除失败"); } - } catch (e: any) { - toast.error(e.message || "删除失败"); + } catch (error) { + toast.error(extractApiErrorMessage(error, "删除失败")); } finally { setBatchLoading(false); } @@ -730,8 +568,8 @@ export default function TunnelPage() { } else { toast.error(res.msg || "下发失败"); } - } catch (e: any) { - toast.error(e.message || "下发失败"); + } catch (error) { + toast.error(extractApiErrorMessage(error, "下发失败")); } finally { setBatchLoading(false); } @@ -829,14 +667,7 @@ export default function TunnelPage() { }; if (loading) { - return ( -
-
- - 正在加载... -
-
- ); + return ; } return ( @@ -902,7 +733,7 @@ export default function TunnelPage() { >
{sortedTunnels.map((tunnel) => { - const typeDisplay = getTypeDisplay(tunnel.type); + const typeDisplay = getTunnelTypeDisplay(tunnel.type); return ( @@ -942,6 +773,7 @@ export default function TunnelPage() { title="拖拽排序" >
- {getFlowDisplay(tunnel.flow)} + {getTunnelFlowDisplay(tunnel.flow)}
@@ -1091,6 +928,7 @@ export default function TunnelPage() { size="sm" startContent={
{results.map((result, index) => { - const quality = getQualityDisplay( + const quality = getDiagnosisQualityDisplay( result.averageTime, result.packetLoss, ); @@ -2522,6 +2366,7 @@ export default function TunnelPage() {
-
- - 正在加载... -
-
+ ) : users.length === 0 ? ( @@ -799,6 +802,7 @@ export default function UserPage() { size="sm" startContent={