diff --git a/packages/base/src/__snapshots__/App.test.tsx.snap b/packages/base/src/__snapshots__/App.test.tsx.snap
index ff42a054f9..9fb71089bd 100644
--- a/packages/base/src/__snapshots__/App.test.tsx.snap
+++ b/packages/base/src/__snapshots__/App.test.tsx.snap
@@ -1363,6 +1363,35 @@ exports[`App render App when token is existed 1`] = `
+
{
const { baseElement } = customRender();
expect(baseElement).toMatchSnapshot();
const actions = getAllBySelector('.action-item');
- expect(actions).toHaveLength(4);
+ expect(actions).toHaveLength(5);
fireEvent.click(actions[0]);
await act(async () => jest.advanceTimersByTime(0));
@@ -189,7 +189,7 @@ describe('base/Nav/QuickActions', () => {
expect(navigateSpy).toHaveBeenCalledTimes(3);
expect(navigateSpy).toHaveBeenNthCalledWith(
3,
- ROUTE_PATHS.SQLE.RULE.index.path
+ ROUTE_PATHS.BASE.USER_ACTIVITY
);
fireEvent.click(actions[3]);
@@ -197,6 +197,14 @@ describe('base/Nav/QuickActions', () => {
expect(navigateSpy).toHaveBeenCalledTimes(4);
expect(navigateSpy).toHaveBeenNthCalledWith(
4,
+ ROUTE_PATHS.SQLE.RULE.index.path
+ );
+
+ fireEvent.click(actions[4]);
+ await act(async () => jest.advanceTimersByTime(0));
+ expect(navigateSpy).toHaveBeenCalledTimes(5);
+ expect(navigateSpy).toHaveBeenNthCalledWith(
+ 5,
ROUTE_PATHS.SQLE.KNOWLEDGE.index.path
);
});
@@ -212,7 +220,7 @@ describe('base/Nav/QuickActions', () => {
pathname: ROUTE_PATHS.SQLE.KNOWLEDGE.index.path
});
customRender();
- expect(getAllBySelector('.action-item')[3]).toHaveClass(
+ expect(getAllBySelector('.action-item')[4]).toHaveClass(
'action-item-active'
);
@@ -221,7 +229,7 @@ describe('base/Nav/QuickActions', () => {
pathname: `${ROUTE_PATHS.SQLE.KNOWLEDGE.index.path}/${ROUTE_PATHS.SQLE.KNOWLEDGE.refined.path}`
});
customRender();
- expect(getAllBySelector('.action-item')[3]).toHaveClass(
+ expect(getAllBySelector('.action-item')[4]).toHaveClass(
'action-item-active'
);
});
diff --git a/packages/base/src/page/Nav/SideMenu/QuickActions/index.tsx b/packages/base/src/page/Nav/SideMenu/QuickActions/index.tsx
index a7d089de1f..e7737f1d68 100644
--- a/packages/base/src/page/Nav/SideMenu/QuickActions/index.tsx
+++ b/packages/base/src/page/Nav/SideMenu/QuickActions/index.tsx
@@ -1,6 +1,7 @@
import {
// #if [ee]
TodoListOutlined,
+ UserCircleFilled,
// #endif
SignalFilled,
ProfileSquareFilled,
@@ -86,6 +87,15 @@ const QuickActions: React.FC
= ({
icon: ,
permission: PERMISSIONS.PAGES.SQLE.REPORT_STATISTICS
},
+ // #if [ee]
+ {
+ key: 'user-activity',
+ title: t('dmsMenu.globalSettings.userActivity'),
+ path: ROUTE_PATHS.BASE.USER_ACTIVITY,
+ icon: ,
+ permission: PERMISSIONS.PAGES.BASE.USER_ACTIVITY
+ },
+ // #endif
{
key: 'view-rule',
title: t('dmsMenu.globalSettings.viewRule'),
diff --git a/packages/base/src/page/UserActivity/__tests__/__snapshots__/index.test.tsx.snap b/packages/base/src/page/UserActivity/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..a8df89ca35
--- /dev/null
+++ b/packages/base/src/page/UserActivity/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,755 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`base/page/UserActivity render init snap 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ 日活跃用户
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+ 请求总数
+
+
+
+
+ 13
+
+
+
+
+
+
+
+
+
+
+
+
+ 人均请求数
+
+
+
+
+ 4
+
+
+ .33
+
+
+
+
+
+
+
+
+
+
+
+
+ 错误率
+
+
+
+ 7.69%
+
+
+
+
+
+
+
+
+
+
+
+ 错误请求数
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+ 高峰时段
+
+
+
+ 09:00-10:00
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/packages/base/src/page/UserActivity/__tests__/index.test.tsx b/packages/base/src/page/UserActivity/__tests__/index.test.tsx
new file mode 100644
index 0000000000..4c462f0022
--- /dev/null
+++ b/packages/base/src/page/UserActivity/__tests__/index.test.tsx
@@ -0,0 +1,27 @@
+import { baseSuperRender } from '../../../testUtils/superRender';
+import UserActivity from '../index';
+import userActivity from '@actiontech/shared/lib/testUtil/mockApi/base/userActivity';
+import { mockUseCurrentUser } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentUser';
+import { act, cleanup } from '@testing-library/react';
+
+describe('base/page/UserActivity', () => {
+ beforeEach(() => {
+ userActivity.mockAllApi();
+ // 页面默认统计日期取当天,固定系统时间避免快照随运行日期漂移
+ jest.useFakeTimers().setSystemTime(new Date(2026, 5, 24, 10, 0, 0));
+ mockUseCurrentUser();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ cleanup();
+ });
+
+ it('render init snap', async () => {
+ const { container } = baseSuperRender();
+ await act(async () => {
+ jest.advanceTimersByTime(3000);
+ });
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/packages/base/src/page/UserActivity/components/DailyTrendChart/index.tsx b/packages/base/src/page/UserActivity/components/DailyTrendChart/index.tsx
new file mode 100644
index 0000000000..04fa75909d
--- /dev/null
+++ b/packages/base/src/page/UserActivity/components/DailyTrendChart/index.tsx
@@ -0,0 +1,84 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Line, LineConfig } from '@ant-design/plots';
+import ChartWrapper from 'sqle/src/components/ChartCom/ChartWrapper';
+import { useChangeTheme } from '@actiontech/shared/lib/features';
+import useThemeStyleData from '../../../../hooks/useThemeStyleData';
+import { IUserActivityDailyTrendItem } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+import { USER_ACTIVITY_CHART_PADDING } from '../../utils';
+
+type DailyTrendChartProps = {
+ loading?: boolean;
+ errorMessage?: string;
+ data?: IUserActivityDailyTrendItem[];
+ onRefresh?: () => void;
+};
+
+const DailyTrendChart = ({
+ loading,
+ errorMessage,
+ data,
+ onRefresh
+}: DailyTrendChartProps) => {
+ const { t } = useTranslation();
+ const { currentTheme } = useChangeTheme();
+ const { sqleTheme } = useThemeStyleData();
+
+ const chartData = useMemo(() => {
+ const result: Array<{
+ stat_date: string;
+ value: number;
+ category: string;
+ }> = [];
+ data?.forEach((item) => {
+ result.push({
+ stat_date: item.stat_date ?? '',
+ value: item.dau ?? 0,
+ category: t('userActivity.dailyTrend.dau')
+ });
+ result.push({
+ stat_date: item.stat_date ?? '',
+ value: item.request_count ?? 0,
+ category: t('userActivity.dailyTrend.requestCount')
+ });
+ });
+ return result;
+ }, [data, t]);
+
+ const config: LineConfig = {
+ data: chartData,
+ xField: 'stat_date',
+ yField: 'value',
+ seriesField: 'category',
+ color: sqleTheme.statistics.rectColor,
+ smooth: true,
+ appendPadding: USER_ACTIVITY_CHART_PADDING,
+ legend: {
+ position: 'top'
+ },
+ xAxis: {
+ label: {
+ autoRotate: true,
+ autoHide: false,
+ offset: 10,
+ style: {
+ fontSize: 11
+ }
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default DailyTrendChart;
diff --git a/packages/base/src/page/UserActivity/components/HourlyDistributionChart/index.tsx b/packages/base/src/page/UserActivity/components/HourlyDistributionChart/index.tsx
new file mode 100644
index 0000000000..78020a6d50
--- /dev/null
+++ b/packages/base/src/page/UserActivity/components/HourlyDistributionChart/index.tsx
@@ -0,0 +1,78 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Column, ColumnConfig } from '@ant-design/plots';
+import ChartWrapper from 'sqle/src/components/ChartCom/ChartWrapper';
+import { useChangeTheme } from '@actiontech/shared/lib/features';
+import useThemeStyleData from '../../../../hooks/useThemeStyleData';
+import { IUserActivityHourlyDistributionItem } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+import {
+ buildFullHourlyChartData,
+ USER_ACTIVITY_CHART_PADDING,
+ USER_ACTIVITY_HOUR_LABELS
+} from '../../utils';
+
+type HourlyDistributionChartProps = {
+ loading?: boolean;
+ errorMessage?: string;
+ data?: IUserActivityHourlyDistributionItem[];
+ onRefresh?: () => void;
+};
+
+const HourlyDistributionChart = ({
+ loading,
+ errorMessage,
+ data,
+ onRefresh
+}: HourlyDistributionChartProps) => {
+ const { t } = useTranslation();
+ const { currentTheme } = useChangeTheme();
+ const { sharedTheme } = useThemeStyleData();
+
+ const chartData = useMemo(() => buildFullHourlyChartData(data), [data]);
+
+ const config: ColumnConfig = {
+ data: chartData,
+ xField: 'hour_label',
+ yField: 'request_count',
+ appendPadding: USER_ACTIVITY_CHART_PADDING,
+ columnWidthRatio: 0.55,
+ color: sharedTheme.uiToken.colorPrimary,
+ xAxis: {
+ label: {
+ autoRotate: true,
+ autoHide: false,
+ rotate: -0.75,
+ offset: 10,
+ style: {
+ fontSize: 10,
+ textAlign: 'center'
+ }
+ },
+ tickLine: null
+ },
+ meta: {
+ hour_label: {
+ type: 'cat',
+ values: USER_ACTIVITY_HOUR_LABELS,
+ alias: t('userActivity.hourlyDistribution.hour')
+ },
+ request_count: {
+ alias: t('userActivity.hourlyDistribution.requestCount')
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default HourlyDistributionChart;
diff --git a/packages/base/src/page/UserActivity/components/ModuleDistributionChart/index.tsx b/packages/base/src/page/UserActivity/components/ModuleDistributionChart/index.tsx
new file mode 100644
index 0000000000..2290763e31
--- /dev/null
+++ b/packages/base/src/page/UserActivity/components/ModuleDistributionChart/index.tsx
@@ -0,0 +1,85 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Column, ColumnConfig } from '@ant-design/plots';
+import ChartWrapper from 'sqle/src/components/ChartCom/ChartWrapper';
+import { useChangeTheme } from '@actiontech/shared/lib/features';
+import useThemeStyleData from '../../../../hooks/useThemeStyleData';
+import { IUserActivityModuleDistributionItem } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+import { floatRound } from '@actiontech/dms-kit';
+import { USER_ACTIVITY_CHART_PADDING } from '../../utils';
+
+type ModuleDistributionChartProps = {
+ loading?: boolean;
+ errorMessage?: string;
+ data?: IUserActivityModuleDistributionItem[];
+ onRefresh?: () => void;
+};
+
+const ModuleDistributionChart = ({
+ loading,
+ errorMessage,
+ data,
+ onRefresh
+}: ModuleDistributionChartProps) => {
+ const { t } = useTranslation();
+ const { currentTheme } = useChangeTheme();
+ const { sharedTheme } = useThemeStyleData();
+
+ const chartData = useMemo(
+ () =>
+ data?.map((item) => ({
+ module_name: item.module_name || item.module_code,
+ request_count: item.request_count ?? 0,
+ percent: `${floatRound((item.percent ?? 0) * 100, 2)}%`
+ })) ?? [],
+ [data]
+ );
+
+ const config: ColumnConfig = {
+ data: chartData,
+ xField: 'module_name',
+ yField: 'request_count',
+ appendPadding: USER_ACTIVITY_CHART_PADDING,
+ columnWidthRatio: 0.5,
+ color: sharedTheme.uiToken.colorPrimary,
+ xAxis: {
+ label: {
+ autoRotate: true,
+ autoHide: false,
+ rotate: -0.75,
+ offset: 10,
+ style: {
+ fontSize: 10
+ }
+ },
+ tickLine: null
+ },
+ label: {
+ position: 'top',
+ formatter: (datum: { percent?: string }) => datum.percent ?? ''
+ },
+ meta: {
+ module_name: {
+ type: 'cat',
+ alias: t('userActivity.moduleDistribution.title')
+ },
+ request_count: {
+ alias: t('userActivity.moduleDistribution.requestCount')
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default ModuleDistributionChart;
diff --git a/packages/base/src/page/UserActivity/components/SummaryCards/index.tsx b/packages/base/src/page/UserActivity/components/SummaryCards/index.tsx
new file mode 100644
index 0000000000..bcbbfa51b0
--- /dev/null
+++ b/packages/base/src/page/UserActivity/components/SummaryCards/index.tsx
@@ -0,0 +1,67 @@
+import { Row, Col, Card, Statistic, Spin } from 'antd';
+import { useTranslation } from 'react-i18next';
+import { IUserActivitySummary } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+import {
+ formatAvgRequest,
+ formatCount,
+ formatErrorRate,
+ formatPeakHour
+} from '../../utils';
+
+type SummaryCardsProps = {
+ loading?: boolean;
+ data?: IUserActivitySummary;
+};
+
+const SummaryCards = ({ loading, data }: SummaryCardsProps) => {
+ const { t } = useTranslation();
+
+ const items = [
+ {
+ key: 'dau',
+ title: t('userActivity.summary.dau'),
+ value: formatCount(data?.dau)
+ },
+ {
+ key: 'requestCount',
+ title: t('userActivity.summary.requestCount'),
+ value: formatCount(data?.request_count)
+ },
+ {
+ key: 'avgRequestPerUser',
+ title: t('userActivity.summary.avgRequestPerUser'),
+ value: formatAvgRequest(data?.avg_request_per_user)
+ },
+ {
+ key: 'errorRate',
+ title: t('userActivity.summary.errorRate'),
+ value: formatErrorRate(data?.error_rate)
+ },
+ {
+ key: 'errorCount',
+ title: t('userActivity.summary.errorCount'),
+ value: formatCount(data?.error_count)
+ },
+ {
+ key: 'peakHour',
+ title: t('userActivity.summary.peakHour'),
+ value: formatPeakHour(data?.peak_hour)
+ }
+ ];
+
+ return (
+
+
+ {items.map((item) => (
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default SummaryCards;
diff --git a/packages/base/src/page/UserActivity/components/UserRankingTable/index.tsx b/packages/base/src/page/UserActivity/components/UserRankingTable/index.tsx
new file mode 100644
index 0000000000..3dc71d57c2
--- /dev/null
+++ b/packages/base/src/page/UserActivity/components/UserRankingTable/index.tsx
@@ -0,0 +1,113 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card } from 'antd';
+import {
+ ActiontechTable,
+ ActiontechTableWrapper,
+ useTableRequestError,
+ useTableRequestParams,
+ formatTime
+} from '@actiontech/dms-kit';
+import { DmsApi } from '@actiontech/shared/lib/api';
+import { useRequest } from 'ahooks';
+import { IUserActivityUserItem } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+import { IGetUserActivityUsersV1Params } from '@actiontech/shared/lib/api/base/service/UserActivity/index.d';
+
+type UserRankingTableProps = {
+ filterDateFrom: string;
+ filterDateTo: string;
+};
+
+const UserRankingTable = ({
+ filterDateFrom,
+ filterDateTo
+}: UserRankingTableProps) => {
+ const { t } = useTranslation();
+ const { requestErrorMessage, handleTableRequestError } =
+ useTableRequestError();
+
+ const { tableChange, pagination } = useTableRequestParams<
+ IUserActivityUserItem,
+ IGetUserActivityUsersV1Params
+ >();
+
+ const { data, loading } = useRequest(
+ () =>
+ handleTableRequestError(
+ DmsApi.UserActivityService.GetUserActivityUsersV1({
+ filter_date_from: filterDateFrom,
+ filter_date_to: filterDateTo,
+ page_index: pagination.page_index,
+ page_size: pagination.page_size
+ })
+ ),
+ {
+ refreshDeps: [
+ filterDateFrom,
+ filterDateTo,
+ pagination.page_index,
+ pagination.page_size
+ ]
+ }
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ dataIndex: 'user_name',
+ title: t('userActivity.userRanking.userName'),
+ render: (_: string, record: IUserActivityUserItem) =>
+ record.user_name || record.user_uid || '-'
+ },
+ {
+ dataIndex: 'active_days',
+ title: t('userActivity.userRanking.activeDays')
+ },
+ {
+ dataIndex: 'request_count',
+ title: t('userActivity.userRanking.requestCount')
+ },
+ {
+ dataIndex: 'top_module_name',
+ title: t('userActivity.userRanking.topModule'),
+ render: (_: string, record: IUserActivityUserItem) =>
+ record.top_module_name || record.top_module_code || '-'
+ },
+ {
+ dataIndex: 'last_active_at',
+ title: t('userActivity.userRanking.lastActiveAt'),
+ render: (value?: string) => (value ? formatTime(value) : '-')
+ }
+ ],
+ [t]
+ );
+
+ return (
+
+
+ 20
+ }}
+ onChange={tableChange}
+ />
+
+
+ );
+};
+
+export default UserRankingTable;
diff --git a/packages/base/src/page/UserActivity/index.tsx b/packages/base/src/page/UserActivity/index.tsx
new file mode 100644
index 0000000000..787d6b4fdc
--- /dev/null
+++ b/packages/base/src/page/UserActivity/index.tsx
@@ -0,0 +1,203 @@
+import { useMemo, useState } from 'react';
+import { Button, Col, DatePicker, Row } from 'antd';
+import type { Dayjs } from 'dayjs';
+import dayjs from 'dayjs';
+import { useTranslation } from 'react-i18next';
+import { useRequest } from 'ahooks';
+import { RefreshOutlined } from '@actiontech/icons';
+import { DmsApi } from '@actiontech/shared/lib/api';
+import { ResponseCode } from '@actiontech/dms-kit';
+import CardWrapper from 'sqle/src/components/CardWrapper';
+import { UserActivityStyleWrapper } from './style';
+import SummaryCards from './components/SummaryCards';
+import DailyTrendChart from './components/DailyTrendChart';
+import ModuleDistributionChart from './components/ModuleDistributionChart';
+import HourlyDistributionChart from './components/HourlyDistributionChart';
+import UserRankingTable from './components/UserRankingTable';
+import { DATE_FORMAT, getDefaultStatDate, getTrendDateRange } from './utils';
+
+const UserActivity = () => {
+ const { t } = useTranslation();
+ const [statDate, setStatDate] = useState(getDefaultStatDate());
+ const [trendRange, setTrendRange] = useState<[Dayjs, Dayjs]>(() => {
+ const [from, to] = getTrendDateRange(getDefaultStatDate());
+ return [dayjs(from), dayjs(to)];
+ });
+
+ const statDateStr = useMemo(() => statDate.format(DATE_FORMAT), [statDate]);
+ const trendFrom = useMemo(
+ () => trendRange[0].format(DATE_FORMAT),
+ [trendRange]
+ );
+ const trendTo = useMemo(
+ () => trendRange[1].format(DATE_FORMAT),
+ [trendRange]
+ );
+
+ const {
+ data: summary,
+ loading: summaryLoading,
+ refresh: refreshSummary
+ } = useRequest(
+ () =>
+ DmsApi.UserActivityService.GetUserActivitySummaryV1({
+ stat_date: statDateStr
+ }).then((res) => {
+ if (res.data.code === ResponseCode.SUCCESS) {
+ return res.data.data;
+ }
+ throw new Error(res.data.message);
+ }),
+ { refreshDeps: [statDateStr] }
+ );
+
+ const {
+ data: dailyTrend,
+ loading: dailyTrendLoading,
+ error: dailyTrendError,
+ refresh: refreshDailyTrend
+ } = useRequest(
+ () =>
+ DmsApi.UserActivityService.GetUserActivityDailyTrendV1({
+ filter_date_from: trendFrom,
+ filter_date_to: trendTo
+ }).then((res) => {
+ if (res.data.code === ResponseCode.SUCCESS) {
+ return res.data.data ?? [];
+ }
+ throw new Error(res.data.message);
+ }),
+ { refreshDeps: [trendFrom, trendTo] }
+ );
+
+ const {
+ data: moduleDistribution,
+ loading: moduleLoading,
+ error: moduleError,
+ refresh: refreshModule
+ } = useRequest(
+ () =>
+ DmsApi.UserActivityService.GetUserActivityModuleDistributionV1({
+ stat_date: statDateStr
+ }).then((res) => {
+ if (res.data.code === ResponseCode.SUCCESS) {
+ return res.data.data ?? [];
+ }
+ throw new Error(res.data.message);
+ }),
+ { refreshDeps: [statDateStr] }
+ );
+
+ const {
+ data: hourlyDistribution,
+ loading: hourlyLoading,
+ error: hourlyError,
+ refresh: refreshHourly
+ } = useRequest(
+ () =>
+ DmsApi.UserActivityService.GetUserActivityHourlyDistributionV1({
+ stat_date: statDateStr
+ }).then((res) => {
+ if (res.data.code === ResponseCode.SUCCESS) {
+ return res.data.data ?? [];
+ }
+ throw new Error(res.data.message);
+ }),
+ { refreshDeps: [statDateStr] }
+ );
+
+ const refreshAll = () => {
+ refreshSummary();
+ refreshDailyTrend();
+ refreshModule();
+ refreshHourly();
+ };
+
+ return (
+
+
+
{t('userActivity.title')}
+
+ {t('userActivity.statDate')}
+ {
+ if (!value) {
+ return;
+ }
+ setStatDate(value);
+ const [from, to] = getTrendDateRange(value);
+ setTrendRange([dayjs(from), dayjs(to)]);
+ }}
+ />
+ {t('userActivity.dateRange')}
+ {
+ if (!values?.[0] || !values?.[1]) {
+ return;
+ }
+ setTrendRange([values[0], values[1]]);
+ }}
+ />
+ } onClick={refreshAll}>
+ {t('userActivity.refresh')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default UserActivity;
diff --git a/packages/base/src/page/UserActivity/style.ts b/packages/base/src/page/UserActivity/style.ts
new file mode 100644
index 0000000000..0eb0ee24d1
--- /dev/null
+++ b/packages/base/src/page/UserActivity/style.ts
@@ -0,0 +1,67 @@
+import { styled } from '@mui/material/styles';
+import { USER_ACTIVITY_CHART_HEIGHT } from './utils';
+
+export const UserActivityStyleWrapper = styled('section')`
+ &.user-activity-page {
+ .page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+
+ .page-title {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 28px;
+ }
+
+ .page-filters {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+ }
+
+ .summary-card {
+ height: 100%;
+ }
+
+ .chart-section {
+ margin-top: 16px;
+
+ .chart-panel {
+ width: 100%;
+ height: 440px;
+
+ .card-cont {
+ overflow: visible;
+ height: calc(100% - 48px);
+ }
+
+ .chart-wrapper {
+ height: ${USER_ACTIVITY_CHART_HEIGHT}px;
+
+ .chart-box {
+ height: ${USER_ACTIVITY_CHART_HEIGHT}px;
+ min-height: ${USER_ACTIVITY_CHART_HEIGHT}px;
+ }
+ }
+ }
+
+ .ranking-panel {
+ width: 100%;
+ height: 440px;
+ }
+ }
+
+ .chart-card {
+ height: 100%;
+
+ .ant-card-body {
+ height: calc(100% - 57px);
+ overflow: auto;
+ }
+ }
+ }
+`;
diff --git a/packages/base/src/page/UserActivity/utils.ts b/packages/base/src/page/UserActivity/utils.ts
new file mode 100644
index 0000000000..00dd517f02
--- /dev/null
+++ b/packages/base/src/page/UserActivity/utils.ts
@@ -0,0 +1,76 @@
+import dayjs, { Dayjs } from 'dayjs';
+import { floatRound, formatParamsBySeparator } from '@actiontech/dms-kit';
+
+export const DATE_FORMAT = 'YYYY-MM-DD';
+
+/** 图表绘图区固定高度,避免横轴被父容器裁切 */
+export const USER_ACTIVITY_CHART_HEIGHT = 300;
+
+/** 图表底部留白,避免横轴标签被裁切 */
+export const USER_ACTIVITY_CHART_PADDING: [number, number, number, number] = [
+ 16, 16, 64, 16
+];
+
+export const formatHourLabel = (hour?: number) => {
+ if (hour === undefined || hour === null) {
+ return '';
+ }
+ return `${String(hour).padStart(2, '0')}:00`;
+};
+
+export const USER_ACTIVITY_HOUR_LABELS = Array.from({ length: 24 }, (_, hour) =>
+ formatHourLabel(hour)
+);
+
+export const getDefaultStatDate = () => dayjs();
+
+export const getTrendDateRange = (statDate: Dayjs): [string, string] => {
+ const end = statDate;
+ const start = statDate.subtract(6, 'day');
+ return [start.format(DATE_FORMAT), end.format(DATE_FORMAT)];
+};
+
+export const formatPeakHour = (hour?: number) => {
+ if (hour === undefined || hour === null) {
+ return '-';
+ }
+ const start = `${String(hour).padStart(2, '0')}:00`;
+ const end = `${String((hour + 1) % 24).padStart(2, '0')}:00`;
+ return `${start}-${end}`;
+};
+
+export const formatErrorRate = (rate?: number) => {
+ if (rate === undefined || rate === null) {
+ return '-';
+ }
+ return `${floatRound(rate * 100, 2)}%`;
+};
+
+export const formatAvgRequest = (value?: number) => {
+ if (value === undefined || value === null) {
+ return '-';
+ }
+ return floatRound(value, 2).toString();
+};
+
+export const formatCount = (value?: number) => {
+ if (value === undefined || value === null) {
+ return '-';
+ }
+ return formatParamsBySeparator(value);
+};
+
+export const buildFullHourlyChartData = (
+ data?: Array<{ stat_hour?: number; request_count?: number }>
+) => {
+ const hourMap = new Map();
+ data?.forEach((item) => {
+ if (item.stat_hour !== undefined && item.stat_hour !== null) {
+ hourMap.set(item.stat_hour, item.request_count ?? 0);
+ }
+ });
+ return Array.from({ length: 24 }, (_, hour) => ({
+ hour_label: formatHourLabel(hour),
+ request_count: hourMap.get(hour) ?? 0
+ }));
+};
diff --git a/packages/base/src/router/router.base.tsx b/packages/base/src/router/router.base.tsx
index 59c1b3ea32..dac74eb119 100644
--- a/packages/base/src/router/router.base.tsx
+++ b/packages/base/src/router/router.base.tsx
@@ -36,6 +36,7 @@ const GlobalAddDataSource = React.lazy(
);
const ResourceOverview = React.lazy(() => import('../page/ResourceOverview'));
+const UserActivity = React.lazy(() => import('../page/UserActivity'));
const AvailabilityZoneList = React.lazy(
() => import('../page/AvailabilityZone/List')
@@ -179,6 +180,12 @@ export const BaseGlobalRouterConfig: RouterConfigItem[] = [
key: 'resourceOverview',
element: ,
permission: PERMISSIONS.PAGES.BASE.RESOURCE_OVERVIEW
+ },
+ {
+ path: ROUTE_PATHS.BASE.USER_ACTIVITY,
+ key: 'userActivity',
+ element: ,
+ permission: PERMISSIONS.PAGES.BASE.USER_ACTIVITY
}
// #endif
];
diff --git a/packages/base/src/router/test/__snapshots__/router.base.test.tsx.snap b/packages/base/src/router/test/__snapshots__/router.base.test.tsx.snap
index a66d7e4a55..d06b13f57f 100644
--- a/packages/base/src/router/test/__snapshots__/router.base.test.tsx.snap
+++ b/packages/base/src/router/test/__snapshots__/router.base.test.tsx.snap
@@ -134,6 +134,13 @@ exports[`base/router-base-ee render base route data snap 1`] = `
"key": "resourceOverview",
"path": "/resource-overview",
},
+ {
+ "element":
+ userActivity
+
,
+ "key": "userActivity",
+ "path": "/user-activity",
+ },
{
"children": [
{
@@ -1197,3 +1204,17 @@ exports[`base/router-base-ee render base router when version is ee render route
+