diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts new file mode 100644 index 0000000..11d861f --- /dev/null +++ b/vite-frontend/src/api/error-message.ts @@ -0,0 +1,27 @@ +import axios from "axios"; + +interface ErrorPayload { + msg?: string; + message?: string; +} + +export const isUnauthorizedError = (error: unknown): boolean => { + return axios.isAxiosError(error) && error.response?.status === 401; +}; + +export const extractApiErrorMessage = ( + error: unknown, + fallback = "网络请求失败", +): string => { + if (axios.isAxiosError(error)) { + const payload = error.response?.data as ErrorPayload | undefined; + + return payload?.msg || payload?.message || error.message || fallback; + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return fallback; +}; diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts new file mode 100644 index 0000000..b4ae2e0 --- /dev/null +++ b/vite-frontend/src/api/types.ts @@ -0,0 +1,306 @@ +export interface NodeApiItem { + id: number; + name: string; + status: number; + inx?: number; + syncError?: string; + [key: string]: unknown; +} + +export interface UserApiItem { + id: number; + user: string; + name?: string; + status: number; + flow: number; + num: number; + expTime?: number; + flowResetTime?: number; + inFlow?: number; + outFlow?: number; + [key: string]: unknown; +} + +export interface UserListQuery { + current?: number; + size?: number; + keyword?: string; + [key: string]: unknown; +} + +export interface TunnelApiItem { + id: number; + name: string; + type: number; + status: number; + entryNodeId: number; + exitNodeId: number; + inx?: number; + [key: string]: unknown; +} + +export interface ForwardApiItem { + id: number; + name: string; + status: number; + tunnelName?: string; + inIp?: string; + inPort?: number; + remoteAddr?: string; + inFlow?: number; + outFlow?: number; + userId?: number; + tunnelId?: number; + inx?: number; + [key: string]: unknown; +} + +export interface UserTunnelApiItem { + id: number; + name: string; + tunnelId?: number; + tunnelName?: string; + inNodePortSta?: number; + inNodePortEnd?: number; + speedId?: number | null; + [key: string]: unknown; +} + +export interface UserTunnelPermissionApiItem { + id: number; + userId: number; + tunnelId: number; + tunnelName: string; + status: number; + flow: number; + num: number; + expTime: number; + flowResetTime: number; + speedId?: number | null; + speedLimitName?: string; + inFlow: number; + outFlow: number; + tunnelFlow?: number; + [key: string]: unknown; +} + +export interface StatisticsFlowApiItem { + id: number; + userId: number; + flow: number; + totalFlow: number; + time: string; + [key: string]: unknown; +} + +export interface SpeedLimitApiItem { + id: number; + name: string; + tunnelId: number; + speed: number; + status: number; + tunnelName: string; + createdTime: string; + updatedTime: string; + uploadSpeed?: number; + downloadSpeed?: number; + [key: string]: unknown; +} + +export interface TunnelGroupApiItem { + id: number; + name: string; + status: number; + tunnelIds: number[]; + tunnelNames: string[]; + createdTime: number; + [key: string]: unknown; +} + +export interface UserGroupApiItem { + id: number; + name: string; + status: number; + userIds: number[]; + userNames: string[]; + createdTime: number; + [key: string]: unknown; +} + +export interface GroupPermissionApiItem { + id: number; + userGroupId: number; + userGroupName: string; + tunnelGroupId: number; + tunnelGroupName: string; + createdTime: number; + [key: string]: unknown; +} + +export interface TunnelDiagnosisApiItem { + success: boolean; + description: string; + nodeName: string; + nodeId: string; + targetIp: string; + targetPort?: number; + message?: string; + averageTime?: number; + packetLoss?: number; + fromChainType?: number; + fromInx?: number; + toChainType?: number; + toInx?: number; + [key: string]: unknown; +} + +export interface TunnelDiagnosisApiData { + tunnelName: string; + tunnelType: string; + timestamp: number; + results: TunnelDiagnosisApiItem[]; +} + +export interface ForwardDiagnosisApiData { + forwardName: string; + timestamp: number; + results: TunnelDiagnosisApiItem[]; +} + +export interface NodeReleaseApiItem { + version: string; + name: string; + publishedAt: string; + prerelease: boolean; +} + +export interface UserPackageInfoApiData { + userInfo: { + flow: number; + inFlow: number; + outFlow: number; + num: number; + expTime?: string; + flowResetTime?: number; + [key: string]: unknown; + }; + tunnelPermissions: UserTunnelPermissionApiItem[]; + forwards: ForwardApiItem[]; + statisticsFlows: StatisticsFlowApiItem[]; + [key: string]: unknown; +} + +export interface BatchOperationResult { + successCount: number; + failCount: number; + [key: string]: unknown; +} + +export interface UserMutationPayload { + id?: number; + user?: string; + name?: string; + password?: string; + status?: number; + flow?: number; + num?: number; + expTime?: number | string; + flowResetTime?: number; + tunnelFlow?: number; +} + +export interface NodeMutationPayload { + id?: number | null; + name?: string; + status?: number; + inx?: number; + serverIp?: string; + serverIpV4?: string; + serverIpV6?: string; + port?: string; + tcpListenAddr?: string; + udpListenAddr?: string; + interfaceName?: string; + http?: number; + tls?: number; + socks?: number; +} + +export interface TunnelChainNodePayload { + nodeId: number; + protocol?: string; + strategy?: string; + chainType?: number; + inx?: number; +} + +export interface TunnelMutationPayload { + id?: number; + name?: string; + type?: number; + status?: number; + flow?: number; + trafficRatio?: number; + inIp?: string; + ipPreference?: string; + inNodeId?: TunnelChainNodePayload[]; + outNodeId?: TunnelChainNodePayload[]; + chainNodes?: TunnelChainNodePayload[][]; +} + +export interface UserTunnelAssignPayload { + userId?: number; + id?: number; + tunnelId?: number; + flow?: number; + num?: number; + expTime?: number; + flowResetTime?: number; + status?: number; + speedId?: number | null; + tunnels?: Array<{ tunnelId: number; speedId?: number | null }>; +} + +export interface UserTunnelListQuery { + userId?: number; + tunnelId?: number; + current?: number; + size?: number; +} + +export interface UserTunnelRemovePayload { + id?: number; + userId?: number; + tunnelId?: number; +} + +export interface ForwardMutationPayload { + id?: number; + name?: string; + status?: number; + tunnelId?: number | null; + inIp?: string; + inPort?: number | null; + remoteAddr?: string; + strategy?: string; +} + +export interface SpeedLimitMutationPayload { + id?: number; + name?: string; + speed?: number; + status?: number; + tunnelId?: number | null; + tunnelName?: string; +} + +export interface UpdatePasswordPayload { + currentPassword: string; + newPassword: string; + newUsername?: string; +} + +export interface BackupImportPayload { + types: string[]; + [key: string]: unknown; +} diff --git a/vite-frontend/src/components/page-state.tsx b/vite-frontend/src/components/page-state.tsx new file mode 100644 index 0000000..0d00dce --- /dev/null +++ b/vite-frontend/src/components/page-state.tsx @@ -0,0 +1,42 @@ +import { Spinner } from "@/shadcn-bridge/heroui/spinner"; + +interface BaseStateProps { + message: string; + className?: string; +} + +export const PageLoadingState = ({ + message, + className = "h-64", +}: BaseStateProps) => { + return ( +
+
+ + {message} +
+
+ ); +}; + +export const PageEmptyState = ({ + message, + className = "h-48", +}: BaseStateProps) => { + return ( +
+ {message} +
+ ); +}; + +export const PageErrorState = ({ + message, + className = "h-48", +}: BaseStateProps) => { + return ( +
+ {message} +
+ ); +}; diff --git a/vite-frontend/src/hooks/useH5Mode.ts b/vite-frontend/src/hooks/useH5Mode.ts new file mode 100644 index 0000000..72958ed --- /dev/null +++ b/vite-frontend/src/hooks/useH5Mode.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +import { DEFAULT_MOBILE_BREAKPOINT } from "@/hooks/useMobileBreakpoint"; + +const detectH5Mode = (): boolean => { + const isMobile = window.innerWidth <= DEFAULT_MOBILE_BREAKPOINT; + const isMobileBrowser = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); + const urlParams = new URLSearchParams(window.location.search); + const isH5Param = urlParams.get("h5") === "true"; + + return isMobile || isMobileBrowser || isH5Param; +}; + +export const useH5Mode = (): boolean => { + const [isH5, setIsH5] = useState(detectH5Mode); + + useEffect(() => { + const checkH5Mode = () => { + setIsH5(detectH5Mode()); + }; + + window.addEventListener("resize", checkH5Mode); + + return () => window.removeEventListener("resize", checkH5Mode); + }, []); + + return isH5; +}; diff --git a/vite-frontend/src/hooks/useMobileBreakpoint.ts b/vite-frontend/src/hooks/useMobileBreakpoint.ts new file mode 100644 index 0000000..eec34f4 --- /dev/null +++ b/vite-frontend/src/hooks/useMobileBreakpoint.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +export const DEFAULT_MOBILE_BREAKPOINT = 768; + +export const useMobileBreakpoint = ( + breakpoint = DEFAULT_MOBILE_BREAKPOINT, +): boolean => { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === "undefined") { + return false; + } + + return window.innerWidth < breakpoint; + }); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const onResize = () => { + setIsMobile(window.innerWidth < breakpoint); + }; + + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("resize", onResize); + }; + }, [breakpoint]); + + return isMobile; +}; diff --git a/vite-frontend/src/hooks/useScrollTopOnPathChange.ts b/vite-frontend/src/hooks/useScrollTopOnPathChange.ts new file mode 100644 index 0000000..9600189 --- /dev/null +++ b/vite-frontend/src/hooks/useScrollTopOnPathChange.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export const useScrollTopOnPathChange = (): void => { + const { pathname } = useLocation(); + + useEffect(() => { + if (!pathname) { + return; + } + + try { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + } catch { + window.scrollTo(0, 0); + } + + document.body.scrollTop = 0; + document.documentElement.scrollTop = 0; + }, [pathname]); +}; diff --git a/vite-frontend/src/hooks/useWebViewMode.ts b/vite-frontend/src/hooks/useWebViewMode.ts new file mode 100644 index 0000000..2d65fa5 --- /dev/null +++ b/vite-frontend/src/hooks/useWebViewMode.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +import { isWebViewFunc } from "@/utils/panel"; + +export const useWebViewMode = (): boolean => { + const [isWebView, setIsWebView] = useState(false); + + useEffect(() => { + setIsWebView(isWebViewFunc()); + }, []); + + return isWebView; +}; diff --git a/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx b/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx new file mode 100644 index 0000000..fa45614 --- /dev/null +++ b/vite-frontend/src/pages/dashboard/components/announcement-banner.tsx @@ -0,0 +1,46 @@ +import type { AnnouncementData } from "@/api"; + +import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; + +interface AnnouncementBannerProps { + announcement: AnnouncementData; +} + +export const AnnouncementBanner = ({ + announcement, +}: AnnouncementBannerProps) => { + if (!announcement.content) { + return null; + } + + return ( + + +
+
+ +
+
+

+ 公告 +

+

+ {announcement.content} +

+
+
+
+
+ ); +}; diff --git a/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx new file mode 100644 index 0000000..9916b69 --- /dev/null +++ b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx @@ -0,0 +1,130 @@ +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { PageEmptyState } from "@/components/page-state"; + +interface FlowChartPoint { + time: string; + flow: number; +} + +interface FlowChartCardProps { + statisticsFlowsCount: number; + chartData: FlowChartPoint[]; + formatFlow: (value: number, unit?: string) => string; +} + +export const FlowChartCard = ({ + statisticsFlowsCount, + chartData, + formatFlow, +}: FlowChartCardProps) => { + return ( + + +
+ +

+ 24小时流量统计 +

+
+
+ + {statisticsFlowsCount === 0 ? ( + + ) : ( +
+ + + + + { + const v = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(v)) return String(value); + if (v === 0) return "0"; + if (v < 1024) return `${v}B`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`; + if (v < 1024 * 1024 * 1024) + return `${(v / (1024 * 1024)).toFixed(1)}M`; + + return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`; + }} + tickLine={false} + /> + ; + label?: string | number; + }) => { + if (active && payload && payload.length) { + const firstValue = payload[0]?.value; + const numericValue = + typeof firstValue === "number" + ? firstValue + : Number(firstValue); + const flowValue = Number.isFinite(numericValue) + ? numericValue + : 0; + + return ( +
+

{`时间: ${label ?? ""}`}

+

{`流量: ${formatFlow(flowValue)}`}

+
+ ); + } + + return null; + }} + /> + +
+
+
+ )} +
+
+ ); +}; diff --git a/vite-frontend/src/pages/dashboard/components/metric-card.tsx b/vite-frontend/src/pages/dashboard/components/metric-card.tsx new file mode 100644 index 0000000..942ffb6 --- /dev/null +++ b/vite-frontend/src/pages/dashboard/components/metric-card.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; + +interface MetricCardProps { + title: string; + value: string | number; + iconClassName: string; + icon: ReactNode; + bottomContent?: ReactNode; +} + +export const MetricCard = ({ + title, + value, + iconClassName, + icon, + bottomContent, +}: MetricCardProps) => { + return ( + + +
+
+

+ {title} +

+
+ {icon} +
+
+

+ {value} +

+ {bottomContent} +
+
+
+ ); +}; diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts new file mode 100644 index 0000000..e58e427 --- /dev/null +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -0,0 +1,249 @@ +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; + +import { + getAnnouncement, + getUserPackageInfo, + type AnnouncementData, +} from "@/api"; +import type { ForwardApiItem } from "@/api/types"; +import { getAdminFlag } from "@/utils/session"; + +export interface DashboardUserInfo { + flow: number; + inFlow: number; + outFlow: number; + num: number; + expTime?: string | number; + flowResetTime?: number; +} + +export interface DashboardUserTunnel { + id: number; + tunnelId: number; + tunnelName: string; + flow: number; + inFlow: number; + outFlow: number; + num: number; + expTime?: number; + flowResetTime?: number; + tunnelFlow?: number; +} + +export interface DashboardForward { + id: number; + name: string; + tunnelId: number; + tunnelName: string; + inIp: string; + inPort: number; + remoteAddr: string; + inFlow: number; + outFlow: number; +} + +export interface DashboardStatisticsFlow { + id: number; + userId: number; + flow: number; + totalFlow: number; + time: string; +} + +interface DashboardDataState { + loading: boolean; + userInfo: DashboardUserInfo; + userTunnels: DashboardUserTunnel[]; + forwardList: DashboardForward[]; + statisticsFlows: DashboardStatisticsFlow[]; + isAdmin: boolean; + announcement: AnnouncementData | null; +} + +const checkExpirationNotifications = ( + userInfo: DashboardUserInfo, + tunnels: DashboardUserTunnel[], +) => { + const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`; + const lastNotified = localStorage.getItem("lastNotified"); + + if (lastNotified === notificationKey) { + return; + } + + let hasNotification = false; + + if (userInfo.expTime) { + const expDate = new Date(userInfo.expTime); + const now = new Date(); + + if (!isNaN(expDate.getTime()) && expDate > now) { + const diffTime = expDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7 && diffDays > 0) { + hasNotification = true; + if (diffDays === 1) { + toast("账户将于明天过期,请及时续费", { + icon: "⚠️", + duration: 6000, + style: { background: "#f59e0b", color: "#fff" }, + }); + } else { + toast(`账户将于${diffDays}天后过期,请及时续费`, { + icon: "⚠️", + duration: 6000, + style: { background: "#f59e0b", color: "#fff" }, + }); + } + } else if (diffDays <= 0) { + hasNotification = true; + toast("账户已过期,请立即续费", { + icon: "⚠️", + duration: 8000, + style: { background: "#ef4444", color: "#fff" }, + }); + } + } + } + + tunnels.forEach((tunnel) => { + if (!tunnel.expTime) { + return; + } + + const expDate = new Date(tunnel.expTime); + const now = new Date(); + + if (!isNaN(expDate.getTime()) && expDate > now) { + const diffTime = expDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays <= 7 && diffDays > 0) { + hasNotification = true; + if (diffDays === 1) { + toast(`隧道"${tunnel.tunnelName}"将于明天过期`, { + icon: "⚠️", + duration: 5000, + style: { background: "#f59e0b", color: "#fff" }, + }); + } else { + toast(`隧道"${tunnel.tunnelName}"将于${diffDays}天后过期`, { + icon: "⚠️", + duration: 5000, + style: { background: "#f59e0b", color: "#fff" }, + }); + } + } else if (diffDays <= 0) { + hasNotification = true; + toast(`隧道"${tunnel.tunnelName}"已过期`, { + icon: "⚠️", + duration: 6000, + style: { background: "#ef4444", color: "#fff" }, + }); + } + } + }); + + if (hasNotification) { + localStorage.setItem("lastNotified", notificationKey); + } +}; + +const normalizeForwards = (items: ForwardApiItem[]) => { + return (items || []).map((item) => ({ + ...item, + name: item.name || "", + tunnelId: item.tunnelId ?? 0, + tunnelName: item.tunnelName || "", + inIp: item.inIp || "", + inPort: item.inPort || 0, + remoteAddr: item.remoteAddr || "", + inFlow: item.inFlow || 0, + outFlow: item.outFlow || 0, + })); +}; + +const normalizeTunnelPermissions = (items: DashboardUserTunnel[]) => { + return (items || []).map((item) => ({ + ...item, + inFlow: item.inFlow ?? 0, + outFlow: item.outFlow ?? 0, + })); +}; + +export const useDashboardData = (): DashboardDataState => { + const [loading, setLoading] = useState(true); + const [userInfo, setUserInfo] = useState( + {} as DashboardUserInfo, + ); + const [userTunnels, setUserTunnels] = useState([]); + const [forwardList, setForwardList] = useState([]); + const [statisticsFlows, setStatisticsFlows] = useState([]); + const [isAdmin, setIsAdmin] = useState(false); + const [announcement, setAnnouncement] = useState(null); + + useEffect(() => { + const loadAnnouncement = async () => { + try { + const res = await getAnnouncement(); + + if (res.code === 0 && res.data && res.data.enabled === 1) { + setAnnouncement(res.data); + } + } catch (error) { + console.error("Failed to load announcement:", error); + } + }; + + const loadPackageData = async () => { + setLoading(true); + try { + const res = await getUserPackageInfo(); + + if (res.code === 0) { + const data = res.data; + const normalizedTunnelPermissions = normalizeTunnelPermissions( + data.tunnelPermissions || [], + ); + const normalizedForwards = normalizeForwards(data.forwards || []); + + setUserInfo(data.userInfo || ({} as DashboardUserInfo)); + setUserTunnels(normalizedTunnelPermissions); + setForwardList(normalizedForwards); + setStatisticsFlows(data.statisticsFlows || []); + + checkExpirationNotifications(data.userInfo, normalizedTunnelPermissions); + } else { + toast.error(res.msg || "获取套餐信息失败"); + } + } catch { + toast.error("获取套餐信息失败"); + } finally { + setLoading(false); + } + }; + + setLoading(true); + setUserInfo({} as DashboardUserInfo); + setUserTunnels([]); + setForwardList([]); + setStatisticsFlows([]); + setIsAdmin(getAdminFlag()); + + loadPackageData(); + loadAnnouncement(); + localStorage.setItem("e", "/dashboard"); + }, []); + + return { + loading, + userInfo, + userTunnels, + forwardList, + statisticsFlows, + isAdmin, + announcement, + }; +}; diff --git a/vite-frontend/src/pages/forward/address.ts b/vite-frontend/src/pages/forward/address.ts new file mode 100644 index 0000000..b557824 --- /dev/null +++ b/vite-frontend/src/pages/forward/address.ts @@ -0,0 +1,133 @@ +export interface ForwardAddressItem { + id: number; + address: string; + copying: boolean; +} + +export type ForwardAddressAction = + | { type: "none" } + | { type: "copy"; text: string; label: string } + | { type: "modal"; title: string; items: ForwardAddressItem[] }; + +const splitAddressEntries = (value: string): string[] => { + return value + .split(",") + .map((item) => item.trim()) + .filter((item) => item); +}; + +const formatAddressWithPort = (ip: string, port: number): string => { + if (ip.includes(":") && !ip.startsWith("[")) { + return `[${ip}]:${port}`; + } + + return `${ip}:${port}`; +}; + +export const formatInAddress = (ipString: string, port: number): string => { + if (!ipString) { + return ""; + } + + const items = splitAddressEntries(ipString); + + if (items.length === 0) { + return ""; + } + + const hasPort = /:\d+$/.test(items[0]); + + if (hasPort) { + if (items.length === 1) { + return items[0]; + } + + return `${items[0]} (+${items.length - 1}个)`; + } + + if (!port) { + return ""; + } + + if (items.length === 1) { + return formatAddressWithPort(items[0], port); + } + + return `${formatAddressWithPort(items[0], port)} (+${items.length - 1}个)`; +}; + +export const formatRemoteAddress = (addressString: string): string => { + if (!addressString) { + return ""; + } + + const addresses = splitAddressEntries(addressString); + + if (addresses.length === 0) { + return ""; + } + + if (addresses.length === 1) { + return addresses[0]; + } + + return `${addresses[0]} (+${addresses.length - 1})`; +}; + +export const hasMultipleAddresses = (addressString: string): boolean => { + if (!addressString) { + return false; + } + + return splitAddressEntries(addressString).length > 1; +}; + +export const resolveForwardAddressAction = ( + addressString: string, + port: number | null, + title: string, +): ForwardAddressAction => { + if (!addressString) { + return { type: "none" }; + } + + let addresses: string[]; + + if (port !== null) { + const items = splitAddressEntries(addressString); + + if (items.length <= 1) { + return { + type: "copy", + text: formatInAddress(addressString, port), + label: title, + }; + } + + const hasPort = /:\d+$/.test(items[0]); + + addresses = hasPort + ? items + : items.map((ip) => formatAddressWithPort(ip, port)); + } else { + addresses = splitAddressEntries(addressString); + + if (addresses.length <= 1) { + return { + type: "copy", + text: addressString, + label: title, + }; + } + } + + return { + type: "modal", + title: `${title} (${addresses.length}个)`, + items: addresses.map((address, index) => ({ + id: index, + address, + copying: false, + })), + }; +}; diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts new file mode 100644 index 0000000..66af192 --- /dev/null +++ b/vite-frontend/src/pages/forward/batch-actions.ts @@ -0,0 +1,178 @@ +import type { BatchOperationResult } from "@/api/types"; + +import { + batchChangeTunnel, + batchDeleteForwards, + batchPauseForwards, + batchRedeployForwards, + batchResumeForwards, +} from "@/api"; +import { extractApiErrorMessage } from "@/api/error-message"; + +export interface ForwardBatchActionOutcome { + toastVariant: "success" | "error"; + toastMessage: string; + shouldRefresh: boolean; + closeDeleteModal?: boolean; + closeChangeTunnelModal?: boolean; + resetTargetTunnel?: boolean; +} + +const normalizeBatchResult = (value: unknown): BatchOperationResult => { + const raw = (value ?? {}) as Partial; + + return { + successCount: Number(raw.successCount ?? 0), + failCount: Number(raw.failCount ?? 0), + }; +}; + +const buildBatchToast = ( + result: BatchOperationResult, + successText: string, +): Pick => { + if (result.failCount === 0) { + return { + toastVariant: "success", + toastMessage: successText, + }; + } + + return { + toastVariant: "error", + toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, + }; +}; + +export const executeForwardBatchDelete = async ( + ids: number[], +): Promise => { + try { + const response = await batchDeleteForwards(ids); + + if (response.code !== 0) { + return { + toastVariant: "error", + toastMessage: response.msg || "删除失败", + shouldRefresh: false, + }; + } + + const summary = normalizeBatchResult(response.data); + + return { + ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`), + shouldRefresh: true, + closeDeleteModal: true, + }; + } catch (error) { + return { + toastVariant: "error", + toastMessage: extractApiErrorMessage(error, "删除失败"), + shouldRefresh: false, + }; + } +}; + +export const executeForwardBatchToggleService = async ( + ids: number[], + enable: boolean, +): Promise => { + const fallback = enable ? "启用失败" : "停用失败"; + + try { + const response = enable + ? await batchResumeForwards(ids) + : await batchPauseForwards(ids); + + if (response.code !== 0) { + return { + toastVariant: "error", + toastMessage: response.msg || fallback, + shouldRefresh: false, + }; + } + + const summary = normalizeBatchResult(response.data); + + return { + ...buildBatchToast( + summary, + enable + ? `成功启用 ${summary.successCount} 项` + : `成功停用 ${summary.successCount} 项`, + ), + shouldRefresh: true, + }; + } catch (error) { + return { + toastVariant: "error", + toastMessage: extractApiErrorMessage(error, fallback), + shouldRefresh: false, + }; + } +}; + +export const executeForwardBatchRedeploy = async ( + ids: number[], +): Promise => { + try { + const response = await batchRedeployForwards(ids); + + if (response.code !== 0) { + return { + toastVariant: "error", + toastMessage: response.msg || "下发失败", + shouldRefresh: false, + }; + } + + const summary = normalizeBatchResult(response.data); + + return { + ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`), + shouldRefresh: true, + }; + } catch (error) { + return { + toastVariant: "error", + toastMessage: extractApiErrorMessage(error, "下发失败"), + shouldRefresh: false, + }; + } +}; + +export const executeForwardBatchChangeTunnel = async ( + ids: number[], + targetTunnelId: number, +): Promise => { + try { + const response = await batchChangeTunnel({ + forwardIds: ids, + targetTunnelId, + }); + + if (response.code !== 0) { + return { + toastVariant: "error", + toastMessage: response.msg || "隧道失败", + shouldRefresh: false, + }; + } + + const summary = normalizeBatchResult(response.data); + + return { + ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`), + shouldRefresh: true, + closeChangeTunnelModal: true, + resetTargetTunnel: true, + }; + } catch (error) { + return { + toastVariant: "error", + toastMessage: extractApiErrorMessage(error, "隧道失败"), + shouldRefresh: false, + }; + } +}; diff --git a/vite-frontend/src/pages/forward/diagnosis.ts b/vite-frontend/src/pages/forward/diagnosis.ts new file mode 100644 index 0000000..7ebe9ad --- /dev/null +++ b/vite-frontend/src/pages/forward/diagnosis.ts @@ -0,0 +1,88 @@ +export interface ForwardDiagnosisEntry { + success: boolean; + description: string; + nodeName: string; + nodeId: string; + targetIp: string; + targetPort?: number; + message?: string; + averageTime?: number; + packetLoss?: number; + fromChainType?: number; + fromInx?: number; + toChainType?: number; + toInx?: number; +} + +export interface ForwardDiagnosisResult { + forwardName: string; + timestamp: number; + results: ForwardDiagnosisEntry[]; +} + +export interface ForwardDiagnosisFallbackInput { + forwardName: string; + remoteAddr: string; + description: string; + message: string; +} + +const pickPrimaryTargetIp = (remoteAddr: string): string => { + return remoteAddr.split(",")[0] || "-"; +}; + +export const buildForwardDiagnosisFallbackResult = ({ + forwardName, + remoteAddr, + description, + message, +}: ForwardDiagnosisFallbackInput): ForwardDiagnosisResult => { + return { + forwardName, + timestamp: Date.now(), + results: [ + { + success: false, + description, + nodeName: "-", + nodeId: "-", + targetIp: pickPrimaryTargetIp(remoteAddr), + message, + }, + ], + }; +}; + +export const getForwardDiagnosisQualityDisplay = ( + averageTime?: number, + packetLoss?: number, +): { + text: string; + color: "success" | "primary" | "warning" | "danger"; +} | null => { + if (averageTime === undefined || packetLoss === undefined) { + return null; + } + + if (averageTime < 30 && packetLoss === 0) { + return { text: "🚀 优秀", color: "success" }; + } + + if (averageTime < 50 && packetLoss === 0) { + return { text: "✨ 很好", color: "success" }; + } + + if (averageTime < 100 && packetLoss < 1) { + return { text: "👍 良好", color: "primary" }; + } + + if (averageTime < 150 && packetLoss < 2) { + return { text: "😐 一般", color: "warning" }; + } + + if (averageTime < 200 && packetLoss < 5) { + return { text: "😟 较差", color: "warning" }; + } + + return { text: "😵 很差", color: "danger" }; +}; diff --git a/vite-frontend/src/pages/forward/order.ts b/vite-frontend/src/pages/forward/order.ts new file mode 100644 index 0000000..624460b --- /dev/null +++ b/vite-frontend/src/pages/forward/order.ts @@ -0,0 +1,47 @@ +import { loadStoredOrder } from "@/utils/order-storage"; + +export interface ForwardOrderItem { + id: number; + userId?: number; + inx?: number; +} + +export const FORWARD_ORDER_KEY = "forward-order"; + +export const getUserScopedForwards = ( + forwards: T[], + currentUserId: number | null, +): T[] => { + if (currentUserId === null) { + return forwards; + } + + return forwards.filter((item) => item.userId === currentUserId); +}; + +export const buildForwardOrder = ( + forwards: T[], + currentUserId: number | null, +): { order: number[]; fromDatabase: boolean } => { + const userForwards = getUserScopedForwards(forwards, currentUserId); + + const hasDbOrdering = userForwards.some( + (item) => item.inx !== undefined && item.inx !== 0, + ); + + if (hasDbOrdering) { + const dbOrder = [...userForwards] + .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) + .map((item) => item.id); + + return { order: dbOrder, fromDatabase: true }; + } + + return { + order: loadStoredOrder( + FORWARD_ORDER_KEY, + userForwards.map((item) => item.id), + ), + fromDatabase: false, + }; +}; diff --git a/vite-frontend/src/pages/node/display.ts b/vite-frontend/src/pages/node/display.ts new file mode 100644 index 0000000..e91a856 --- /dev/null +++ b/vite-frontend/src/pages/node/display.ts @@ -0,0 +1,25 @@ +export const getConnectionStatusMeta = ( + status: "online" | "offline", +): { color: "success" | "danger"; text: string } => { + if (status === "online") { + return { color: "success", text: "在线" }; + } + + return { color: "danger", text: "离线" }; +}; + +export const getRemoteSyncErrorMessage = (syncError: string): string => { + if (syncError === "provider_share_deleted") { + return "提供方已删除该分享"; + } + + if (syncError === "provider_share_disabled") { + return "提供方已禁用该分享"; + } + + if (syncError === "provider_share_expired") { + return "提供方分享已过期"; + } + + return `远程同步失败: ${syncError}`; +}; diff --git a/vite-frontend/src/pages/node/install-command.ts b/vite-frontend/src/pages/node/install-command.ts new file mode 100644 index 0000000..9a4e7c6 --- /dev/null +++ b/vite-frontend/src/pages/node/install-command.ts @@ -0,0 +1,11 @@ +export const tryCopyInstallCommand = async ( + command: string, +): Promise => { + try { + await navigator.clipboard.writeText(command); + + return true; + } catch { + return false; + } +}; diff --git a/vite-frontend/src/pages/node/system-info.ts b/vite-frontend/src/pages/node/system-info.ts new file mode 100644 index 0000000..4b5005f --- /dev/null +++ b/vite-frontend/src/pages/node/system-info.ts @@ -0,0 +1,86 @@ +export interface NodeSystemInfo { + cpuUsage: number; + memoryUsage: number; + uploadTraffic: number; + downloadTraffic: number; + uploadSpeed: number; + downloadSpeed: number; + uptime: number; +} + +type RawSystemInfo = Record; + +const toInteger = (value: string | number | undefined): number => { + return Number.parseInt(String(value ?? 0), 10) || 0; +}; + +const toFloat = (value: string | number | undefined): number => { + return Number.parseFloat(String(value ?? 0)) || 0; +}; + +const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => { + if (typeof messageData === "string") { + try { + const parsed = JSON.parse(messageData); + + if (parsed && typeof parsed === "object") { + return parsed as RawSystemInfo; + } + + return null; + } catch { + return null; + } + } + + if (messageData && typeof messageData === "object") { + return messageData as RawSystemInfo; + } + + return null; +}; + +export const buildNodeSystemInfo = ( + messageData: unknown, + previous: NodeSystemInfo | null | undefined, +): NodeSystemInfo | null => { + const raw = parseRawSystemInfo(messageData); + + if (!raw) { + return null; + } + + const uploadTraffic = toInteger(raw.bytes_transmitted); + const downloadTraffic = toInteger(raw.bytes_received); + const uptime = toInteger(raw.uptime); + + let uploadSpeed = 0; + let downloadSpeed = 0; + + if (previous && previous.uptime) { + const timeDiff = uptime - previous.uptime; + + if (timeDiff > 0 && timeDiff <= 10) { + const uploadDiff = uploadTraffic - previous.uploadTraffic; + const downloadDiff = downloadTraffic - previous.downloadTraffic; + + if (uploadTraffic >= previous.uploadTraffic && uploadDiff >= 0) { + uploadSpeed = uploadDiff / timeDiff; + } + + if (downloadTraffic >= previous.downloadTraffic && downloadDiff >= 0) { + downloadSpeed = downloadDiff / timeDiff; + } + } + } + + return { + cpuUsage: toFloat(raw.cpu_usage), + memoryUsage: toFloat(raw.memory_usage), + uploadTraffic, + downloadTraffic, + uploadSpeed, + downloadSpeed, + uptime, + }; +}; diff --git a/vite-frontend/src/pages/node/use-node-offline-timers.ts b/vite-frontend/src/pages/node/use-node-offline-timers.ts new file mode 100644 index 0000000..f1aa54e --- /dev/null +++ b/vite-frontend/src/pages/node/use-node-offline-timers.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useRef } from "react"; + +interface UseNodeOfflineTimersOptions { + delayMs?: number; + onNodeOffline: (nodeId: number) => void; +} + +export const useNodeOfflineTimers = ({ + delayMs = 3000, + onNodeOffline, +}: UseNodeOfflineTimersOptions) => { + const timersRef = useRef>>( + new Map(), + ); + + const clearOfflineTimer = useCallback((nodeId: number) => { + const timer = timersRef.current.get(nodeId); + + if (!timer) { + return; + } + + clearTimeout(timer); + timersRef.current.delete(nodeId); + }, []); + + const clearAllOfflineTimers = useCallback(() => { + timersRef.current.forEach((timer) => { + clearTimeout(timer); + }); + timersRef.current.clear(); + }, []); + + const scheduleNodeOffline = useCallback( + (nodeId: number) => { + if (timersRef.current.has(nodeId)) { + return; + } + + const timer = setTimeout(() => { + timersRef.current.delete(nodeId); + onNodeOffline(nodeId); + }, delayMs); + + timersRef.current.set(nodeId, timer); + }, + [delayMs, onNodeOffline], + ); + + useEffect(() => { + return () => { + clearAllOfflineTimers(); + }; + }, [clearAllOfflineTimers]); + + return { + clearOfflineTimer, + scheduleNodeOffline, + clearAllOfflineTimers, + }; +}; diff --git a/vite-frontend/src/pages/node/use-node-realtime.ts b/vite-frontend/src/pages/node/use-node-realtime.ts new file mode 100644 index 0000000..5ef1806 --- /dev/null +++ b/vite-frontend/src/pages/node/use-node-realtime.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import axios from "axios"; + +import { getToken } from "@/utils/session"; + +interface NodeRealtimeMessage { + id?: string | number; + type?: string; + data?: unknown; + message?: string; +} + +interface UseNodeRealtimeOptions { + onMessage: (message: NodeRealtimeMessage) => void; + enabled?: boolean; +} + +const getRealtimeWsUrl = (): string => { + const baseUrl = + axios.defaults.baseURL || + (import.meta.env.VITE_API_BASE + ? `${import.meta.env.VITE_API_BASE}/api/v1/` + : "/api/v1/"); + + return ( + baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") + + `/system-info?type=0&secret=${getToken() || ""}` + ); +}; + +export const useNodeRealtime = ({ + onMessage, + enabled = true, +}: UseNodeRealtimeOptions) => { + const [wsConnected, setWsConnected] = useState(false); + const [wsConnecting, setWsConnecting] = useState(false); + + const websocketRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const reconnectAttemptsRef = useRef(0); + const onMessageRef = useRef(onMessage); + + const maxReconnectAttempts = 5; + + useEffect(() => { + onMessageRef.current = onMessage; + }, [onMessage]); + + const clearReconnectTimer = useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const disconnect = useCallback(() => { + clearReconnectTimer(); + reconnectAttemptsRef.current = 0; + setWsConnected(false); + setWsConnecting(false); + + if (!websocketRef.current) { + return; + } + + websocketRef.current.onopen = null; + websocketRef.current.onmessage = null; + websocketRef.current.onerror = null; + websocketRef.current.onclose = null; + + if ( + websocketRef.current.readyState === WebSocket.OPEN || + websocketRef.current.readyState === WebSocket.CONNECTING + ) { + websocketRef.current.close(); + } + + websocketRef.current = null; + }, [clearReconnectTimer]); + + const connect = useCallback(() => { + if (!enabled) { + return; + } + + if ( + websocketRef.current && + (websocketRef.current.readyState === WebSocket.OPEN || + websocketRef.current.readyState === WebSocket.CONNECTING) + ) { + return; + } + + if (websocketRef.current) { + disconnect(); + } + + try { + setWsConnecting(true); + websocketRef.current = new WebSocket(getRealtimeWsUrl()); + + websocketRef.current.onopen = () => { + reconnectAttemptsRef.current = 0; + setWsConnected(true); + setWsConnecting(false); + }; + + websocketRef.current.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data); + + if (parsed && typeof parsed === "object") { + onMessageRef.current(parsed as NodeRealtimeMessage); + } + } catch {} + }; + + websocketRef.current.onerror = () => {}; + + websocketRef.current.onclose = () => { + websocketRef.current = null; + setWsConnected(false); + setWsConnecting(false); + + if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) { + return; + } + + reconnectAttemptsRef.current += 1; + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + connect(); + }, 3000 * reconnectAttemptsRef.current); + }; + } catch { + setWsConnected(false); + setWsConnecting(false); + } + }, [disconnect, enabled]); + + useEffect(() => { + if (!enabled) { + return; + } + + connect(); + + return () => { + disconnect(); + }; + }, [connect, disconnect, enabled]); + + return { + wsConnected, + wsConnecting, + reconnectRealtime: connect, + disconnectRealtime: disconnect, + }; +}; diff --git a/vite-frontend/src/pages/tunnel/diagnosis.ts b/vite-frontend/src/pages/tunnel/diagnosis.ts new file mode 100644 index 0000000..8d409b4 --- /dev/null +++ b/vite-frontend/src/pages/tunnel/diagnosis.ts @@ -0,0 +1,87 @@ +export interface DiagnosisEntry { + success: boolean; + description: string; + nodeName: string; + nodeId: string; + targetIp: string; + targetPort?: number; + message?: string; + averageTime?: number; + packetLoss?: number; + fromChainType?: number; + fromInx?: number; + toChainType?: number; + toInx?: number; +} + +export interface DiagnosisResult { + tunnelName: string; + tunnelType: string; + timestamp: number; + results: DiagnosisEntry[]; +} + +export interface DiagnosisFallbackInput { + tunnelName: string; + tunnelType: number; + description: string; + message: string; +} + +export const buildDiagnosisFallbackResult = ({ + tunnelName, + tunnelType, + description, + message, +}: DiagnosisFallbackInput): DiagnosisResult => { + return { + tunnelName, + tunnelType: tunnelType === 1 ? "端口转发" : "隧道转发", + timestamp: Date.now(), + results: [ + { + success: false, + description, + nodeName: "-", + nodeId: "-", + targetIp: "-", + targetPort: 443, + message, + }, + ], + }; +}; + +export const getDiagnosisQualityDisplay = ( + averageTime?: number, + packetLoss?: number, +): { + text: string; + color: "success" | "primary" | "warning" | "danger"; +} | null => { + if (averageTime === undefined || packetLoss === undefined) { + return null; + } + + if (averageTime < 30 && packetLoss === 0) { + return { text: "🚀 优秀", color: "success" }; + } + + if (averageTime < 50 && packetLoss === 0) { + return { text: "✨ 很好", color: "success" }; + } + + if (averageTime < 100 && packetLoss < 1) { + return { text: "👍 良好", color: "primary" }; + } + + if (averageTime < 150 && packetLoss < 2) { + return { text: "😐 一般", color: "warning" }; + } + + if (averageTime < 200 && packetLoss < 5) { + return { text: "😟 较差", color: "warning" }; + } + + return { text: "😵 很差", color: "danger" }; +}; diff --git a/vite-frontend/src/pages/tunnel/form.ts b/vite-frontend/src/pages/tunnel/form.ts new file mode 100644 index 0000000..97c0f69 --- /dev/null +++ b/vite-frontend/src/pages/tunnel/form.ts @@ -0,0 +1,110 @@ +interface TunnelChainNode { + nodeId: number; +} + +interface TunnelFormInput { + name: string; + type: number; + inNodeId: TunnelChainNode[]; + outNodeId?: TunnelChainNode[]; + trafficRatio: number; +} + +interface TunnelNodeInput { + id: number; + status: number; +} + +export const createTunnelFormDefaults = () => { + return { + name: "", + type: 1, + inNodeId: [], + outNodeId: [], + chainNodes: [], + flow: 1, + trafficRatio: 1.0, + inIp: "", + ipPreference: "", + status: 1, + }; +}; + +export const validateTunnelForm = ( + form: TunnelFormInput, + nodes: TunnelNodeInput[], +): Record => { + const errors: Record = {}; + + if (!form.name.trim()) { + errors.name = "请输入隧道名称"; + } else if (form.name.length < 2 || form.name.length > 50) { + errors.name = "隧道名称长度应在2-50个字符之间"; + } + + if (!form.inNodeId || form.inNodeId.length === 0) { + errors.inNodeId = "请至少选择一个入口节点"; + } else { + const offlineInNodes = form.inNodeId.filter((item) => { + const node = nodes.find((n) => n.id === item.nodeId); + + return node && node.status !== 1; + }); + + if (offlineInNodes.length > 0) { + errors.inNodeId = "所有入口节点必须在线"; + } + } + + if (form.trafficRatio <= 0 || form.trafficRatio > 100.0) { + errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)"; + } + + if (form.type === 2) { + if (!form.outNodeId || form.outNodeId.length === 0) { + errors.outNodeId = "请至少选择一个出口节点"; + } else { + const offlineOutNodes = form.outNodeId.filter((item) => { + const node = nodes.find((n) => n.id === item.nodeId); + + return node && node.status !== 1; + }); + + if (offlineOutNodes.length > 0) { + errors.outNodeId = "所有出口节点必须在线"; + } + + const inNodeIds = form.inNodeId.map((item) => item.nodeId); + const outNodeIds = form.outNodeId.map((item) => item.nodeId); + const overlap = inNodeIds.filter((id) => outNodeIds.includes(id)); + + if (overlap.length > 0) { + errors.outNodeId = "隧道转发模式下,入口和出口不能有相同节点"; + } + } + } + + return errors; +}; + +export const getTunnelTypeDisplay = (type: number) => { + switch (type) { + case 1: + return { text: "端口转发", color: "primary" }; + case 2: + return { text: "隧道转发", color: "secondary" }; + default: + return { text: "未知", color: "default" }; + } +}; + +export const getTunnelFlowDisplay = (flow: number) => { + switch (flow) { + case 1: + return "单向计算"; + case 2: + return "双向计算"; + default: + return "未知"; + } +}; diff --git a/vite-frontend/src/utils/order-storage.ts b/vite-frontend/src/utils/order-storage.ts new file mode 100644 index 0000000..e4e4295 --- /dev/null +++ b/vite-frontend/src/utils/order-storage.ts @@ -0,0 +1,57 @@ +const parseOrderIds = (rawValue: string | null): number[] | null => { + if (!rawValue) { + return null; + } + + try { + const parsed = JSON.parse(rawValue); + + if (!Array.isArray(parsed)) { + return null; + } + + return parsed + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id) && id >= 0); + } catch { + return null; + } +}; + +export const loadStoredOrder = ( + storageKey: string, + sourceIds: number[], +): number[] => { + const parsed = parseOrderIds(localStorage.getItem(storageKey)); + + if (!parsed || parsed.length === 0) { + return sourceIds; + } + + const idSet = new Set(sourceIds); + const validIds: number[] = []; + + parsed.forEach((id) => { + if (idSet.has(id) && !validIds.includes(id)) { + validIds.push(id); + } + }); + + if (validIds.length === 0) { + return sourceIds; + } + + sourceIds.forEach((id) => { + if (!validIds.includes(id)) { + validIds.push(id); + } + }); + + return validIds; +}; + +export const saveOrder = (storageKey: string, ids: number[]): void => { + try { + localStorage.setItem(storageKey, JSON.stringify(ids)); + } catch {} +}; diff --git a/vite-frontend/src/utils/session.ts b/vite-frontend/src/utils/session.ts new file mode 100644 index 0000000..6ca449c --- /dev/null +++ b/vite-frontend/src/utils/session.ts @@ -0,0 +1,90 @@ +export const SESSION_STORAGE_KEYS = { + token: "token", + roleId: "role_id", + name: "name", + admin: "admin", +} as const; + +export interface SessionData { + token: string | null; + roleId: number | null; + name: string | null; + isAdmin: boolean; +} + +export interface LoginSessionPayload { + token: string; + role_id: number; + name: string; +} + +const SESSION_EVENT_NAME = "sessionUpdated"; + +const parseRoleId = (value: string | null): number | null => { + if (value === null) { + return null; + } + + const roleId = Number.parseInt(value, 10); + + return Number.isNaN(roleId) ? null : roleId; +}; + +export const getToken = (): string | null => { + return localStorage.getItem(SESSION_STORAGE_KEYS.token); +}; + +export const getRoleId = (): number | null => { + return parseRoleId(localStorage.getItem(SESSION_STORAGE_KEYS.roleId)); +}; + +export const getSessionName = (): string | null => { + return localStorage.getItem(SESSION_STORAGE_KEYS.name); +}; + +export const getAdminFlag = (): boolean => { + const adminValue = localStorage.getItem(SESSION_STORAGE_KEYS.admin); + + if (adminValue !== null) { + return adminValue === "true"; + } + + const roleId = getRoleId(); + const isAdmin = roleId === 0; + + if (roleId !== null) { + localStorage.setItem(SESSION_STORAGE_KEYS.admin, String(isAdmin)); + } + + return isAdmin; +}; + +export const readSession = (): SessionData => { + return { + token: getToken(), + roleId: getRoleId(), + name: getSessionName(), + isAdmin: getAdminFlag(), + }; +}; + +export const writeLoginSession = (payload: LoginSessionPayload): void => { + localStorage.setItem(SESSION_STORAGE_KEYS.token, payload.token); + localStorage.setItem(SESSION_STORAGE_KEYS.roleId, String(payload.role_id)); + localStorage.setItem(SESSION_STORAGE_KEYS.name, payload.name); + localStorage.setItem( + SESSION_STORAGE_KEYS.admin, + String(payload.role_id === 0), + ); + window.dispatchEvent(new Event(SESSION_EVENT_NAME)); +}; + +export const clearSession = (): void => { + localStorage.removeItem(SESSION_STORAGE_KEYS.token); + localStorage.removeItem(SESSION_STORAGE_KEYS.roleId); + localStorage.removeItem(SESSION_STORAGE_KEYS.name); + localStorage.removeItem(SESSION_STORAGE_KEYS.admin); + window.dispatchEvent(new Event(SESSION_EVENT_NAME)); +}; + +export const SESSION_UPDATED_EVENT = SESSION_EVENT_NAME;