优化ui
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useId, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
type NodeHealth = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
enabled: number;
|
||||
last_error_message: string | null;
|
||||
};
|
||||
|
||||
type TrafficTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
auth_id: string;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type TrafficUserOption = {
|
||||
auth_id: string;
|
||||
username: string;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type LoadTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
online_users: number;
|
||||
stream_count: number;
|
||||
};
|
||||
|
||||
type FailureTrendRow = {
|
||||
bucket_start: string;
|
||||
node_id: number;
|
||||
failed_count: number;
|
||||
};
|
||||
|
||||
type TopConsumerRow = {
|
||||
node_id: number;
|
||||
auth_id: string;
|
||||
username: string;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
total_bytes: number;
|
||||
};
|
||||
|
||||
type DashboardChartsProps = {
|
||||
nodeHealth: NodeHealth[];
|
||||
trafficTrend: TrafficTrendRow[];
|
||||
trafficBuckets: string[];
|
||||
trafficUserOptions: TrafficUserOption[];
|
||||
loadTrend: LoadTrendRow[];
|
||||
failureTrend: FailureTrendRow[];
|
||||
topConsumerRows: TopConsumerRow[];
|
||||
};
|
||||
|
||||
type TrafficPoint = {
|
||||
bucketStart: string;
|
||||
label: string;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
const nodePalette = [
|
||||
"#c96233",
|
||||
"#2b6c80",
|
||||
"#5d7b63",
|
||||
"#7a3116",
|
||||
"#6f5aa8",
|
||||
"#9c6b12",
|
||||
"#2f5d52",
|
||||
"#8a3f55",
|
||||
];
|
||||
|
||||
const axisTick = {
|
||||
fill: "#3f4b57",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const tooltipStyle = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(24, 34, 44, 0.12)",
|
||||
backgroundColor: "rgba(255, 251, 245, 0.96)",
|
||||
boxShadow: "0 12px 34px rgba(51, 35, 18, 0.1)",
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = units[0];
|
||||
|
||||
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||
value /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatCompactBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = units[0];
|
||||
|
||||
for (let index = 1; index < units.length && value >= 1024; index += 1) {
|
||||
value /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}${unit}`;
|
||||
}
|
||||
|
||||
function formatBucketLabel(value: string, mode: "hour" | "day") {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
...(mode === "hour"
|
||||
? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
|
||||
: {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function truncateLabel(value: string, maxLength = 12) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function getNodeColor(index: number) {
|
||||
return nodePalette[index % nodePalette.length];
|
||||
}
|
||||
|
||||
function getNodeDataKey(nodeId: number) {
|
||||
return `node_${nodeId}`;
|
||||
}
|
||||
|
||||
function matchesUser(row: { auth_id: string }, selectedAuthId: string) {
|
||||
return selectedAuthId === "all" || row.auth_id === selectedAuthId;
|
||||
}
|
||||
|
||||
function buildNodeTrafficSeries(
|
||||
rows: TrafficTrendRow[],
|
||||
buckets: string[],
|
||||
nodes: NodeHealth[],
|
||||
selectedAuthId: string,
|
||||
) {
|
||||
const series = buckets.map((bucketStart) => {
|
||||
const point: TrafficPoint = {
|
||||
bucketStart,
|
||||
label: formatBucketLabel(bucketStart, "hour"),
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
point[getNodeDataKey(node.id)] = 0;
|
||||
}
|
||||
|
||||
return point;
|
||||
});
|
||||
const pointMap = new Map(series.map((point) => [point.bucketStart, point]));
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = pointMap.get(row.bucket_start);
|
||||
|
||||
if (!point) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = getNodeDataKey(row.node_id);
|
||||
point[key] = Number(point[key] ?? 0) + row.total_bytes;
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
function aggregateTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||
return rows.reduce(
|
||||
(accumulator, row) => {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
return {
|
||||
txBytes: accumulator.txBytes + row.tx_bytes,
|
||||
rxBytes: accumulator.rxBytes + row.rx_bytes,
|
||||
totalBytes: accumulator.totalBytes + row.total_bytes,
|
||||
};
|
||||
},
|
||||
{ txBytes: 0, rxBytes: 0, totalBytes: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateNodeTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
|
||||
const totals = new Map<number, number>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totals.set(row.node_id, (totals.get(row.node_id) ?? 0) + row.total_bytes);
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
function aggregateLoadSeries(rows: LoadTrendRow[]) {
|
||||
const map = new Map<string, {
|
||||
bucketStart: string;
|
||||
onlineUsers: number;
|
||||
streamCount: number;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.bucket_start) ?? {
|
||||
bucketStart: row.bucket_start,
|
||||
onlineUsers: 0,
|
||||
streamCount: 0,
|
||||
};
|
||||
|
||||
existing.onlineUsers += row.online_users;
|
||||
existing.streamCount += row.stream_count;
|
||||
map.set(row.bucket_start, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: formatBucketLabel(item.bucketStart, "hour"),
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateFailureSeries(rows: FailureTrendRow[]) {
|
||||
const map = new Map<string, { bucketStart: string; failedCount: number }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.bucket_start) ?? {
|
||||
bucketStart: row.bucket_start,
|
||||
failedCount: 0,
|
||||
};
|
||||
|
||||
existing.failedCount += row.failed_count;
|
||||
map.set(row.bucket_start, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: formatBucketLabel(item.bucketStart, "day"),
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateTopConsumers(rows: TopConsumerRow[], selectedAuthId: string) {
|
||||
const map = new Map<string, {
|
||||
authId: string;
|
||||
username: string;
|
||||
txBytes: number;
|
||||
rxBytes: number;
|
||||
totalBytes: number;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!matchesUser(row, selectedAuthId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = map.get(row.auth_id) ?? {
|
||||
authId: row.auth_id,
|
||||
username: row.username,
|
||||
txBytes: 0,
|
||||
rxBytes: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
existing.txBytes += row.tx_bytes;
|
||||
existing.rxBytes += row.rx_bytes;
|
||||
existing.totalBytes += row.total_bytes;
|
||||
map.set(row.auth_id, existing);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.sort((left, right) => right.totalBytes - left.totalBytes)
|
||||
.slice(0, 6)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
label: truncateLabel(item.username),
|
||||
}));
|
||||
}
|
||||
|
||||
export function DashboardCharts({
|
||||
nodeHealth,
|
||||
trafficTrend,
|
||||
trafficBuckets,
|
||||
trafficUserOptions,
|
||||
loadTrend,
|
||||
failureTrend,
|
||||
topConsumerRows,
|
||||
}: DashboardChartsProps) {
|
||||
const userSelectId = useId();
|
||||
const [selectedAuthId, setSelectedAuthId] = useState("all");
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<number>>(() => new Set());
|
||||
const selectedUser = selectedAuthId === "all"
|
||||
? null
|
||||
: trafficUserOptions.find((user) => user.auth_id === selectedAuthId) ?? null;
|
||||
const selectedUserLabel = selectedUser?.username ?? "全部用户";
|
||||
|
||||
const trafficSeries = buildNodeTrafficSeries(
|
||||
trafficTrend,
|
||||
trafficBuckets,
|
||||
nodeHealth,
|
||||
selectedAuthId,
|
||||
);
|
||||
const trafficTotals = aggregateTrafficTotals(trafficTrend, selectedAuthId);
|
||||
const nodeTrafficTotals = aggregateNodeTrafficTotals(trafficTrend, selectedAuthId);
|
||||
const loadSeries = aggregateLoadSeries(loadTrend);
|
||||
const failureSeries = aggregateFailureSeries(failureTrend);
|
||||
const consumerSeries = aggregateTopConsumers(topConsumerRows, selectedAuthId);
|
||||
const visibleNodes = nodeHealth.filter((node) => !hiddenNodeIds.has(node.id));
|
||||
|
||||
const peakLoad = loadSeries.reduce(
|
||||
(accumulator, item) => ({
|
||||
onlineUsers: Math.max(accumulator.onlineUsers, item.onlineUsers),
|
||||
streamCount: Math.max(accumulator.streamCount, item.streamCount),
|
||||
}),
|
||||
{ onlineUsers: 0, streamCount: 0 },
|
||||
);
|
||||
const totalFailures = failureSeries.reduce(
|
||||
(accumulator, item) => accumulator + item.failedCount,
|
||||
0,
|
||||
);
|
||||
const healthyNodes = nodeHealth.filter(
|
||||
(node) => node.enabled && !node.last_error_message,
|
||||
).length;
|
||||
const disabledNodes = nodeHealth.filter((node) => !node.enabled).length;
|
||||
const degradedNodes = nodeHealth.length - healthyNodes - disabledNodes;
|
||||
const statusTone = degradedNodes > 0 ? "danger" : disabledNodes > 0 ? "warm" : "ok";
|
||||
const statusLabel = `${healthyNodes}/${nodeHealth.length} 节点健康`;
|
||||
const hasTrafficData = trafficTotals.totalBytes > 0;
|
||||
const hasVisibleTrafficData = visibleNodes.some((node) => {
|
||||
const key = getNodeDataKey(node.id);
|
||||
return trafficSeries.some((point) => Number(point[key] ?? 0) > 0);
|
||||
});
|
||||
const hasLoadData = loadSeries.some(
|
||||
(item) => item.onlineUsers > 0 || item.streamCount > 0,
|
||||
);
|
||||
const hasFailureData = failureSeries.some((item) => item.failedCount > 0);
|
||||
const hasConsumerData = consumerSeries.length > 0;
|
||||
|
||||
function toggleNodeLine(nodeId: number) {
|
||||
startTransition(() => {
|
||||
setHiddenNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-card dashboard-analytics">
|
||||
<div className="dashboard-toolbar">
|
||||
<div className="stack-tight">
|
||||
<div className="section-title section-title--top">
|
||||
<div>
|
||||
<h3>流量与节点趋势</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--warm">{selectedUserLabel}</span>
|
||||
<span className={`status-pill status-pill--${statusTone}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-filter">
|
||||
<label htmlFor={userSelectId}>用户</label>
|
||||
<select
|
||||
id={userSelectId}
|
||||
value={selectedAuthId}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
startTransition(() => {
|
||||
setSelectedAuthId(nextValue);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="all">全部用户</option>
|
||||
{trafficUserOptions.map((user) => (
|
||||
<option key={user.auth_id} value={user.auth_id}>
|
||||
{user.username} / {formatCompactBytes(user.total_bytes)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-glance-grid">
|
||||
<article className="dashboard-glance-card dashboard-glance-card--warm">
|
||||
<span>24h 总流量</span>
|
||||
<strong>{formatBytes(trafficTotals.totalBytes)}</strong>
|
||||
<small>TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)}</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--ink">
|
||||
<span>24h 峰值在线</span>
|
||||
<strong>{peakLoad.onlineUsers}</strong>
|
||||
<small>峰值流数 {peakLoad.streamCount}</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--sage">
|
||||
<span>14d 鉴权失败</span>
|
||||
<strong>{totalFailures}</strong>
|
||||
<small>失败请求数</small>
|
||||
</article>
|
||||
<article className="dashboard-glance-card dashboard-glance-card--paper">
|
||||
<span>节点状态</span>
|
||||
<strong>{statusLabel}</strong>
|
||||
<small>异常 {degradedNodes} / 已禁用 {disabledNodes}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-chart-grid">
|
||||
<article className="dashboard-chart-card dashboard-chart-card--wide">
|
||||
<div className="dashboard-chart-card__head">
|
||||
<div className="section-title">
|
||||
<h4>节点流量</h4>
|
||||
<span className="status-pill status-pill--warm">最近 24 小时</span>
|
||||
</div>
|
||||
<div className="node-line-toggles">
|
||||
{nodeHealth.map((node, index) => {
|
||||
const hidden = hiddenNodeIds.has(node.id);
|
||||
const color = getNodeColor(index);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className={`node-line-toggle ${hidden ? "is-hidden" : ""}`}
|
||||
aria-pressed={!hidden}
|
||||
onClick={() => toggleNodeLine(node.id)}
|
||||
>
|
||||
<span
|
||||
className="node-line-toggle__swatch"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span>{node.name}</span>
|
||||
<small>{formatCompactBytes(nodeTrafficTotals.get(node.id) ?? 0)}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{hasTrafficData && hasVisibleTrafficData ? (
|
||||
<div className="chart-shell chart-shell--wide">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trafficSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
minTickGap={22}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatCompactBytes}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={58}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
formatBytes(Number(value ?? 0)),
|
||||
String(name),
|
||||
]}
|
||||
labelFormatter={(label) => `时间 ${label}`}
|
||||
/>
|
||||
{visibleNodes.map((node, index) => (
|
||||
<Line
|
||||
key={node.id}
|
||||
type="monotone"
|
||||
dataKey={getNodeDataKey(node.id)}
|
||||
name={node.name}
|
||||
stroke={getNodeColor(index)}
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无可见节点流量。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>在线负载</h4>
|
||||
<span className="status-pill status-pill--ink">24h</span>
|
||||
</div>
|
||||
{hasLoadData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={loadSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
minTickGap={22}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name) => [
|
||||
`${Number(value ?? 0)}`,
|
||||
String(name) === "onlineUsers" ? "在线用户" : "流数量",
|
||||
]}
|
||||
labelFormatter={(label) => `时间 ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="onlineUsers"
|
||||
name="在线用户"
|
||||
stroke="#18222c"
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="streamCount"
|
||||
name="流数量"
|
||||
stroke="#5d7b63"
|
||||
strokeWidth={2.2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无同步快照。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>鉴权失败</h4>
|
||||
<span className="status-pill status-pill--danger">14d</span>
|
||||
</div>
|
||||
{hasFailureData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={failureSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={axisTick} tickLine={false} axisLine={false} />
|
||||
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value) => [`${Number(value ?? 0)} 次`, "失败次数"]}
|
||||
labelFormatter={(label) => `日期 ${label}`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="failedCount"
|
||||
name="失败次数"
|
||||
fill="#af2d21"
|
||||
radius={[6, 6, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无鉴权失败。</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-chart-card">
|
||||
<div className="section-title">
|
||||
<h4>用户流量排行</h4>
|
||||
<span className="status-pill status-pill--sage">7d</span>
|
||||
</div>
|
||||
{hasConsumerData ? (
|
||||
<div className="chart-shell">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={consumerSeries}
|
||||
layout="vertical"
|
||||
margin={{ top: 6, right: 14, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={formatCompactBytes}
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={axisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={86}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value, name, item) => {
|
||||
const payload = item?.payload as { username?: string } | undefined;
|
||||
|
||||
return [
|
||||
formatBytes(Number(value ?? 0)),
|
||||
String(name) === "totalBytes"
|
||||
? payload?.username ?? "累计流量"
|
||||
: String(name),
|
||||
];
|
||||
}}
|
||||
labelFormatter={() => "用户"}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="totalBytes"
|
||||
name="totalBytes"
|
||||
fill="#7a3116"
|
||||
radius={[0, 6, 6, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty">暂无用户流量。</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user