refactor(frontend): extract reusable hooks and page helper modules

This commit is contained in:
Sagit
2026-02-19 05:14:11 +00:00
parent 6d57b49595
commit c5124a01e6
24 changed files with 2072 additions and 0 deletions
@@ -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>
);
};