mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 16:06:36 +08:00
feat(frontend): wire pages to shadcn bridge and modular helpers
This commit is contained in:
+19
-31
@@ -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",
|
||||
|
||||
@@ -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 <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
@@ -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<LoginResponse>("/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<UserApiItem[]>("/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<UserPackageInfoApiData>("/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<NodeApiItem[]>("/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<string>("/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<NodeReleaseApiItem[]>("/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<TunnelApiItem[]>("/tunnel/list");
|
||||
export const getTunnelById = (id: number) =>
|
||||
Network.post("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: any) => Network.post("/tunnel/update", data);
|
||||
Network.post<TunnelApiItem>("/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<TunnelDiagnosisApiData>("/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<UserTunnelPermissionApiItem[]>("/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<UserTunnelApiItem[]>("/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<ForwardApiItem[]>("/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<ForwardDiagnosisApiData>("/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<SpeedLimitApiItem[]>("/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<number[]>("/user/groups", { id });
|
||||
|
||||
// 网站配置相关接口
|
||||
export const getConfigs = () => Network.post("/config/list");
|
||||
export const getConfigs = () =>
|
||||
Network.post<Record<string, string>>("/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<string, string>) =>
|
||||
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<BatchOperationResult>("/forward/batch-delete", { ids });
|
||||
export const batchPauseForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-pause", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-pause", { ids });
|
||||
export const batchResumeForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-resume", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-resume", { ids });
|
||||
export const batchDeleteTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-delete", { ids });
|
||||
Network.post<BatchOperationResult>("/tunnel/batch-delete", { ids });
|
||||
export const batchDeleteNodes = (ids: number[]) =>
|
||||
Network.post("/node/batch-delete", { ids });
|
||||
Network.post<BatchOperationResult>("/node/batch-delete", { ids });
|
||||
export const batchRedeployForwards = (ids: number[]) =>
|
||||
Network.post("/forward/batch-redeploy", { ids });
|
||||
Network.post<BatchOperationResult>("/forward/batch-redeploy", { ids });
|
||||
export const batchRedeployTunnels = (ids: number[]) =>
|
||||
Network.post("/tunnel/batch-redeploy", { ids });
|
||||
Network.post<BatchOperationResult>("/tunnel/batch-redeploy", { ids });
|
||||
export const batchChangeTunnel = (data: {
|
||||
forwardIds: number[];
|
||||
targetTunnelId: number;
|
||||
}) => Network.post("/forward/batch-change-tunnel", data);
|
||||
}) => Network.post<BatchOperationResult>("/forward/batch-change-tunnel", data);
|
||||
|
||||
// 分组与权限分配接口
|
||||
export const getTunnelGroupList = () => Network.post("/group/tunnel/list");
|
||||
export const getTunnelGroupList = () =>
|
||||
Network.post<TunnelGroupApiItem[]>("/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<UserGroupApiItem[]>("/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<GroupPermissionApiItem[]>("/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<Array<Record<string, unknown>>>("/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<Array<Record<string, unknown>>>(
|
||||
"/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 {
|
||||
|
||||
@@ -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<T = any> {
|
||||
interface ApiResponse<T = unknown> {
|
||||
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<unknown>) {
|
||||
return (
|
||||
response &&
|
||||
response.code === 401 &&
|
||||
@@ -72,9 +74,9 @@ function isTokenExpired(response: ApiResponse) {
|
||||
}
|
||||
|
||||
const Network = {
|
||||
get: function <T = any>(
|
||||
get: function <T = unknown>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
data: unknown = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
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<ApiResponse<T>>) {
|
||||
// 检查是否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 <T = any>(
|
||||
post: function <T = unknown>(
|
||||
path: string = "",
|
||||
data: any = {},
|
||||
data: unknown = {},
|
||||
options: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
// 异步检查是否有更新的配置
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-black">
|
||||
{/* 顶部导航栏 */}
|
||||
|
||||
@@ -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() {
|
||||
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -200,7 +201,7 @@ export default function ChangePasswordPage() {
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
{loading ? "修改中..." : "立即修改账号密码"}
|
||||
</Button>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Textarea } from "@heroui/input";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Divider } from "@/shadcn-bridge/heroui/divider";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@heroui/modal";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
@@ -599,7 +599,7 @@ export default function ConfigPage() {
|
||||
disabled={!hasChanges}
|
||||
isLoading={saving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={handleSave}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
@@ -715,7 +715,7 @@ export default function ConfigPage() {
|
||||
color="primary"
|
||||
isLoading={announcementSaving}
|
||||
startContent={<SaveIcon className="w-4 h-4" />}
|
||||
onClick={saveAnnouncement}
|
||||
onPress={saveAnnouncement}
|
||||
>
|
||||
保存公告
|
||||
</Button>
|
||||
|
||||
@@ -1,57 +1,18 @@
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@heroui/modal";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody } from "@/shadcn-bridge/heroui/modal";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
|
||||
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
|
||||
import { MetricCard } from "@/pages/dashboard/components/metric-card";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
getUserPackageInfo,
|
||||
getAnnouncement,
|
||||
type AnnouncementData,
|
||||
} from "@/api";
|
||||
|
||||
interface UserInfo {
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
}
|
||||
|
||||
interface UserTunnel {
|
||||
id: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
flow: number;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
num: number;
|
||||
expTime?: string;
|
||||
flowResetTime?: number;
|
||||
tunnelFlow: number;
|
||||
}
|
||||
|
||||
interface Forward {
|
||||
id: number;
|
||||
name: string;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
inIp: string;
|
||||
inPort: number;
|
||||
remoteAddr: string;
|
||||
inFlow: number;
|
||||
outFlow: number;
|
||||
}
|
||||
useDashboardData,
|
||||
type DashboardForward as Forward,
|
||||
type DashboardUserTunnel as UserTunnel,
|
||||
} from "@/pages/dashboard/use-dashboard-data";
|
||||
|
||||
interface AddressItem {
|
||||
id: number;
|
||||
@@ -60,178 +21,21 @@ interface AddressItem {
|
||||
copying: boolean;
|
||||
}
|
||||
|
||||
interface StatisticsFlow {
|
||||
id: number;
|
||||
userId: number;
|
||||
flow: number;
|
||||
totalFlow: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>({} as UserInfo);
|
||||
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<Forward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<StatisticsFlow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
loading,
|
||||
userInfo,
|
||||
userTunnels,
|
||||
forwardList,
|
||||
statisticsFlows,
|
||||
isAdmin,
|
||||
announcement,
|
||||
} = useDashboardData();
|
||||
|
||||
const [addressModalOpen, setAddressModalOpen] = useState(false);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
||||
|
||||
// 检查有效期通知
|
||||
const checkExpirationNotifications = (
|
||||
userInfo: UserInfo,
|
||||
tunnels: UserTunnel[],
|
||||
) => {
|
||||
// 避免重复通知,检查是否已经显示过
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setUserInfo({} as UserInfo);
|
||||
setUserTunnels([]);
|
||||
setForwardList([]);
|
||||
setStatisticsFlows([]);
|
||||
|
||||
const adminStatus = localStorage.getItem("admin");
|
||||
|
||||
setIsAdmin(adminStatus === "true");
|
||||
|
||||
loadPackageData();
|
||||
loadAnnouncement();
|
||||
localStorage.setItem("e", "/dashboard");
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
||||
setUserInfo(data.userInfo || {});
|
||||
setUserTunnels(data.tunnelPermissions || []);
|
||||
setForwardList(data.forwards || []);
|
||||
setStatisticsFlows(data.statisticsFlows || []);
|
||||
|
||||
// 检查有效期并显示通知
|
||||
checkExpirationNotifications(
|
||||
data.userInfo,
|
||||
data.tunnelPermissions || [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "获取套餐信息失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("获取套餐信息失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
// 99999 表示无限制
|
||||
if (value === 99999) {
|
||||
@@ -289,7 +93,7 @@ export default function DashboardPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const getExpStatus = (expTime?: string) => {
|
||||
const getExpStatus = (expTime?: string | number) => {
|
||||
if (!expTime)
|
||||
return {
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
@@ -450,7 +254,7 @@ export default function DashboardPage() {
|
||||
const now = new Date();
|
||||
const currentDay = now.getDate();
|
||||
|
||||
let daysUntilReset;
|
||||
let daysUntilReset: number;
|
||||
|
||||
if (resetDay > currentDay) {
|
||||
daysUntilReset = resetDay - currentDay;
|
||||
@@ -533,7 +337,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const firstIp = items[0];
|
||||
let formattedFirstIp;
|
||||
let formattedFirstIp: string;
|
||||
|
||||
if (firstIp.includes(":") && !firstIp.startsWith("[")) {
|
||||
formattedFirstIp = `[${firstIp}]`;
|
||||
@@ -598,7 +402,7 @@ export default function DashboardPage() {
|
||||
// 检查是否已经包含端口
|
||||
const hasPort = /:\d+$/.test(items[0]);
|
||||
|
||||
let formattedList;
|
||||
let formattedList: AddressItem[];
|
||||
|
||||
if (hasPort) {
|
||||
// 已经包含完整的 IP:Port 组合,直接使用
|
||||
@@ -611,7 +415,7 @@ export default function DashboardPage() {
|
||||
} else {
|
||||
// 只包含IP,需要添加端口
|
||||
formattedList = items.map((ip, index) => {
|
||||
let formattedAddress;
|
||||
let formattedAddress: string;
|
||||
|
||||
if (ip.includes(":") && !ip.startsWith("[")) {
|
||||
formattedAddress = `[${ip}]:${port}`;
|
||||
@@ -709,324 +513,147 @@ export default function DashboardPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-3 lg:px-6 flex-grow pt-2 lg:pt-4">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-gray-200 dark:border-gray-700 border-t-gray-600 dark:border-t-gray-300 rounded-full" />
|
||||
<span className="text-default-600">正在加载数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载数据..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 lg:px-6 py-2 lg:py-4">
|
||||
{announcement && announcement.content && (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{announcement && <AnnouncementBanner announcement={announcement} />}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
总流量
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatFlow(userInfo.flow, "gb")}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
已用流量
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-green-100 dark:bg-green-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatFlow(calculateUserTotalUsedFlow())}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("flow"),
|
||||
"sm",
|
||||
userInfo.flow === 99999,
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
{userInfo.flow === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
|
||||
</p>
|
||||
{userInfo.flowResetTime !== undefined &&
|
||||
userInfo.flowResetTime !== null && (
|
||||
<div className="text-xs text-default-500 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{formatResetTime(userInfo.flowResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
转发配额
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-purple-100 dark:bg-purple-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{formatNumber(userInfo.num || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
已用转发
|
||||
</p>
|
||||
<div className="p-1.5 lg:p-2 bg-orange-100 dark:bg-orange-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{forwardList.length}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("forwards"),
|
||||
"sm",
|
||||
userInfo.num === 99999,
|
||||
)}
|
||||
<p className="text-xs text-default-500 mt-1 truncate">
|
||||
{userInfo.num === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 24小时流量统计图表 */}
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MetricCard
|
||||
icon={
|
||||
<svg
|
||||
className="w-5 h-5 text-primary"
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
24小时流量统计
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{statisticsFlows.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-default-500">暂无流量统计数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 流量趋势图 */}
|
||||
<div className="h-64 lg:h-80 w-full">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={processFlowChartData()}>
|
||||
<CartesianGrid
|
||||
className="opacity-30"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<XAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v =
|
||||
typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number | string }>;
|
||||
label?: string | number;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const firstValue = payload[0]?.value;
|
||||
const numericValue =
|
||||
typeof firstValue === "number"
|
||||
? firstValue
|
||||
: Number(firstValue);
|
||||
const flowValue = Number.isFinite(numericValue)
|
||||
? numericValue
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||
<p className="text-primary">
|
||||
{`流量: ${formatFlow(flowValue)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: "#8b5cf6",
|
||||
strokeWidth: 2,
|
||||
fill: "#fff",
|
||||
}}
|
||||
dataKey="flow"
|
||||
dot={false}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<MetricCard
|
||||
bottomContent={
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("flow"),
|
||||
"sm",
|
||||
userInfo.flow === 99999,
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<p className="text-xs text-default-500 truncate">
|
||||
{userInfo.flow === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("flow").toFixed(1)}%`}
|
||||
</p>
|
||||
{userInfo.flowResetTime !== undefined &&
|
||||
userInfo.flowResetTime !== null && (
|
||||
<div className="text-xs text-default-500 flex items-center gap-1">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">
|
||||
{formatResetTime(userInfo.flowResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
}
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-green-600 dark:text-green-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-green-100 dark:bg-green-500/20"
|
||||
title="已用流量"
|
||||
value={formatFlow(calculateUserTotalUsedFlow())}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-purple-600 dark:text-purple-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-purple-100 dark:bg-purple-500/20"
|
||||
title="转发配额"
|
||||
value={formatNumber(userInfo.num || 0)}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
bottomContent={
|
||||
<div className="mt-1">
|
||||
{renderProgressBar(
|
||||
calculateUsagePercentage("forwards"),
|
||||
"sm",
|
||||
userInfo.num === 99999,
|
||||
)}
|
||||
<p className="text-xs text-default-500 mt-1 truncate">
|
||||
{userInfo.num === 99999
|
||||
? "无限制"
|
||||
: `${calculateUsagePercentage("forwards").toFixed(1)}%`}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 lg:w-5 lg:h-5 text-orange-600 dark:text-orange-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconClassName="bg-orange-100 dark:bg-orange-500/20"
|
||||
title="已用转发"
|
||||
value={forwardList.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FlowChartCard
|
||||
chartData={processFlowChartData()}
|
||||
formatFlow={formatFlow}
|
||||
statisticsFlowsCount={statisticsFlows.length}
|
||||
/>
|
||||
|
||||
{/* 隧道权限 - 管理员不显示 */}
|
||||
{!isAdmin && (
|
||||
@@ -1034,6 +661,7 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1054,22 +682,7 @@ export default function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{userTunnels.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-default-500">暂无隧道权限</p>
|
||||
</div>
|
||||
<PageEmptyState className="h-48" message="暂无隧道权限" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{userTunnels.map((tunnel) => {
|
||||
@@ -1170,6 +783,7 @@ export default function DashboardPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1192,6 +806,7 @@ export default function DashboardPage() {
|
||||
{groupedForwards().length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-12 h-12 text-default-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1330,7 +945,7 @@ export default function DashboardPage() {
|
||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
<div className="mb-4 text-right">
|
||||
<Button size="sm" onClick={copyAllAddresses}>
|
||||
<Button size="sm" onPress={copyAllAddresses}>
|
||||
复制全部
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1348,7 +963,7 @@ export default function DashboardPage() {
|
||||
isLoading={item.copying}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => copyAddress(item)}
|
||||
onPress={() => copyAddress(item)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
|
||||
+170
-422
@@ -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<Forward[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
|
||||
// 检测是否为移动端
|
||||
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<Forward | null>(null);
|
||||
const [diagnosisResult, setDiagnosisResult] =
|
||||
useState<DiagnosisResult | null>(null);
|
||||
useState<ForwardDiagnosisResult | null>(null);
|
||||
const [addressModalTitle, setAddressModalTitle] = useState("");
|
||||
const [addressList, setAddressList] = useState<AddressItem[]>([]);
|
||||
const [addressList, setAddressList] = useState<ForwardAddressItem[]>([]);
|
||||
|
||||
// 导出相关状态
|
||||
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 ? "长按拖拽排序" : "拖拽排序"}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1621,6 +1346,7 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
{hasMultipleAddresses(forward.inIp) && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-default-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1660,6 +1386,7 @@ export default function ForwardPage() {
|
||||
</div>
|
||||
{hasMultipleAddresses(forward.remoteAddr) && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-default-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1715,6 +1442,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1733,6 +1461,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1755,6 +1484,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1783,14 +1513,7 @@ export default function ForwardPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
const userGroups = groupForwardsByUserAndTunnel();
|
||||
@@ -1804,6 +1527,9 @@ export default function ForwardPage() {
|
||||
{/* 显示模式切换按钮 */}
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label={
|
||||
viewMode === "grouped" ? "切换到直接显示" : "切换到分类显示"
|
||||
}
|
||||
className="text-sm"
|
||||
color="default"
|
||||
size="sm"
|
||||
@@ -1812,7 +1538,12 @@ export default function ForwardPage() {
|
||||
onPress={handleViewModeChange}
|
||||
>
|
||||
{viewMode === "grouped" ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2zM3 16a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
|
||||
@@ -1820,7 +1551,12 @@ export default function ForwardPage() {
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
)}
|
||||
@@ -1936,6 +1672,7 @@ export default function ForwardPage() {
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1984,6 +1721,7 @@ export default function ForwardPage() {
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-success"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -2039,6 +1777,7 @@ export default function ForwardPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -2092,6 +1831,7 @@ export default function ForwardPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -2309,7 +2049,7 @@ export default function ForwardPage() {
|
||||
<ModalHeader className="text-base">{addressModalTitle}</ModalHeader>
|
||||
<ModalBody className="pb-6">
|
||||
<div className="mb-4 text-right">
|
||||
<Button size="sm" onClick={copyAllAddresses}>
|
||||
<Button size="sm" onPress={copyAllAddresses}>
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2327,7 +2067,7 @@ export default function ForwardPage() {
|
||||
isLoading={item.copying}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => copyAddress(item)}
|
||||
onPress={() => copyAddress(item)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
@@ -2401,6 +2141,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -2421,6 +2162,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -2446,6 +2188,7 @@ export default function ForwardPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -2591,6 +2334,7 @@ export default function ForwardPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-success-600 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -2603,6 +2347,7 @@ export default function ForwardPage() {
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-danger-600 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -2811,10 +2556,11 @@ export default function ForwardPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
const quality =
|
||||
getForwardDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -2984,10 +2730,11 @@ export default function ForwardPage() {
|
||||
</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
const quality =
|
||||
getForwardDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -3140,6 +2887,7 @@ export default function ForwardPage() {
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
TableColumn,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@heroui/table";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
updateTunnelGroup,
|
||||
updateUserGroup,
|
||||
} from "@/api";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
|
||||
interface TunnelItem {
|
||||
id: number;
|
||||
@@ -86,22 +87,9 @@ const formatDate = (timestamp?: number): string => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const isAdminUser = () => {
|
||||
let adminFlag = localStorage.getItem("admin") === "true";
|
||||
|
||||
if (localStorage.getItem("admin") === null) {
|
||||
const roleId = parseInt(localStorage.getItem("role_id") || "1", 10);
|
||||
|
||||
adminFlag = roleId === 0;
|
||||
localStorage.setItem("admin", adminFlag.toString());
|
||||
}
|
||||
|
||||
return adminFlag;
|
||||
};
|
||||
|
||||
export default function GroupPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin] = useState(isAdminUser());
|
||||
const [isAdmin] = useState(getAdminFlag());
|
||||
|
||||
const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
@@ -198,19 +186,25 @@ export default function GroupPage() {
|
||||
]);
|
||||
|
||||
if (tunnelGroupRes.code === 0) {
|
||||
setTunnelGroups(tunnelGroupRes.data || []);
|
||||
setTunnelGroups(
|
||||
Array.isArray(tunnelGroupRes.data) ? tunnelGroupRes.data : [],
|
||||
);
|
||||
}
|
||||
if (userGroupRes.code === 0) {
|
||||
setUserGroups(userGroupRes.data || []);
|
||||
setUserGroups(
|
||||
Array.isArray(userGroupRes.data) ? userGroupRes.data : [],
|
||||
);
|
||||
}
|
||||
if (permissionRes.code === 0) {
|
||||
setPermissions(permissionRes.data || []);
|
||||
setPermissions(
|
||||
Array.isArray(permissionRes.data) ? permissionRes.data : [],
|
||||
);
|
||||
}
|
||||
if (tunnelRes.code === 0) {
|
||||
setTunnels(tunnelRes.data || []);
|
||||
setTunnels(Array.isArray(tunnelRes.data) ? tunnelRes.data : []);
|
||||
}
|
||||
if (userRes.code === 0) {
|
||||
setUsers(userRes.data || []);
|
||||
setUsers(Array.isArray(userRes.data) ? userRes.data : []);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Button } from "@heroui/button";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { useState, useEffect } from "react";
|
||||
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";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title } from "@/components/primitives";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
||||
import { writeLoginSession } from "@/utils/session";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
interface LoginForm {
|
||||
username: string;
|
||||
@@ -29,12 +30,7 @@ export default function IndexPage() {
|
||||
const [showCaptcha, setShowCaptcha] = useState(false);
|
||||
const [siteKey, setSiteKey] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [isWebView, setIsWebView] = useState(false);
|
||||
|
||||
// 检测是否在WebView中运行
|
||||
useEffect(() => {
|
||||
setIsWebView(isWebViewFunc());
|
||||
}, []);
|
||||
const isWebView = useWebViewMode();
|
||||
|
||||
// 验证表单
|
||||
const validateForm = (): boolean => {
|
||||
@@ -91,10 +87,7 @@ export default function IndexPage() {
|
||||
|
||||
// 检查是否需要强制修改密码
|
||||
if (response.data.requirePasswordChange) {
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
writeLoginSession(response.data);
|
||||
toast.success("检测到默认密码,即将跳转到修改密码页面");
|
||||
navigate("/change-password");
|
||||
|
||||
@@ -102,10 +95,7 @@ export default function IndexPage() {
|
||||
}
|
||||
|
||||
// 保存登录信息
|
||||
localStorage.setItem("token", response.data.token);
|
||||
localStorage.setItem("role_id", response.data.role_id.toString());
|
||||
localStorage.setItem("name", response.data.name);
|
||||
localStorage.setItem("admin", (response.data.role_id === 0).toString());
|
||||
writeLoginSession(response.data);
|
||||
|
||||
// 登录成功
|
||||
toast.success("登录成功");
|
||||
@@ -206,7 +196,7 @@ export default function IndexPage() {
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
onPress={handleLogin}
|
||||
>
|
||||
{loading ? (showCaptcha ? "验证中..." : "登录中...") : "登录"}
|
||||
</Button>
|
||||
@@ -238,10 +228,9 @@ export default function IndexPage() {
|
||||
{showCaptcha && siteKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
||||
<div
|
||||
<button
|
||||
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCaptcha(false);
|
||||
setLoading(false);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -293,6 +286,7 @@ export default function LimitPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -311,6 +305,7 @@ export default function LimitPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -344,6 +339,7 @@ export default function LimitPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { useState, useEffect, useMemo, useRef } 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 { useState, useEffect, useMemo, useCallback } from "react";
|
||||
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 {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Alert } from "@heroui/alert";
|
||||
import { Progress } from "@heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@heroui/accordion";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import toast from "react-hot-toast";
|
||||
import axios from "axios";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -51,6 +50,16 @@ import {
|
||||
getNodeReleases,
|
||||
rollbackNode,
|
||||
} from "@/api";
|
||||
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
|
||||
import {
|
||||
getConnectionStatusMeta,
|
||||
getRemoteSyncErrorMessage,
|
||||
} from "@/pages/node/display";
|
||||
import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
interface Node {
|
||||
id: number;
|
||||
@@ -140,8 +149,6 @@ export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [wsConnecting, setWsConnecting] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
@@ -200,53 +207,26 @@ export default function NodePage() {
|
||||
Record<number, { stage: string; percent: number; message: string }>
|
||||
>({});
|
||||
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const offlineTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const offlineDelayMs = 3000;
|
||||
const handleNodeOffline = useCallback((nodeId: number) => {
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === "offline" && node.systemInfo === null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const clearOfflineTimer = (nodeId: number) => {
|
||||
const timer = offlineTimersRef.current.get(nodeId);
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNodeOffline = (nodeId: number) => {
|
||||
if (offlineTimersRef.current.has(nodeId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
offlineTimersRef.current.delete(nodeId);
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (node.connectionStatus === "offline" && node.systemInfo === null)
|
||||
return node;
|
||||
|
||||
return { ...node, connectionStatus: "offline", systemInfo: null };
|
||||
}),
|
||||
);
|
||||
}, offlineDelayMs);
|
||||
|
||||
offlineTimersRef.current.set(nodeId, timer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
initWebSocket();
|
||||
|
||||
return () => {
|
||||
closeWebSocket();
|
||||
};
|
||||
return { ...node, connectionStatus: "offline", systemInfo: null };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({
|
||||
delayMs: 3000,
|
||||
onNodeOffline: handleNodeOffline,
|
||||
});
|
||||
|
||||
// 加载节点列表
|
||||
const loadNodes = async () => {
|
||||
const loadNodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getNodeList();
|
||||
@@ -279,32 +259,12 @@ export default function NodePage() {
|
||||
|
||||
setNodeOrder(dbOrder);
|
||||
} else {
|
||||
try {
|
||||
const stored = localStorage.getItem("node-order");
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const existingIds = new Set(nodesData.map((n) => n.id));
|
||||
const validOrder = parsed
|
||||
.map((id: any) => Number(id))
|
||||
.filter((id: number) => existingIds.has(id));
|
||||
|
||||
if (validOrder.length > 0) {
|
||||
setNodeOrder(validOrder);
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} else {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
} catch {
|
||||
setNodeOrder(nodesData.map((n) => n.id));
|
||||
}
|
||||
setNodeOrder(
|
||||
loadStoredOrder(
|
||||
"node-order",
|
||||
nodesData.map((n) => n.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.msg || "加载节点列表失败");
|
||||
@@ -314,68 +274,7 @@ export default function NodePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化WebSocket连接
|
||||
const initWebSocket = () => {
|
||||
if (
|
||||
websocketRef.current &&
|
||||
(websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (websocketRef.current) {
|
||||
closeWebSocket();
|
||||
}
|
||||
|
||||
// 构建WebSocket URL,使用axios的baseURL
|
||||
const baseUrl =
|
||||
axios.defaults.baseURL ||
|
||||
(import.meta.env.VITE_API_BASE
|
||||
? `${import.meta.env.VITE_API_BASE}/api/v1/`
|
||||
: "/api/v1/");
|
||||
const wsUrl =
|
||||
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
|
||||
`/system-info?type=0&secret=${localStorage.getItem("token")}`;
|
||||
|
||||
try {
|
||||
setWsConnecting(true);
|
||||
websocketRef.current = new WebSocket(wsUrl);
|
||||
|
||||
websocketRef.current.onopen = () => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(true);
|
||||
setWsConnecting(false);
|
||||
};
|
||||
|
||||
websocketRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
handleWebSocketMessage(data);
|
||||
} catch {
|
||||
// 解析失败时不输出错误信息
|
||||
}
|
||||
};
|
||||
|
||||
websocketRef.current.onerror = () => {
|
||||
// WebSocket错误时不输出错误信息
|
||||
};
|
||||
|
||||
websocketRef.current.onclose = () => {
|
||||
websocketRef.current = null;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理WebSocket消息
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
@@ -404,61 +303,20 @@ export default function NodePage() {
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
try {
|
||||
let systemInfo;
|
||||
const systemInfo = buildNodeSystemInfo(
|
||||
messageData,
|
||||
node.systemInfo,
|
||||
);
|
||||
|
||||
if (typeof messageData === "string") {
|
||||
systemInfo = JSON.parse(messageData);
|
||||
} else {
|
||||
systemInfo = messageData;
|
||||
}
|
||||
|
||||
const currentUpload = parseInt(systemInfo.bytes_transmitted) || 0;
|
||||
const currentDownload = parseInt(systemInfo.bytes_received) || 0;
|
||||
const currentUptime = parseInt(systemInfo.uptime) || 0;
|
||||
|
||||
let uploadSpeed = 0;
|
||||
let downloadSpeed = 0;
|
||||
|
||||
if (node.systemInfo && node.systemInfo.uptime) {
|
||||
const timeDiff = currentUptime - node.systemInfo.uptime;
|
||||
|
||||
if (timeDiff > 0 && timeDiff <= 10) {
|
||||
const lastUpload = node.systemInfo.uploadTraffic || 0;
|
||||
const lastDownload = node.systemInfo.downloadTraffic || 0;
|
||||
|
||||
const uploadDiff = currentUpload - lastUpload;
|
||||
const downloadDiff = currentDownload - lastDownload;
|
||||
|
||||
const uploadReset = currentUpload < lastUpload;
|
||||
const downloadReset = currentDownload < lastDownload;
|
||||
|
||||
if (!uploadReset && uploadDiff >= 0) {
|
||||
uploadSpeed = uploadDiff / timeDiff;
|
||||
}
|
||||
|
||||
if (!downloadReset && downloadDiff >= 0) {
|
||||
downloadSpeed = downloadDiff / timeDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
systemInfo: {
|
||||
cpuUsage: parseFloat(systemInfo.cpu_usage) || 0,
|
||||
memoryUsage: parseFloat(systemInfo.memory_usage) || 0,
|
||||
uploadTraffic: currentUpload,
|
||||
downloadTraffic: currentDownload,
|
||||
uploadSpeed: uploadSpeed,
|
||||
downloadSpeed: downloadSpeed,
|
||||
uptime: currentUptime,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
if (!systemInfo) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
systemInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
@@ -487,49 +345,13 @@ export default function NodePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试重新连接
|
||||
const attemptReconnect = () => {
|
||||
if (reconnectTimerRef.current) return;
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const { wsConnected, wsConnecting } = useNodeRealtime({
|
||||
onMessage: handleWebSocketMessage,
|
||||
});
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null;
|
||||
initWebSocket();
|
||||
}, 3000 * reconnectAttemptsRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭WebSocket连接
|
||||
const closeWebSocket = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
|
||||
offlineTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
offlineTimersRef.current.clear();
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setWsConnected(false);
|
||||
setWsConnecting(false);
|
||||
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.onopen = null;
|
||||
websocketRef.current.onmessage = null;
|
||||
websocketRef.current.onerror = null;
|
||||
websocketRef.current.onclose = null;
|
||||
|
||||
if (
|
||||
websocketRef.current.readyState === WebSocket.OPEN ||
|
||||
websocketRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
websocketRef.current.close();
|
||||
}
|
||||
|
||||
websocketRef.current = null;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
}, [loadNodes]);
|
||||
|
||||
// 格式化速度
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
@@ -797,11 +619,11 @@ export default function NodePage() {
|
||||
const res = await getNodeInstallCommand(node.id);
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(res.data);
|
||||
const copied = await tryCopyInstallCommand(res.data);
|
||||
|
||||
if (copied) {
|
||||
toast.success("安装命令已复制到剪贴板");
|
||||
} catch {
|
||||
// 复制失败,显示安装命令模态框
|
||||
} else {
|
||||
setInstallCommand(res.data);
|
||||
setCurrentNodeName(node.name);
|
||||
setInstallCommandModal(true);
|
||||
@@ -1031,10 +853,7 @@ export default function NodePage() {
|
||||
|
||||
setNodeOrder(newOrder);
|
||||
|
||||
// 保存到 localStorage
|
||||
try {
|
||||
localStorage.setItem("node-order", JSON.stringify(newOrder));
|
||||
} catch {}
|
||||
saveOrder("node-order", newOrder);
|
||||
|
||||
// 持久化到数据库
|
||||
try {
|
||||
@@ -1241,42 +1060,12 @@ export default function NodePage() {
|
||||
|
||||
{/* 节点列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : nodeList.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M5 12l4-4m-4 4l4 4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
暂无节点配置
|
||||
</h3>
|
||||
<p className="text-default-500 text-sm mt-1">
|
||||
还没有创建任何节点配置,点击上方按钮开始创建
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<PageEmptyState
|
||||
className="h-64"
|
||||
message="暂无节点配置,点击上方按钮开始创建"
|
||||
/>
|
||||
) : (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
@@ -1315,6 +1104,7 @@ export default function NodePage() {
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1332,20 +1122,23 @@ export default function NodePage() {
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={
|
||||
node.connectionStatus === "online"
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{node.connectionStatus === "online"
|
||||
? "在线"
|
||||
: "离线"}
|
||||
</Chip>
|
||||
{(() => {
|
||||
const connectionStatusMeta =
|
||||
getConnectionStatusMeta(
|
||||
node.connectionStatus,
|
||||
);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={connectionStatusMeta.color}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{connectionStatusMeta.text}
|
||||
</Chip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -1353,13 +1146,7 @@ export default function NodePage() {
|
||||
<CardBody className="pt-0 pb-3">
|
||||
{isRemoteNode && node.syncError && (
|
||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||
{node.syncError === "provider_share_deleted"
|
||||
? "提供方已删除该分享"
|
||||
: node.syncError === "provider_share_disabled"
|
||||
? "提供方已禁用该分享"
|
||||
: node.syncError === "provider_share_expired"
|
||||
? "提供方分享已过期"
|
||||
: `远程同步失败: ${node.syncError}`}
|
||||
{getRemoteSyncErrorMessage(node.syncError)}
|
||||
</div>
|
||||
)}
|
||||
{/* 基础信息 */}
|
||||
@@ -1816,6 +1603,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1856,6 +1644,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1899,6 +1688,7 @@ export default function NodePage() {
|
||||
<div className="px-3 py-3 rounded-lg bg-white dark:bg-default-50 border border-default-200 dark:border-default-100/30 hover:border-primary-200 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Tabs, Tab } from "@heroui/tabs";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Tabs, Tab } from "@/shadcn-bridge/heroui/tabs";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "@heroui/modal";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import {
|
||||
@@ -131,7 +131,9 @@ export default function PanelSharingPage() {
|
||||
const res = await getPeerShareList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setShares(res.data || []);
|
||||
setShares(
|
||||
Array.isArray(res.data) ? (res.data as unknown as PeerShare[]) : [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "加载分享列表失败");
|
||||
}
|
||||
@@ -145,9 +147,9 @@ export default function PanelSharingPage() {
|
||||
const res = await getNodeList();
|
||||
|
||||
if (res.code === 0) {
|
||||
const localNodes: Node[] = (res.data || []).filter(
|
||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||
);
|
||||
const localNodes: Node[] = (
|
||||
Array.isArray(res.data) ? (res.data as Node[]) : []
|
||||
).filter((node: Node) => (node?.isRemote ?? 0) !== 1);
|
||||
|
||||
setNodes(localNodes);
|
||||
setShareForm((prev) => {
|
||||
@@ -172,7 +174,11 @@ export default function PanelSharingPage() {
|
||||
const res = await getPeerRemoteUsageList();
|
||||
|
||||
if (res.code === 0) {
|
||||
setRemoteUsageNodes(res.data || []);
|
||||
setRemoteUsageNodes(
|
||||
Array.isArray(res.data)
|
||||
? (res.data as unknown as RemoteUsageNode[])
|
||||
: [],
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "加载远程占用端口失败");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -8,8 +8,8 @@ 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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||
interface PasswordForm {
|
||||
newUsername: string;
|
||||
currentPassword: string;
|
||||
@@ -47,21 +48,8 @@ export default function ProfilePage() {
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
setUsername(name);
|
||||
setIsAdmin(adminFlag);
|
||||
setUsername(getSessionName() || "Admin");
|
||||
setIsAdmin(getAdminFlag());
|
||||
}, []);
|
||||
|
||||
// 管理员菜单项
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -87,11 +87,13 @@ export const SettingsPage = () => {
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="返回上一页"
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
variant="light"
|
||||
onClick={() => navigate(-1)}
|
||||
onPress={() => navigate(-1)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -136,7 +138,7 @@ export const SettingsPage = () => {
|
||||
onChange={(e) => setNewAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button color="primary" onClick={addPanelAddress}>
|
||||
<Button color="primary" onPress={addPanelAddress}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
@@ -182,7 +184,7 @@ export const SettingsPage = () => {
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => setCurrentPanel(panel.name)}
|
||||
onPress={() => setCurrentPanel(panel.name)}
|
||||
>
|
||||
设为当前
|
||||
</Button>
|
||||
@@ -191,7 +193,7 @@ export const SettingsPage = () => {
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => handleDeletePanelAddress(panel.name)}
|
||||
onPress={() => handleDeletePanelAddress(panel.name)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
|
||||
@@ -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<Tunnel[]>([]);
|
||||
@@ -134,18 +127,7 @@ export default function TunnelPage() {
|
||||
useState<DiagnosisResult | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState<TunnelForm>({
|
||||
name: "",
|
||||
type: 1,
|
||||
inNodeId: [],
|
||||
outNodeId: [],
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
});
|
||||
const [form, setForm] = useState<TunnelForm>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoadingState message="正在加载..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -902,7 +733,7 @@ export default function TunnelPage() {
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedTunnels.map((tunnel) => {
|
||||
const typeDisplay = getTypeDisplay(tunnel.type);
|
||||
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
||||
|
||||
return (
|
||||
<SortableItem key={tunnel.id} id={tunnel.id}>
|
||||
@@ -942,6 +773,7 @@ export default function TunnelPage() {
|
||||
title="拖拽排序"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -960,6 +792,7 @@ export default function TunnelPage() {
|
||||
{/* 入口节点 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-primary-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -977,6 +810,7 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 箭头 */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -993,6 +827,7 @@ export default function TunnelPage() {
|
||||
{/* 转发链 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-secondary-50 dark:bg-secondary-100/20 rounded border border-secondary-200 dark:border-secondary-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-secondary-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1013,6 +848,7 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 箭头 */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1029,6 +865,7 @@ export default function TunnelPage() {
|
||||
{/* 出口节点 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-success-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1058,7 +895,7 @@ export default function TunnelPage() {
|
||||
流量计算
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground mt-0.5">
|
||||
{getFlowDisplay(tunnel.flow)}
|
||||
{getTunnelFlowDisplay(tunnel.flow)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100/30 rounded">
|
||||
@@ -1091,6 +928,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1109,6 +947,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1131,6 +970,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1169,6 +1009,7 @@ export default function TunnelPage() {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1422,6 +1263,7 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1481,12 +1323,14 @@ export default function TunnelPage() {
|
||||
</span>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label={`删除第${groupIndex + 1}跳`}
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => removeChainNode(groupIndex)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1559,17 +1403,17 @@ export default function TunnelPage() {
|
||||
);
|
||||
|
||||
// 添加新节点
|
||||
addedIds.forEach((nodeId) =>
|
||||
addNodeToChain(groupIndex, nodeId),
|
||||
);
|
||||
addedIds.forEach((nodeId) => {
|
||||
addNodeToChain(groupIndex, nodeId);
|
||||
});
|
||||
|
||||
// 删除取消选择的节点
|
||||
removedIds.forEach((nodeId) =>
|
||||
removedIds.forEach((nodeId) => {
|
||||
removeNodeFromChain(
|
||||
groupIndex,
|
||||
nodeId,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
@@ -2193,7 +2037,7 @@ export default function TunnelPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
@@ -2366,7 +2210,7 @@ export default function TunnelPage() {
|
||||
</h3>
|
||||
</div>
|
||||
{results.map((result, index) => {
|
||||
const quality = getQualityDisplay(
|
||||
const quality = getDiagnosisQualityDisplay(
|
||||
result.averageTime,
|
||||
result.packetLoss,
|
||||
);
|
||||
@@ -2522,6 +2366,7 @@ export default function TunnelPage() {
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 bg-default-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-default-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||
import { Input } from "@heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@heroui/table";
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
} from "@heroui/modal";
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { RadioGroup, Radio } from "@heroui/radio";
|
||||
import { Checkbox } from "@heroui/checkbox";
|
||||
import { DatePicker } from "@heroui/date-picker";
|
||||
import { Spinner } from "@heroui/spinner";
|
||||
import { Progress } from "@heroui/progress";
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { RadioGroup, Radio } from "@/shadcn-bridge/heroui/radio";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
UserIcon,
|
||||
SettingsIcon,
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -228,9 +229,7 @@ export default function UserPage() {
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
const data = response.data || {};
|
||||
|
||||
setUsers(data || []);
|
||||
setUsers(Array.isArray(response.data) ? response.data : []);
|
||||
} else {
|
||||
toast.error(response.msg || "获取用户列表失败");
|
||||
}
|
||||
@@ -246,7 +245,7 @@ export default function UserPage() {
|
||||
const response = await getTunnelList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setTunnels(response.data || []);
|
||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -256,7 +255,15 @@ export default function UserPage() {
|
||||
const response = await getSpeedLimitList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setSpeedLimits(response.data || []);
|
||||
const speedLimitList = Array.isArray(response.data)
|
||||
? response.data.map((item) => ({
|
||||
...item,
|
||||
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
||||
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setSpeedLimits(speedLimitList);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -266,7 +273,7 @@ export default function UserPage() {
|
||||
const response = await getUserGroupList();
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserGroups(response.data || []);
|
||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -277,7 +284,7 @@ export default function UserPage() {
|
||||
const response = await getUserTunnelList({ userId });
|
||||
|
||||
if (response.code === 0) {
|
||||
setUserTunnels(response.data || []);
|
||||
setUserTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
} else {
|
||||
toast.error(response.msg || "获取隧道权限列表失败");
|
||||
}
|
||||
@@ -626,10 +633,11 @@ export default function UserPage() {
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索用户"
|
||||
className="min-h-10 w-10"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onClick={handleSearch}
|
||||
onPress={handleSearch}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -643,12 +651,7 @@ export default function UserPage() {
|
||||
|
||||
{/* 用户列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-default-600">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageLoadingState message="正在加载..." />
|
||||
) : users.length === 0 ? (
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="text-center py-16">
|
||||
@@ -799,6 +802,7 @@ export default function UserPage() {
|
||||
size="sm"
|
||||
startContent={
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1259,22 +1263,25 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="编辑隧道权限"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => handleEditTunnel(userTunnel)}
|
||||
onPress={() => handleEditTunnel(userTunnel)}
|
||||
>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="重置隧道流量"
|
||||
color="warning"
|
||||
size="sm"
|
||||
title="重置流量"
|
||||
variant="flat"
|
||||
onClick={() => handleResetTunnelFlow(userTunnel)}
|
||||
onPress={() => handleResetTunnelFlow(userTunnel)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1288,10 +1295,11 @@ export default function UserPage() {
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="删除隧道权限"
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onClick={() => handleRemoveTunnel(userTunnel)}
|
||||
onPress={() => handleRemoveTunnel(userTunnel)}
|
||||
>
|
||||
<DeleteIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1596,6 +1604,7 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1684,6 +1693,7 @@ export default function UserPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-warning-100 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-warning"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { NavigateOptions } from "react-router-dom";
|
||||
|
||||
import * as React from "react";
|
||||
import { HeroUIProvider } from "@heroui/system";
|
||||
import { HeroUIProvider } from "@/shadcn-bridge/heroui/system";
|
||||
import { useHref, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { I18nProvider } from "@react-aria/i18n";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
declare module "@react-types/shared" {
|
||||
interface RouterConfig {
|
||||
routerOptions: NavigateOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
|
||||
/* 移动端视口高度优化 */
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 现代浏览器使用 100dvh,避免地址栏收起/展开导致的布局跳动 */
|
||||
@supports (height: 100dvh) {
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
/* 解决移动端100vh问题 */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.h-screen {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端滚动优化 */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 安全区域变量与工具类(适配刘海/底部手势条) */
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
--background: #f6f7fb;
|
||||
--foreground: #111827;
|
||||
--border: #e5e7eb;
|
||||
--input: #d1d5db;
|
||||
--ring: #93c5fd;
|
||||
|
||||
--content1: #ffffff;
|
||||
--divider: #e5e7eb;
|
||||
|
||||
--default-50: #f9fafb;
|
||||
--default-100: #f3f4f6;
|
||||
--default-200: #e5e7eb;
|
||||
--default-300: #d1d5db;
|
||||
--default-400: #9ca3af;
|
||||
--default-500: #6b7280;
|
||||
--default-600: #4b5563;
|
||||
--default-700: #374151;
|
||||
--default-800: #1f2937;
|
||||
--default-900: #111827;
|
||||
|
||||
--primary-50: #eff6ff;
|
||||
--primary-100: #dbeafe;
|
||||
--primary-200: #bfdbfe;
|
||||
--primary-300: #93c5fd;
|
||||
--primary-400: #60a5fa;
|
||||
--primary-500: #3b82f6;
|
||||
--primary-600: #2563eb;
|
||||
--primary-700: #1d4ed8;
|
||||
--primary-800: #1e40af;
|
||||
--primary-900: #1e3a8a;
|
||||
|
||||
--secondary-50: #eef2ff;
|
||||
--secondary-100: #e0e7ff;
|
||||
--secondary-200: #c7d2fe;
|
||||
--secondary-300: #a5b4fc;
|
||||
--secondary-400: #818cf8;
|
||||
--secondary-500: #6366f1;
|
||||
--secondary-600: #4f46e5;
|
||||
--secondary-700: #4338ca;
|
||||
--secondary-800: #3730a3;
|
||||
--secondary-900: #312e81;
|
||||
|
||||
--danger-50: #fef2f2;
|
||||
--danger-100: #fee2e2;
|
||||
--danger-200: #fecaca;
|
||||
--danger-300: #fca5a5;
|
||||
--danger-400: #f87171;
|
||||
--danger-500: #ef4444;
|
||||
--danger-600: #dc2626;
|
||||
--danger-700: #b91c1c;
|
||||
--danger-800: #991b1b;
|
||||
--danger-900: #7f1d1d;
|
||||
|
||||
--success-50: #f0fdf4;
|
||||
--success-100: #dcfce7;
|
||||
--success-200: #bbf7d0;
|
||||
--success-300: #86efac;
|
||||
--success-400: #4ade80;
|
||||
--success-500: #22c55e;
|
||||
--success-600: #16a34a;
|
||||
--success-700: #15803d;
|
||||
--success-800: #166534;
|
||||
--success-900: #14532d;
|
||||
|
||||
--warning-50: #fffbeb;
|
||||
--warning-100: #fef3c7;
|
||||
--warning-200: #fde68a;
|
||||
--warning-300: #fcd34d;
|
||||
--warning-400: #fbbf24;
|
||||
--warning-500: #f59e0b;
|
||||
--warning-600: #d97706;
|
||||
--warning-700: #b45309;
|
||||
--warning-800: #92400e;
|
||||
--warning-900: #78350f;
|
||||
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #6366f1;
|
||||
--secondary-foreground: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"] {
|
||||
box-shadow: none;
|
||||
.dark {
|
||||
--background: #0b1020;
|
||||
--foreground: #f3f4f6;
|
||||
--border: #334155;
|
||||
--input: #475569;
|
||||
--ring: #60a5fa;
|
||||
|
||||
--content1: #111827;
|
||||
--divider: #334155;
|
||||
|
||||
--default-50: #0f172a;
|
||||
--default-100: #1e293b;
|
||||
--default-200: #334155;
|
||||
--default-300: #475569;
|
||||
--default-400: #64748b;
|
||||
--default-500: #94a3b8;
|
||||
--default-600: #cbd5e1;
|
||||
--default-700: #e2e8f0;
|
||||
--default-800: #f1f5f9;
|
||||
--default-900: #f8fafc;
|
||||
|
||||
--primary: #3b82f6;
|
||||
--secondary: #818cf8;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]),
|
||||
[data-slot="input-wrapper"][data-focus="true"]:not([data-invalid="true"]),
|
||||
[data-slot="input-wrapper"][data-focused="true"]:not([data-invalid="true"]),
|
||||
button[data-slot="trigger"][data-focus="true"]:not([data-invalid="true"]),
|
||||
button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
|
||||
border-color: var(--heroui-default-200, #e5e7eb) !important;
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[data-slot="input-wrapper"] input:focus,
|
||||
[data-slot="input-wrapper"] textarea:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
html,
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.safe-top {
|
||||
@@ -76,8 +133,46 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-tiny {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.h-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.h-screen {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 验证码弹层动画 */
|
||||
.captcha-modal-enter {
|
||||
animation: captchaModalIn 0.3s ease-out forwards;
|
||||
}
|
||||
@@ -87,13 +182,13 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 验证码弹层背景渐入 */
|
||||
.captcha-backdrop-enter {
|
||||
animation: backdropFadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
@@ -102,9 +197,8 @@ button[data-slot="trigger"][data-open="true"]:not([data-invalid="true"]) {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@config "../../tailwind.config.js"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { clearSession } from "@/utils/session";
|
||||
|
||||
/**
|
||||
* 安全退出登录函数
|
||||
* 清除登录相关数据,但保留用户偏好设置(如主题)
|
||||
*/
|
||||
export const safeLogout = () => {
|
||||
localStorage.clear();
|
||||
clearSession();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {heroui} from "@heroui/theme"
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
@@ -7,11 +5,100 @@ export default {
|
||||
'./src/layouts/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||
'./src/shadcn-bridge/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
border: "var(--border)",
|
||||
input: "var(--input)",
|
||||
ring: "var(--ring)",
|
||||
content1: "var(--content1)",
|
||||
divider: "var(--divider)",
|
||||
default: {
|
||||
50: "var(--default-50)",
|
||||
100: "var(--default-100)",
|
||||
200: "var(--default-200)",
|
||||
300: "var(--default-300)",
|
||||
400: "var(--default-400)",
|
||||
500: "var(--default-500)",
|
||||
600: "var(--default-600)",
|
||||
700: "var(--default-700)",
|
||||
800: "var(--default-800)",
|
||||
900: "var(--default-900)",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "var(--primary)",
|
||||
foreground: "var(--primary-foreground)",
|
||||
50: "var(--primary-50)",
|
||||
100: "var(--primary-100)",
|
||||
200: "var(--primary-200)",
|
||||
300: "var(--primary-300)",
|
||||
400: "var(--primary-400)",
|
||||
500: "var(--primary-500)",
|
||||
600: "var(--primary-600)",
|
||||
700: "var(--primary-700)",
|
||||
800: "var(--primary-800)",
|
||||
900: "var(--primary-900)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "var(--secondary)",
|
||||
foreground: "var(--secondary-foreground)",
|
||||
50: "var(--secondary-50)",
|
||||
100: "var(--secondary-100)",
|
||||
200: "var(--secondary-200)",
|
||||
300: "var(--secondary-300)",
|
||||
400: "var(--secondary-400)",
|
||||
500: "var(--secondary-500)",
|
||||
600: "var(--secondary-600)",
|
||||
700: "var(--secondary-700)",
|
||||
800: "var(--secondary-800)",
|
||||
900: "var(--secondary-900)",
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: "var(--danger)",
|
||||
50: "var(--danger-50)",
|
||||
100: "var(--danger-100)",
|
||||
200: "var(--danger-200)",
|
||||
300: "var(--danger-300)",
|
||||
400: "var(--danger-400)",
|
||||
500: "var(--danger-500)",
|
||||
600: "var(--danger-600)",
|
||||
700: "var(--danger-700)",
|
||||
800: "var(--danger-800)",
|
||||
900: "var(--danger-900)",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "var(--success)",
|
||||
50: "var(--success-50)",
|
||||
100: "var(--success-100)",
|
||||
200: "var(--success-200)",
|
||||
300: "var(--success-300)",
|
||||
400: "var(--success-400)",
|
||||
500: "var(--success-500)",
|
||||
600: "var(--success-600)",
|
||||
700: "var(--success-700)",
|
||||
800: "var(--success-800)",
|
||||
900: "var(--success-900)",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "var(--warning)",
|
||||
50: "var(--warning-50)",
|
||||
100: "var(--warning-100)",
|
||||
200: "var(--warning-200)",
|
||||
300: "var(--warning-300)",
|
||||
400: "var(--warning-400)",
|
||||
500: "var(--warning-500)",
|
||||
600: "var(--warning-600)",
|
||||
700: "var(--warning-700)",
|
||||
800: "var(--warning-800)",
|
||||
900: "var(--warning-900)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
darkMode: "class",
|
||||
plugins: [heroui()],
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -8,16 +8,12 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
Reference in New Issue
Block a user