mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 17:46:37 +08:00
refactor(frontend): extract reusable hooks and page helper modules
This commit is contained in:
@@ -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 (
|
||||
<Card className="mb-4 lg:mb-6 border border-blue-200 dark:border-blue-500/30 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-500/10 dark:to-purple-500/10">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex-shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-blue-600 dark:text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm lg:text-base font-semibold text-blue-900 dark:text-blue-100 mb-1">
|
||||
公告
|
||||
</h3>
|
||||
<p className="text-xs lg:text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
|
||||
{announcement.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Card className="mb-6 lg:mb-8 border border-gray-200 dark:border-default-200 shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-5 h-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
</svg>
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-foreground">
|
||||
24小时流量统计
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
{statisticsFlowsCount === 0 ? (
|
||||
<PageEmptyState className="h-48" message="暂无流量统计数据" />
|
||||
) : (
|
||||
<div className="h-64 lg:h-80 w-full">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid className="opacity-30" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={{ stroke: "#e5e7eb", strokeWidth: 1 }}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: number | string) => {
|
||||
const v = typeof value === "number" ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(v)) return String(value);
|
||||
if (v === 0) return "0";
|
||||
if (v < 1024) return `${v}B`;
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}K`;
|
||||
if (v < 1024 * 1024 * 1024)
|
||||
return `${(v / (1024 * 1024)).toFixed(1)}M`;
|
||||
|
||||
return `${(v / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
||||
}}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value?: number | string }>;
|
||||
label?: string | number;
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const firstValue = payload[0]?.value;
|
||||
const numericValue =
|
||||
typeof firstValue === "number"
|
||||
? firstValue
|
||||
: Number(firstValue);
|
||||
const flowValue = Number.isFinite(numericValue)
|
||||
? numericValue
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-default-100 border border-default-200 rounded-lg shadow-lg p-3">
|
||||
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
|
||||
<p className="text-primary">{`流量: ${formatFlow(flowValue)}`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
activeDot={{
|
||||
r: 4,
|
||||
stroke: "#8b5cf6",
|
||||
strokeWidth: 2,
|
||||
fill: "#fff",
|
||||
}}
|
||||
dataKey="flow"
|
||||
dot={false}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={3}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Card className="border border-gray-200 dark:border-default-200 shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardBody className="p-3 lg:p-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs lg:text-sm text-default-600 truncate">
|
||||
{title}
|
||||
</p>
|
||||
<div
|
||||
className={`p-1.5 lg:p-2 rounded-lg flex-shrink-0 ${iconClassName}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-xl font-bold text-foreground truncate">
|
||||
{value}
|
||||
</p>
|
||||
{bottomContent}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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<DashboardUserInfo>(
|
||||
{} as DashboardUserInfo,
|
||||
);
|
||||
const [userTunnels, setUserTunnels] = useState<DashboardUserTunnel[]>([]);
|
||||
const [forwardList, setForwardList] = useState<DashboardForward[]>([]);
|
||||
const [statisticsFlows, setStatisticsFlows] = useState<DashboardStatisticsFlow[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData | null>(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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user