From c5124a01e671b1e13417ab6b0762e99dd2a58dce Mon Sep 17 00:00:00 2001 From: Sagit Date: Thu, 19 Feb 2026 05:14:11 +0000 Subject: [PATCH 1/6] refactor(frontend): extract reusable hooks and page helper modules --- vite-frontend/src/api/error-message.ts | 27 ++ vite-frontend/src/api/types.ts | 306 ++++++++++++++++++ vite-frontend/src/components/page-state.tsx | 42 +++ vite-frontend/src/hooks/useH5Mode.ts | 31 ++ .../src/hooks/useMobileBreakpoint.ts | 33 ++ .../src/hooks/useScrollTopOnPathChange.ts | 21 ++ vite-frontend/src/hooks/useWebViewMode.ts | 13 + .../components/announcement-banner.tsx | 46 +++ .../dashboard/components/flow-chart-card.tsx | 130 ++++++++ .../dashboard/components/metric-card.tsx | 42 +++ .../src/pages/dashboard/use-dashboard-data.ts | 249 ++++++++++++++ vite-frontend/src/pages/forward/address.ts | 133 ++++++++ .../src/pages/forward/batch-actions.ts | 178 ++++++++++ vite-frontend/src/pages/forward/diagnosis.ts | 88 +++++ vite-frontend/src/pages/forward/order.ts | 47 +++ vite-frontend/src/pages/node/display.ts | 25 ++ .../src/pages/node/install-command.ts | 11 + vite-frontend/src/pages/node/system-info.ts | 86 +++++ .../src/pages/node/use-node-offline-timers.ts | 61 ++++ .../src/pages/node/use-node-realtime.ts | 159 +++++++++ vite-frontend/src/pages/tunnel/diagnosis.ts | 87 +++++ vite-frontend/src/pages/tunnel/form.ts | 110 +++++++ vite-frontend/src/utils/order-storage.ts | 57 ++++ vite-frontend/src/utils/session.ts | 90 ++++++ 24 files changed, 2072 insertions(+) create mode 100644 vite-frontend/src/api/error-message.ts create mode 100644 vite-frontend/src/api/types.ts create mode 100644 vite-frontend/src/components/page-state.tsx create mode 100644 vite-frontend/src/hooks/useH5Mode.ts create mode 100644 vite-frontend/src/hooks/useMobileBreakpoint.ts create mode 100644 vite-frontend/src/hooks/useScrollTopOnPathChange.ts create mode 100644 vite-frontend/src/hooks/useWebViewMode.ts create mode 100644 vite-frontend/src/pages/dashboard/components/announcement-banner.tsx create mode 100644 vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx create mode 100644 vite-frontend/src/pages/dashboard/components/metric-card.tsx create mode 100644 vite-frontend/src/pages/dashboard/use-dashboard-data.ts create mode 100644 vite-frontend/src/pages/forward/address.ts create mode 100644 vite-frontend/src/pages/forward/batch-actions.ts create mode 100644 vite-frontend/src/pages/forward/diagnosis.ts create mode 100644 vite-frontend/src/pages/forward/order.ts create mode 100644 vite-frontend/src/pages/node/display.ts create mode 100644 vite-frontend/src/pages/node/install-command.ts create mode 100644 vite-frontend/src/pages/node/system-info.ts create mode 100644 vite-frontend/src/pages/node/use-node-offline-timers.ts create mode 100644 vite-frontend/src/pages/node/use-node-realtime.ts create mode 100644 vite-frontend/src/pages/tunnel/diagnosis.ts create mode 100644 vite-frontend/src/pages/tunnel/form.ts create mode 100644 vite-frontend/src/utils/order-storage.ts create mode 100644 vite-frontend/src/utils/session.ts 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; From 12c50df6a71016e2c17549f1d87b880df8991a93 Mon Sep 17 00:00:00 2001 From: Sagit Date: Thu, 19 Feb 2026 05:14:42 +0000 Subject: [PATCH 2/6] feat(frontend): add shadcn ui primitives and compatibility bridge --- vite-frontend/components.json | 21 ++ vite-frontend/src/components/ui/accordion.tsx | 59 +++++ vite-frontend/src/components/ui/alert.tsx | 54 +++++ vite-frontend/src/components/ui/badge.tsx | 39 +++ vite-frontend/src/components/ui/button.tsx | 52 ++++ vite-frontend/src/components/ui/card.tsx | 64 +++++ vite-frontend/src/components/ui/checkbox.tsx | 27 +++ vite-frontend/src/components/ui/dialog.tsx | 123 ++++++++++ .../src/components/ui/dropdown-menu.tsx | 220 +++++++++++++++++ vite-frontend/src/components/ui/input.tsx | 19 ++ vite-frontend/src/components/ui/label.tsx | 19 ++ vite-frontend/src/components/ui/progress.tsx | 30 +++ .../src/components/ui/radio-group.tsx | 37 +++ vite-frontend/src/components/ui/select.tsx | 162 +++++++++++++ vite-frontend/src/components/ui/separator.tsx | 27 +++ vite-frontend/src/components/ui/switch.tsx | 26 ++ vite-frontend/src/components/ui/table.tsx | 91 +++++++ vite-frontend/src/components/ui/tabs.tsx | 48 ++++ vite-frontend/src/components/ui/textarea.tsx | 18 ++ vite-frontend/src/lib/utils.ts | 6 + .../src/shadcn-bridge/heroui/accordion.tsx | 47 ++++ .../src/shadcn-bridge/heroui/alert.tsx | 52 ++++ .../src/shadcn-bridge/heroui/button.tsx | 105 +++++++++ .../src/shadcn-bridge/heroui/card.tsx | 20 ++ .../src/shadcn-bridge/heroui/checkbox.tsx | 43 ++++ .../src/shadcn-bridge/heroui/chip.tsx | 56 +++++ .../src/shadcn-bridge/heroui/date-picker.tsx | 90 +++++++ .../src/shadcn-bridge/heroui/divider.tsx | 7 + .../src/shadcn-bridge/heroui/dropdown.tsx | 81 +++++++ .../src/shadcn-bridge/heroui/input.tsx | 157 ++++++++++++ .../src/shadcn-bridge/heroui/link.tsx | 24 ++ .../src/shadcn-bridge/heroui/modal.tsx | 161 +++++++++++++ .../src/shadcn-bridge/heroui/navbar.tsx | 90 +++++++ .../src/shadcn-bridge/heroui/progress.tsx | 57 +++++ .../src/shadcn-bridge/heroui/radio.tsx | 63 +++++ .../src/shadcn-bridge/heroui/select.tsx | 223 ++++++++++++++++++ .../src/shadcn-bridge/heroui/shared.tsx | 62 +++++ .../src/shadcn-bridge/heroui/spinner.tsx | 31 +++ .../src/shadcn-bridge/heroui/switch.tsx | 31 +++ .../src/shadcn-bridge/heroui/system.tsx | 11 + .../src/shadcn-bridge/heroui/table.tsx | 140 +++++++++++ .../src/shadcn-bridge/heroui/tabs.tsx | 87 +++++++ .../src/shadcn-bridge/heroui/use-theme.tsx | 59 +++++ 43 files changed, 2839 insertions(+) create mode 100644 vite-frontend/components.json create mode 100644 vite-frontend/src/components/ui/accordion.tsx create mode 100644 vite-frontend/src/components/ui/alert.tsx create mode 100644 vite-frontend/src/components/ui/badge.tsx create mode 100644 vite-frontend/src/components/ui/button.tsx create mode 100644 vite-frontend/src/components/ui/card.tsx create mode 100644 vite-frontend/src/components/ui/checkbox.tsx create mode 100644 vite-frontend/src/components/ui/dialog.tsx create mode 100644 vite-frontend/src/components/ui/dropdown-menu.tsx create mode 100644 vite-frontend/src/components/ui/input.tsx create mode 100644 vite-frontend/src/components/ui/label.tsx create mode 100644 vite-frontend/src/components/ui/progress.tsx create mode 100644 vite-frontend/src/components/ui/radio-group.tsx create mode 100644 vite-frontend/src/components/ui/select.tsx create mode 100644 vite-frontend/src/components/ui/separator.tsx create mode 100644 vite-frontend/src/components/ui/switch.tsx create mode 100644 vite-frontend/src/components/ui/table.tsx create mode 100644 vite-frontend/src/components/ui/tabs.tsx create mode 100644 vite-frontend/src/components/ui/textarea.tsx create mode 100644 vite-frontend/src/lib/utils.ts create mode 100644 vite-frontend/src/shadcn-bridge/heroui/accordion.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/alert.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/button.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/card.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/checkbox.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/chip.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/date-picker.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/divider.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/dropdown.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/input.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/link.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/modal.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/navbar.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/progress.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/radio.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/select.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/shared.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/spinner.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/switch.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/system.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/table.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/tabs.tsx create mode 100644 vite-frontend/src/shadcn-bridge/heroui/use-theme.tsx diff --git a/vite-frontend/components.json b/vite-frontend/components.json new file mode 100644 index 0000000..3154a3d --- /dev/null +++ b/vite-frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/vite-frontend/src/components/ui/accordion.tsx b/vite-frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..eba1eae --- /dev/null +++ b/vite-frontend/src/components/ui/accordion.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Accordion({ ...props }: React.ComponentProps) { + return ; +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + data-slot="accordion-trigger" + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/vite-frontend/src/components/ui/alert.tsx b/vite-frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..96ed73d --- /dev/null +++ b/vite-frontend/src/components/ui/alert.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm", + { + variants: { + variant: { + default: "border-default-200 bg-default-50/70 text-foreground", + destructive: "border-danger-200 bg-danger-50 text-danger-700 dark:text-danger-300", + success: "border-success-200 bg-success-50 text-success-700 dark:text-success-300", + warning: "border-warning-200 bg-warning-50 text-warning-700 dark:text-warning-300", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) { + return ( +
+ ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertDescription, AlertTitle }; diff --git a/vite-frontend/src/components/ui/badge.tsx b/vite-frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..b13a087 --- /dev/null +++ b/vite-frontend/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + destructive: "border-transparent bg-danger text-white", + outline: "text-foreground", + success: "border-transparent bg-success text-white", + warning: "border-transparent bg-warning text-white", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/vite-frontend/src/components/ui/button.tsx b/vite-frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..6c22b2f --- /dev/null +++ b/vite-frontend/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-danger text-white hover:bg-danger/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + light: "bg-transparent hover:bg-default-100 dark:hover:bg-default-200/40", + flat: "bg-default-100 text-foreground hover:bg-default-200 dark:bg-default-100/40", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonProps = React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }; + +function Button({ asChild = false, className, size, variant, ...props }: ButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/vite-frontend/src/components/ui/card.tsx b/vite-frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..7d031ab --- /dev/null +++ b/vite-frontend/src/components/ui/card.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h3">) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/vite-frontend/src/components/ui/checkbox.tsx b/vite-frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..7a48c1c --- /dev/null +++ b/vite-frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..13c9357 --- /dev/null +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/vite-frontend/src/components/ui/dropdown-menu.tsx b/vite-frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..5ac423c --- /dev/null +++ b/vite-frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,220 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 6, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/vite-frontend/src/components/ui/input.tsx b/vite-frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..d238a6d --- /dev/null +++ b/vite-frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/vite-frontend/src/components/ui/label.tsx b/vite-frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..9391551 --- /dev/null +++ b/vite-frontend/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/vite-frontend/src/components/ui/progress.tsx b/vite-frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..95e9898 --- /dev/null +++ b/vite-frontend/src/components/ui/progress.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/lib/utils"; + +function Progress({ + className, + value, + indicatorClassName, + ...props +}: React.ComponentProps & { + indicatorClassName?: string; +}) { + return ( + + + + ); +} + +export { Progress }; diff --git a/vite-frontend/src/components/ui/radio-group.tsx b/vite-frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..34ad09c --- /dev/null +++ b/vite-frontend/src/components/ui/radio-group.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function RadioGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/vite-frontend/src/components/ui/select.tsx b/vite-frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..9a28ac6 --- /dev/null +++ b/vite-frontend/src/components/ui/select.tsx @@ -0,0 +1,162 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children} + + + + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/vite-frontend/src/components/ui/separator.tsx b/vite-frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..0dd4c82 --- /dev/null +++ b/vite-frontend/src/components/ui/separator.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + decorative = true, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/vite-frontend/src/components/ui/switch.tsx b/vite-frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..c75163e --- /dev/null +++ b/vite-frontend/src/components/ui/switch.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SwitchPrimitive from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils"; + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Switch }; diff --git a/vite-frontend/src/components/ui/table.tsx b/vite-frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..3ac5fa2 --- /dev/null +++ b/vite-frontend/src/components/ui/table.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ); +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ); +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + data-slot="table-footer" + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/vite-frontend/src/components/ui/tabs.tsx b/vite-frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..e57c653 --- /dev/null +++ b/vite-frontend/src/components/ui/tabs.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsContent, TabsList, TabsTrigger }; diff --git a/vite-frontend/src/components/ui/textarea.tsx b/vite-frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..cd7d5d4 --- /dev/null +++ b/vite-frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +