refactor: 前端性能优化 — 倒计时组件隔离、React memoization 链路
- 新建 RefreshCountdown 组件,内部持有 timer,消除 App 每秒重渲染 - TargetBoard 分组逻辑 useMemo 化,避免 targets 引用不变时重复计算 - TargetGroup 加 React.memo,阻断无效渲染 - TrendChart 加 React.memo + chartData useMemo,避免 recharts 不必要重绘 - OverviewTab 统计项去掉 Card 包裹,改用纯 CSS 实现视觉效果 - 同步更新 refresh-control 和 target-detail-drawer spec 性能提升:消除每秒全组件树重渲染,减少 DOM 节点数
This commit is contained in:
@@ -20,7 +20,15 @@ HeadMenu operations 区域 SHALL 提供 RadioGroup 组件供用户选择刷新
|
|||||||
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
|
- **THEN** 系统 SHALL 立即触发一次数据刷新,然后应用新的刷新间隔
|
||||||
|
|
||||||
### Requirement: 倒计时显示
|
### Requirement: 倒计时显示
|
||||||
RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。
|
RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。倒计时逻辑 SHALL 封装在独立的 `RefreshCountdown` 组件中,App 组件 SHALL NOT 持有每秒更新的 `now` state。
|
||||||
|
|
||||||
|
#### Scenario: RefreshCountdown 组件封装
|
||||||
|
- **WHEN** Dashboard 页面渲染
|
||||||
|
- **THEN** 倒计时显示 SHALL 由独立的 `RefreshCountdown` 组件负责,该组件内部持有 `now` state 和每秒 `setInterval`,渲染边界限制在该组件内部
|
||||||
|
|
||||||
|
#### Scenario: RefreshCountdown props
|
||||||
|
- **WHEN** RefreshCountdown 组件渲染
|
||||||
|
- **THEN** 组件 SHALL 接收 `dashboardUpdatedAt: number`、`refreshInterval: number`、`isFetching: boolean`、`isManualRefresh: boolean`、`onRefresh: () => void` 作为 props
|
||||||
|
|
||||||
#### Scenario: 短时间格式
|
#### Scenario: 短时间格式
|
||||||
- **WHEN** 距下次刷新剩余时间小于 60 秒
|
- **WHEN** 距下次刷新剩余时间小于 60 秒
|
||||||
@@ -38,6 +46,17 @@ RadioGroup 右侧 SHALL 显示距下次自动刷新的倒计时文本。
|
|||||||
- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false)
|
- **WHEN** 数据正在刷新(isFetching=true 且 isLoading=false)
|
||||||
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
|
- **THEN** 倒计时文本 SHALL 显示为"刷新中..."
|
||||||
|
|
||||||
|
### Requirement: App 组件渲染隔离
|
||||||
|
App 组件 SHALL NOT 持有任何高频更新的 state(如每秒更新的时钟),确保 App 的重渲染频率与数据刷新频率一致(默认 30 秒一次)。
|
||||||
|
|
||||||
|
#### Scenario: App 无 now state
|
||||||
|
- **WHEN** App 组件渲染
|
||||||
|
- **THEN** App SHALL NOT 包含 `useState` 管理的时钟 state,也 SHALL NOT 包含每秒触发的 `setInterval`
|
||||||
|
|
||||||
|
#### Scenario: App 重渲染频率
|
||||||
|
- **WHEN** Dashboard 处于自动刷新模式
|
||||||
|
- **THEN** App 组件的重渲染 SHALL 仅由 TanStack Query 的 refetch 触发(频率等于用户选择的刷新间隔),而非每秒触发
|
||||||
|
|
||||||
### Requirement: 手动刷新按钮
|
### Requirement: 手动刷新按钮
|
||||||
选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。
|
选择"手动"模式时,倒计时区域 SHALL 替换为刷新按钮。
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,36 @@ TrendChart 组件 SHALL 仅接收数据 props,不处理 loading 状态。
|
|||||||
- **WHEN** TrendChart 接收空数组
|
- **WHEN** TrendChart 接收空数组
|
||||||
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
|
- **THEN** 组件 SHALL 显示"暂无趋势数据"占位文本
|
||||||
|
|
||||||
|
#### Scenario: TrendChart memo 包裹
|
||||||
|
- **WHEN** TrendChart 的父组件重渲染但 data prop 引用未变
|
||||||
|
- **THEN** TrendChart SHALL 跳过重渲染(通过 React.memo shallow compare)
|
||||||
|
|
||||||
|
#### Scenario: chartData useMemo
|
||||||
|
- **WHEN** TrendChart 渲染
|
||||||
|
- **THEN** 内部 `chartData` 转换结果 SHALL 通过 `useMemo` 缓存,依赖为 `[data]`,data 引用不变时不重新计算
|
||||||
|
|
||||||
|
### Requirement: TargetBoard 分组 memoize
|
||||||
|
TargetBoard 组件的分组计算 SHALL 使用 useMemo 缓存,避免 targets 引用不变时重复计算分组。
|
||||||
|
|
||||||
|
#### Scenario: 分组结果 useMemo
|
||||||
|
- **WHEN** TargetBoard 渲染
|
||||||
|
- **THEN** 分组逻辑(Map 构建 + sort)SHALL 通过 `useMemo` 缓存,依赖为 `[targets]`
|
||||||
|
|
||||||
|
#### Scenario: targets 引用不变时跳过分组
|
||||||
|
- **WHEN** TargetBoard 因父组件重渲染而重渲染,但 targets prop 引用未变
|
||||||
|
- **THEN** 分组计算 SHALL 返回缓存结果,不重新执行 Map 构建和排序
|
||||||
|
|
||||||
|
### Requirement: TargetGroup 渲染优化
|
||||||
|
TargetGroup 组件 SHALL 使用 React.memo 包裹,在 props 引用不变时跳过重渲染。
|
||||||
|
|
||||||
|
#### Scenario: TargetGroup memo 包裹
|
||||||
|
- **WHEN** TargetBoard 重渲染但某个分组的 targets 数组引用未变
|
||||||
|
- **THEN** 对应的 TargetGroup SHALL 跳过重渲染(通过 React.memo shallow compare)
|
||||||
|
|
||||||
|
#### Scenario: TargetGroup props 稳定性
|
||||||
|
- **WHEN** TargetGroup 渲染
|
||||||
|
- **THEN** 其 props(columns、name、targets、onTargetClick)SHALL 全部具有引用稳定性:columns 通过 useMemo、name 为 string 原始值、targets 通过分组 useMemo、onTargetClick 通过 useCallback
|
||||||
|
|
||||||
### Requirement: StatusBar 参数化
|
### Requirement: StatusBar 参数化
|
||||||
StatusBar 组件 SHALL 支持可配置的格数。
|
StatusBar 组件 SHALL 支持可配置的格数。
|
||||||
|
|
||||||
@@ -189,7 +219,7 @@ Drawer 内部 SHALL 使用 TDesign Tabs 组织概览和记录两个面板。Tabs
|
|||||||
|
|
||||||
#### Scenario: 统计区左右布局卡片
|
#### Scenario: 统计区左右布局卡片
|
||||||
- **WHEN** 概览面板渲染且有统计数据
|
- **WHEN** 概览面板渲染且有统计数据
|
||||||
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 Card 包裹,Card 内标题左对齐、数值右对齐,数值使用普通文本字号
|
- **THEN** 面板 SHALL 在"统计"区域使用 4 列 × 2 行的 Row/Col 布局,每个统计项使用 `<div className="overview-stat-card">` 包裹,通过 CSS 类实现背景色和内边距视觉效果
|
||||||
|
|
||||||
#### Scenario: 统计区内容
|
#### Scenario: 统计区内容
|
||||||
- **WHEN** 概览面板渲染
|
- **WHEN** 概览面板渲染
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { SkeletonProps } from "tdesign-react";
|
import type { SkeletonProps } from "tdesign-react";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { RefreshIcon } from "tdesign-icons-react";
|
import { Alert, Layout, Menu, RadioGroup, Skeleton } from "tdesign-react";
|
||||||
import { Alert, Button, Layout, Menu, RadioGroup, Skeleton, Typography } from "tdesign-react";
|
|
||||||
|
|
||||||
|
import { RefreshCountdown } from "./components/RefreshCountdown";
|
||||||
import { SummaryCards } from "./components/SummaryCards";
|
import { SummaryCards } from "./components/SummaryCards";
|
||||||
import { TargetBoard } from "./components/TargetBoard";
|
import { TargetBoard } from "./components/TargetBoard";
|
||||||
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
import { TargetDetailDrawer } from "./components/TargetDetailDrawer";
|
||||||
import { useDashboard } from "./hooks/use-queries";
|
import { useDashboard } from "./hooks/use-queries";
|
||||||
import { useTargetDetail } from "./hooks/use-target-detail";
|
import { useTargetDetail } from "./hooks/use-target-detail";
|
||||||
import { formatCountdown } from "./utils/time";
|
|
||||||
|
|
||||||
const { Content, Header } = Layout;
|
const { Content, Header } = Layout;
|
||||||
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
|
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
|
||||||
@@ -27,7 +26,6 @@ const REFRESH_OPTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [now, setNow] = useState(() => new Date());
|
|
||||||
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
|
const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS);
|
||||||
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
|
const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval;
|
||||||
const {
|
const {
|
||||||
@@ -54,27 +52,12 @@ export function App() {
|
|||||||
timeTo,
|
timeTo,
|
||||||
} = useTargetDetail();
|
} = useTargetDetail();
|
||||||
const isManualRefresh = refreshInterval === 0;
|
const isManualRefresh = refreshInterval === 0;
|
||||||
const nextRefreshSeconds =
|
|
||||||
dashboardUpdatedAt > 0 && !isManualRefresh
|
|
||||||
? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
|
|
||||||
: null;
|
|
||||||
const refreshText =
|
|
||||||
dashboardUpdatedAt > 0
|
|
||||||
? dashboardFetching && !dashboardLoading
|
|
||||||
? "刷新中..."
|
|
||||||
: formatCountdown(nextRefreshSeconds ?? 0)
|
|
||||||
: "等待首次刷新";
|
|
||||||
|
|
||||||
const handleIntervalChange = (value: number) => {
|
const handleIntervalChange = (value: number) => {
|
||||||
void refetchDashboard();
|
void refetchDashboard();
|
||||||
setRefreshInterval(value);
|
setRefreshInterval(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="dashboard">
|
<Layout className="dashboard">
|
||||||
<Header>
|
<Header>
|
||||||
@@ -95,19 +78,13 @@ export function App() {
|
|||||||
variant="default-filled"
|
variant="default-filled"
|
||||||
/>
|
/>
|
||||||
<span className="dashboard-countdown">
|
<span className="dashboard-countdown">
|
||||||
{isManualRefresh ? (
|
<RefreshCountdown
|
||||||
<Button
|
dashboardUpdatedAt={dashboardUpdatedAt}
|
||||||
aria-label="刷新 Dashboard"
|
isFetching={dashboardFetching && !dashboardLoading}
|
||||||
disabled={dashboardFetching}
|
isManualRefresh={isManualRefresh}
|
||||||
icon={<RefreshIcon />}
|
onRefresh={() => void refetchDashboard()}
|
||||||
loading={dashboardFetching}
|
refreshInterval={refreshInterval}
|
||||||
onClick={() => void refetchDashboard()}
|
/>
|
||||||
shape="circle"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Typography.Text theme="secondary">{refreshText}</Typography.Text>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { Card, Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
|
import { Col, Descriptions, Divider, Row, Skeleton, Space, Statistic, Typography } from "tdesign-react";
|
||||||
|
|
||||||
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
import type { TargetMetricsResponse, TargetStatus } from "../../shared/api";
|
||||||
|
|
||||||
@@ -97,11 +97,11 @@ export function OverviewTab({ metricsData, metricsLoading, target }: OverviewTab
|
|||||||
|
|
||||||
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
|
function OverviewStatItem({ color, suffix, title, value }: OverviewStatItemProps) {
|
||||||
return (
|
return (
|
||||||
<Card bordered={false} className="overview-stat-card" size="small">
|
<div className="overview-stat-card">
|
||||||
<div className="overview-stat-item">
|
<div className="overview-stat-item">
|
||||||
<Typography.Text theme="secondary">{title}</Typography.Text>
|
<Typography.Text theme="secondary">{title}</Typography.Text>
|
||||||
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
|
<Statistic className="overview-stat-value" color={color} suffix={suffix} value={value} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/web/components/RefreshCountdown.tsx
Normal file
52
src/web/components/RefreshCountdown.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RefreshIcon } from "tdesign-icons-react";
|
||||||
|
import { Button, Typography } from "tdesign-react";
|
||||||
|
|
||||||
|
import { formatCountdown } from "../utils/time";
|
||||||
|
|
||||||
|
interface RefreshCountdownProps {
|
||||||
|
dashboardUpdatedAt: number;
|
||||||
|
isFetching: boolean;
|
||||||
|
isManualRefresh: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
refreshInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshCountdown({
|
||||||
|
dashboardUpdatedAt,
|
||||||
|
isFetching,
|
||||||
|
isManualRefresh,
|
||||||
|
onRefresh,
|
||||||
|
refreshInterval,
|
||||||
|
}: RefreshCountdownProps) {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextRefreshSeconds =
|
||||||
|
dashboardUpdatedAt > 0 && !isManualRefresh
|
||||||
|
? Math.max(0, Math.ceil((dashboardUpdatedAt + refreshInterval - now.getTime()) / 1000))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isManualRefresh) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="刷新 Dashboard"
|
||||||
|
disabled={isFetching}
|
||||||
|
icon={<RefreshIcon />}
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void onRefresh()}
|
||||||
|
shape="circle"
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshText =
|
||||||
|
dashboardUpdatedAt > 0 ? (isFetching ? "刷新中..." : formatCountdown(nextRefreshSeconds ?? 0)) : "等待首次刷新";
|
||||||
|
|
||||||
|
return <Typography.Text theme="secondary">{refreshText}</Typography.Text>;
|
||||||
|
}
|
||||||
@@ -19,21 +19,22 @@ export function TargetBoard({ onTargetClick, targets }: TargetBoardProps) {
|
|||||||
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
|
const checkerTypes = meta?.checkerTypes ?? EMPTY_CHECKER_TYPES;
|
||||||
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
|
const columns = useMemo(() => createTargetTableColumns(checkerTypes), [checkerTypes]);
|
||||||
|
|
||||||
const groups = new Map<string, TargetStatus[]>();
|
const sortedGroups = useMemo(() => {
|
||||||
for (const target of targets) {
|
const groups = new Map<string, TargetStatus[]>();
|
||||||
const group = target.group;
|
for (const target of targets) {
|
||||||
const list = groups.get(group);
|
const group = target.group;
|
||||||
if (list) {
|
const list = groups.get(group);
|
||||||
list.push(target);
|
if (list) {
|
||||||
} else {
|
list.push(target);
|
||||||
groups.set(group, [target]);
|
} else {
|
||||||
|
groups.set(group, [target]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return Array.from(groups.entries()).sort(([a]) => {
|
||||||
|
if (a === "default") return -1;
|
||||||
const sortedGroups = Array.from(groups.entries()).sort(([a]) => {
|
return 0;
|
||||||
if (a === "default") return -1;
|
});
|
||||||
return 0;
|
}, [targets]);
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space className="full-width" direction="vertical" size={24}>
|
<Space className="full-width" direction="vertical" size={24}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PrimaryTableCol } from "tdesign-react";
|
import type { PrimaryTableCol } from "tdesign-react";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
|
import { Card, PrimaryTable, Space, Tag } from "tdesign-react";
|
||||||
|
|
||||||
import type { TargetStatus } from "../../shared/api";
|
import type { TargetStatus } from "../../shared/api";
|
||||||
@@ -11,7 +12,7 @@ interface TargetGroupProps {
|
|||||||
targets: TargetStatus[];
|
targets: TargetStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
export const TargetGroup = memo(function TargetGroup({ columns, name, onTargetClick, targets }: TargetGroupProps) {
|
||||||
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
const up = targets.filter((t) => t.latestCheck?.matched).length;
|
||||||
const down = targets.length - up;
|
const down = targets.length - up;
|
||||||
const displayName = name === "default" ? "默认分组" : name;
|
const displayName = name === "default" ? "默认分组" : name;
|
||||||
@@ -48,4 +49,4 @@ export function TargetGroup({ columns, name, onTargetClick, targets }: TargetGro
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
import type { TrendPoint } from "../../shared/api";
|
import type { TrendPoint } from "../../shared/api";
|
||||||
@@ -12,18 +13,24 @@ interface TrendChartProps {
|
|||||||
data: TrendPoint[];
|
data: TrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrendChart({ data }: TrendChartProps) {
|
export const TrendChart = memo(function TrendChart({ data }: TrendChartProps) {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((point) => ({
|
||||||
|
...point,
|
||||||
|
durationRange:
|
||||||
|
point.minDurationMs !== null && point.maxDurationMs !== null
|
||||||
|
? [point.minDurationMs, point.maxDurationMs]
|
||||||
|
: null,
|
||||||
|
label: formatBucketLabel(point.bucketStart),
|
||||||
|
})),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <div className="trend-empty">暂无趋势数据</div>;
|
return <div className="trend-empty">暂无趋势数据</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = data.map((point) => ({
|
|
||||||
...point,
|
|
||||||
durationRange:
|
|
||||||
point.minDurationMs !== null && point.maxDurationMs !== null ? [point.minDurationMs, point.maxDurationMs] : null,
|
|
||||||
label: formatBucketLabel(point.bucketStart),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="trend-chart">
|
<div className="trend-chart">
|
||||||
<ResponsiveContainer height={240} width="100%">
|
<ResponsiveContainer height={240} width="100%">
|
||||||
@@ -69,7 +76,7 @@ export function TrendChart({ data }: TrendChartProps) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function formatBucketLabel(bucketStart: string): string {
|
function formatBucketLabel(bucketStart: string): string {
|
||||||
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
return new Date(bucketStart).toLocaleTimeString("zh-CN", { hour: "2-digit", hour12: false, minute: "2-digit" });
|
||||||
|
|||||||
@@ -172,6 +172,8 @@
|
|||||||
|
|
||||||
.overview-stat-card {
|
.overview-stat-card {
|
||||||
background: var(--td-bg-color-container-hover);
|
background: var(--td-bg-color-container-hover);
|
||||||
|
border-radius: var(--td-radius-default);
|
||||||
|
padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-stat-item {
|
.overview-stat-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user