Skip to content

Commit a9d03be

Browse files
committed
feat: 新增双坐标统计图组件,优化横坐标显示。
1 parent 6b87303 commit a9d03be

3 files changed

Lines changed: 278 additions & 48 deletions

File tree

app/tunnels/details/page.tsx

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { FlowTrafficChart } from "@/components/ui/flow-traffic-chart";
4242
import { useSearchParams } from 'next/navigation';
4343
import { FileLogViewer } from "@/components/ui/file-log-viewer";
4444
import { useTunnelSSE } from "@/lib/hooks/use-sse";
45+
import { DualAxisChart } from "@/components/ui/dual-axis-chart";
4546

4647
interface TunnelInfo {
4748
id: string;
@@ -1117,7 +1118,7 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
11171118
<Card className="p-1 md:p-2 bg-cyan-50 dark:bg-cyan-950/30 shadow-none">
11181119
<CardBody className="p-1 md:p-2 lg:p-3 flex items-center justify-center">
11191120
<div className="text-center">
1120-
<p className="text-xs text-cyan-600 dark:text-cyan-400 mb-1">连接池</p>
1121+
<p className="text-xs text-cyan-600 dark:text-cyan-400 mb-1">池连接数</p>
11211122
<p className="text-xs md:text-sm lg:text-lg font-bold text-cyan-700 dark:text-cyan-300">
11221123
{tunnelInfo.traffic.pool}
11231124
</p>
@@ -1130,7 +1131,7 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
11301131
<Card className="p-1 md:p-2 bg-pink-50 dark:bg-pink-950/30 shadow-none">
11311132
<CardBody className="p-1 md:p-2 lg:p-3 flex items-center justify-center">
11321133
<div className="text-center">
1133-
<p className="text-xs text-pink-600 dark:text-pink-400 mb-1">延迟</p>
1134+
<p className="text-xs text-pink-600 dark:text-pink-400 mb-1">端内延迟</p>
11341135
<p className="text-xs md:text-sm lg:text-lg font-bold text-pink-700 dark:text-pink-300">
11351136
{tunnelInfo.traffic.ping}ms
11361137
</p>
@@ -1509,7 +1510,7 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
15091510
</Card>
15101511

15111512
{/* 连接池变化 - 独立Card - 只有当traffic.pool不为null时才显示 */}
1512-
{tunnelInfo.traffic.pool !== null && (
1513+
{false && (
15131514
<Card className="p-2">
15141515
<CardHeader className="flex items-center justify-between">
15151516
<div className="flex items-center gap-3">
@@ -1625,13 +1626,13 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
16251626
</Card>
16261627
)}
16271628

1628-
{/* 延迟变化 - 独立Card - 只有当traffic.ping不为null时才显示 */}
1629-
{tunnelInfo.traffic.ping !== null && (
1629+
{/* 延迟变化 - 独立Card - 只有当traffic.ping不为null时才显示 */}
1630+
{(tunnelInfo.traffic.ping !== null || tunnelInfo.traffic.pool !== null) && (
16301631
<Card className="p-2">
16311632
<CardHeader className="flex items-center justify-between">
16321633
<div className="flex items-center gap-3">
16331634
<div className="flex items-center gap-2">
1634-
<h3 className="text-lg font-semibold">延迟变化</h3>
1635+
<h3 className="text-lg font-semibold">连接质量</h3>
16351636
<Tooltip content="显示隧道连接的延迟变化趋势" placement="right">
16361637
<FontAwesomeIcon
16371638
icon={faQuestionCircle}
@@ -1646,8 +1647,8 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
16461647
size="sm"
16471648
variant="flat"
16481649
isIconOnly
1649-
onPress={fetchPingTrend}
1650-
isLoading={pingRefreshLoading}
1650+
onPress={() => { fetchPingTrend(); fetchPoolTrend(); }}
1651+
isLoading={pingRefreshLoading || poolRefreshLoading}
16511652
className="h-7 w-7 min-w-0"
16521653
>
16531654
<FontAwesomeIcon icon={faRefresh} className="text-xs" />
@@ -1706,35 +1707,39 @@ export default function TunnelDetailPage({ params }: { params: Promise<PageParam
17061707
</div>
17071708
</div>
17081709
) : (
1709-
<FlowTrafficChart
1710-
key={`ping-${pingTimeRange}-${pingTrend?.length || 0}`} // 强制重新渲染
1710+
<DualAxisChart
1711+
key={`dual-${pingTimeRange}-${pingTrend?.length || 0}-${poolTrend?.length || 0}`}
17111712
timeRange={pingTimeRange}
1712-
showLegend={false}
1713-
data={(() => {
1714-
// 安全检查
1715-
if (!pingTrend || !Array.isArray(pingTrend) || pingTrend.length === 0) {
1716-
return [];
1713+
showLegend={true}
1714+
leftUnit="个"
1715+
rightUnit="ms"
1716+
datasets={(() => {
1717+
const result: any[] = [];
1718+
1719+
if (poolTrend && Array.isArray(poolTrend) && poolTrend.length > 0) {
1720+
const filteredPool = filterPoolDataByTimeRange(poolTrend, pingTimeRange);
1721+
if (filteredPool.length > 0) {
1722+
result.push({
1723+
id: '池连接数',
1724+
axis: 'left',
1725+
data: filteredPool.map((item: PoolTrendData) => ({ x: item.eventTime || '', y: Number(item.pool) || 0 }))
1726+
});
1727+
}
17171728
}
1718-
1719-
// 首先根据时间范围过滤数据
1720-
const filteredData = filterPingDataByTimeRange(pingTrend, pingTimeRange);
1721-
1722-
if (filteredData.length === 0) return [];
1723-
1724-
const chartData = [
1725-
{
1726-
id: `延迟`,
1727-
data: filteredData.map((item: PingTrendData) => ({
1728-
x: item.eventTime || '', // 直接使用后端返回的格式 "2025-06-26 18:40"
1729-
y: Number(item.ping) || 0,
1730-
unit: 'ms'
1731-
}))
1729+
1730+
if (pingTrend && Array.isArray(pingTrend) && pingTrend.length > 0) {
1731+
const filteredPing = filterPingDataByTimeRange(pingTrend, pingTimeRange);
1732+
if (filteredPing.length > 0) {
1733+
result.push({
1734+
id: '端内延迟',
1735+
axis: 'right',
1736+
data: filteredPing.map((item: PingTrendData) => ({ x: item.eventTime || '', y: Number(item.ping) || 0 }))
1737+
});
17321738
}
1733-
];
1734-
1735-
return chartData;
1739+
}
1740+
1741+
return result;
17361742
})()}
1737-
unit="ms"
17381743
/>
17391744
)}
17401745
</div>

components/ui/dual-axis-chart.tsx

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"use client";
2+
3+
import React from 'react';
4+
5+
import {
6+
Chart as ChartJS,
7+
CategoryScale,
8+
LinearScale,
9+
PointElement,
10+
LineElement,
11+
Title,
12+
Tooltip,
13+
Legend,
14+
Filler,
15+
} from 'chart.js';
16+
import { Line } from 'react-chartjs-2';
17+
import { useTheme } from 'next-themes';
18+
19+
ChartJS.register(
20+
CategoryScale,
21+
LinearScale,
22+
PointElement,
23+
LineElement,
24+
Title,
25+
Tooltip,
26+
Legend,
27+
Filler,
28+
);
29+
30+
export type DualAxisDataset = {
31+
id: string; // 数据集名称
32+
axis: 'left' | 'right'; // 使用哪个y轴
33+
data: Array<{
34+
x: string;
35+
y: number;
36+
}>;
37+
};
38+
39+
interface DualAxisChartProps {
40+
datasets: DualAxisDataset[];
41+
leftUnit: string; // 左轴单位
42+
rightUnit: string; // 右轴单位
43+
height?: number;
44+
timeRange?: '1h' | '6h' | '12h' | '24h';
45+
showLegend?: boolean;
46+
}
47+
48+
// 双纵坐标折线图
49+
export const DualAxisChart: React.FC<DualAxisChartProps> = ({
50+
datasets,
51+
leftUnit,
52+
rightUnit,
53+
height = 300,
54+
timeRange = '24h',
55+
showLegend = true,
56+
}) => {
57+
const { theme } = useTheme();
58+
const isDark = theme === 'dark';
59+
60+
// 取第一条数据集的 labels 作为横坐标
61+
const labels = React.useMemo(() => {
62+
const first = datasets[0];
63+
return first ? first.data.map((p) => p.x) : [];
64+
}, [datasets]);
65+
66+
// 判断是否所有点在同一天
67+
const sameDay = React.useMemo(() => {
68+
if (!labels || labels.length === 0) return true;
69+
const firstLabel = labels[0];
70+
const dateMatch = firstLabel.match(/(\d{4}-\d{2}-\d{2})/);
71+
if (!dateMatch) return true;
72+
const targetDate = dateMatch[1];
73+
return labels.every((lbl) => lbl.startsWith(targetDate));
74+
}, [labels]);
75+
76+
const chartData = React.useMemo(() => {
77+
const colors = [
78+
{
79+
bg: isDark ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)',
80+
border: isDark ? 'rgb(59, 130, 246)' : 'rgb(59, 130, 246)',
81+
},
82+
{
83+
bg: isDark ? 'rgba(245, 101, 101, 0.1)' : 'rgba(245, 101, 101, 0.1)',
84+
border: isDark ? 'rgb(245, 101, 101)' : 'rgb(245, 101, 101)',
85+
},
86+
{
87+
bg: isDark ? 'rgba(16, 185, 129, 0.1)' : 'rgba(16, 185, 129, 0.1)',
88+
border: isDark ? 'rgb(16, 185, 129)' : 'rgb(16, 185, 129)',
89+
},
90+
{
91+
bg: isDark ? 'rgba(168, 85, 247, 0.1)' : 'rgba(168, 85, 247, 0.1)',
92+
border: isDark ? 'rgb(168, 85, 247)' : 'rgb(168, 85, 247)',
93+
},
94+
];
95+
96+
return {
97+
labels,
98+
datasets: datasets.map((series, index) => {
99+
const color = colors[index % colors.length];
100+
return {
101+
label: series.id,
102+
data: series.data.map((p) => p.y),
103+
borderColor: color.border,
104+
backgroundColor: color.bg,
105+
borderWidth: 3,
106+
fill: false,
107+
tension: 0.4,
108+
yAxisID: series.axis === 'right' ? 'yRight' : 'y',
109+
pointRadius: 0,
110+
pointHoverRadius: 6,
111+
pointHoverBackgroundColor: color.border,
112+
pointHoverBorderColor: isDark ? '#1f2937' : '#ffffff',
113+
pointHoverBorderWidth: 2,
114+
} as const;
115+
}),
116+
};
117+
}, [datasets, isDark, labels]);
118+
119+
const options = React.useMemo(() => {
120+
return {
121+
responsive: true,
122+
maintainAspectRatio: false,
123+
interaction: {
124+
mode: 'index' as const,
125+
intersect: false,
126+
},
127+
scales: {
128+
x: {
129+
grid: { display: false },
130+
ticks: {
131+
color: isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)',
132+
font: { size: 12 },
133+
maxRotation: 45,
134+
minRotation: 0,
135+
callback: (v: any, index: number) => {
136+
const lbl = labels[index];
137+
if (!lbl) return '';
138+
const m = lbl.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
139+
if (!m) return lbl;
140+
const [, , month, day, hh, mm] = m;
141+
if (sameDay) {
142+
return `${hh}:${mm}`;
143+
}
144+
return `${month}-${day} ${hh}:${mm}`;
145+
},
146+
},
147+
},
148+
y: {
149+
type: 'linear' as const,
150+
position: 'left' as const,
151+
grid: {
152+
color: isDark ? 'rgba(75,85,99,0.2)' : 'rgba(209,213,219,0.2)',
153+
drawBorder: false,
154+
},
155+
ticks: {
156+
color: isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)',
157+
font: { size: 12 },
158+
callback: (v: any) => `${v} ${leftUnit}`,
159+
},
160+
},
161+
yRight: {
162+
type: 'linear' as const,
163+
position: 'right' as const,
164+
grid: {
165+
drawOnChartArea: false, // 避免重复网格线
166+
},
167+
ticks: {
168+
color: isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)',
169+
font: { size: 12 },
170+
callback: (v: any) => `${v} ${rightUnit}`,
171+
},
172+
},
173+
},
174+
plugins: {
175+
legend: {
176+
display: showLegend,
177+
position: 'top' as const,
178+
align: 'end' as const,
179+
labels: {
180+
usePointStyle: true,
181+
pointStyle: 'circle',
182+
boxWidth: 8,
183+
boxHeight: 8,
184+
color: isDark ? 'rgb(209,213,219)' : 'rgb(75,85,99)',
185+
font: { size: 13, weight: 500 },
186+
padding: 20,
187+
},
188+
},
189+
tooltip: {
190+
enabled: true,
191+
backgroundColor: isDark ? 'rgba(17,24,39,0.95)' : 'rgba(255,255,255,0.95)',
192+
titleColor: isDark ? 'rgb(243,244,246)' : 'rgb(17,24,39)',
193+
bodyColor: isDark ? 'rgb(209,213,219)' : 'rgb(75,85,99)',
194+
borderColor: isDark ? 'rgba(75,85,99,0.3)' : 'rgba(209,213,219,0.3)',
195+
borderWidth: 1,
196+
cornerRadius: 8,
197+
displayColors: true,
198+
callbacks: {
199+
title: (ctx: any) => {
200+
if (ctx && ctx.length > 0) return labels[ctx[0].dataIndex] || '';
201+
return '';
202+
},
203+
label: (ctx: any) => {
204+
const axis = ctx.dataset.yAxisID === 'yRight' ? rightUnit : leftUnit;
205+
return `${ctx.dataset.label}: ${ctx.parsed.y} ${axis}`;
206+
},
207+
},
208+
},
209+
},
210+
} as const;
211+
}, [isDark, leftUnit, rightUnit, labels, showLegend, sameDay]);
212+
213+
return (
214+
<div style={{ height, width: '100%' }}>
215+
<Line data={chartData} options={options} />
216+
</div>
217+
);
218+
};

components/ui/flow-traffic-chart.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ export function FlowTrafficChart({ data, height = 300, unit = 'GB', timeRange =
9696
};
9797
}, [data, isDark]);
9898

99+
// 判断是否所有点在同一天
100+
const sameDay = React.useMemo(() => {
101+
if (!data || data.length === 0) return true;
102+
const labelsArr = data[0]?.data.map((p) => p.x) || [];
103+
if (labelsArr.length === 0) return true;
104+
const firstLabel = labelsArr[0];
105+
const match = firstLabel.match(/(\d{4}-\d{2}-\d{2})/);
106+
if (!match) return true;
107+
const target = match[1];
108+
return labelsArr.every((lbl) => lbl.startsWith(target));
109+
}, [data]);
110+
99111
const options = React.useMemo(() => ({
100112
responsive: true,
101113
maintainAspectRatio: false,
@@ -119,21 +131,16 @@ export function FlowTrafficChart({ data, height = 300, unit = 'GB', timeRange =
119131
maxTicksLimit: 1000, // 设置足够大的数字,实现全部显示
120132
maxRotation: 45, // 允许旋转45度
121133
minRotation: 0, // 优先水平显示
122-
callback: function(value: any, index: number, ticks: any[]) {
123-
const labels = chartData.labels;
124-
if (!labels || labels.length === 0) return '';
125-
126-
const label = labels[index];
127-
if (!label || typeof label !== 'string') return '';
128-
129-
// 解析时间格式提取小时:分钟部分
130-
const timeMatch = label.match(/(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})/);
131-
if (!timeMatch) return label;
132-
133-
const [, date, hour, minute] = timeMatch;
134-
135-
// 全部显示,让Chart.js自动处理旋转
136-
return `${hour}:${minute}`;
134+
callback: function(value: any, index: number) {
135+
const labelsArr: any = chartData.labels;
136+
if (!labelsArr || labelsArr.length === 0) return '';
137+
const label = labelsArr[index] as string;
138+
if (!label) return '';
139+
const m = label.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
140+
if (!m) return label;
141+
const [, , month, day, hh, mm] = m;
142+
if (sameDay) return `${hh}:${mm}`;
143+
return `${month}-${day} ${hh}:${mm}`;
137144
},
138145
},
139146
border: {

0 commit comments

Comments
 (0)