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`] = ` +
+
+
+
+`; 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]]); + }} + /> + +
+
+ + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ +
+ +
+
+ ); +}; + +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
`; + +exports[`base/router-base-ee render base router when version is ee render route userActivity render router userActivity 1`] = ` + +
+
+
+ userActivity +
+
+
+ +`; diff --git a/packages/base/src/router/test/router.base.test.tsx b/packages/base/src/router/test/router.base.test.tsx index dbdc1bf434..1d52ee76c4 100644 --- a/packages/base/src/router/test/router.base.test.tsx +++ b/packages/base/src/router/test/router.base.test.tsx @@ -203,6 +203,15 @@ describe('base/router-base-ee', () => { }); }); + describe('render route userActivity', () => { + it('render router userActivity', async () => { + const { baseElement } = customRender(['/user-activity']); + + expect(screen.getByText('userActivity')).toBeInTheDocument(); + expect(baseElement).toMatchSnapshot(); + }); + }); + describe('render route syncDataSource', () => { it('render route syncDataSourceList', async () => { const { baseElement } = customRender([ diff --git a/packages/dms-kit/src/data/routePaths.ts b/packages/dms-kit/src/data/routePaths.ts index 61d8fb01d7..a79e2874c9 100644 --- a/packages/dms-kit/src/data/routePaths.ts +++ b/packages/dms-kit/src/data/routePaths.ts @@ -121,6 +121,7 @@ export const ROUTE_PATHS = { } }, RESOURCE_OVERVIEW: '/resource-overview', + USER_ACTIVITY: '/user-activity', AVAILABILITY_ZONE: '/availability-zone' }, SQLE: { diff --git a/packages/shared/lib/api/base/index.ts b/packages/shared/lib/api/base/index.ts index 220befc3e1..e81ee8f17a 100644 --- a/packages/shared/lib/api/base/index.ts +++ b/packages/shared/lib/api/base/index.ts @@ -22,6 +22,7 @@ export { default as ResourceOverviewService } from './service/ResourceOverview'; export { default as RoleService } from './service/Role'; export { default as SMSService } from './service/SMS'; export { default as SessionService } from './service/Session'; +export { default as UserActivityService } from './service/UserActivity'; export { default as UserService } from './service/User'; export { default as UserGroupService } from './service/UserGroup'; export { default as WebhookService } from './service/Webhook'; diff --git a/packages/shared/lib/api/base/service/UserActivity/index.d.ts b/packages/shared/lib/api/base/service/UserActivity/index.d.ts new file mode 100644 index 0000000000..ce264fec76 --- /dev/null +++ b/packages/shared/lib/api/base/service/UserActivity/index.d.ts @@ -0,0 +1,94 @@ +export interface IUserActivitySummary { + dau?: number; + request_count?: number; + avg_request_per_user?: number; + error_count?: number; + error_rate?: number; + peak_hour?: number; + peak_hour_requests?: number; +} + +export interface IGetUserActivitySummaryV1Params { + stat_date: string; +} + +export interface IGetUserActivitySummaryV1Return { + code?: number; + message?: string; + data?: IUserActivitySummary; +} + +export interface IUserActivityDailyTrendItem { + stat_date?: string; + dau?: number; + request_count?: number; + error_count?: number; +} + +export interface IGetUserActivityDailyTrendV1Params { + filter_date_from: string; + filter_date_to: string; +} + +export interface IGetUserActivityDailyTrendV1Return { + code?: number; + message?: string; + data?: IUserActivityDailyTrendItem[]; +} + +export interface IUserActivityModuleDistributionItem { + module_code?: string; + module_name?: string; + request_count?: number; + percent?: number; +} + +export interface IGetUserActivityModuleDistributionV1Params { + stat_date: string; +} + +export interface IGetUserActivityModuleDistributionV1Return { + code?: number; + message?: string; + data?: IUserActivityModuleDistributionItem[]; +} + +export interface IUserActivityHourlyDistributionItem { + stat_hour?: number; + request_count?: number; + active_users?: number; +} + +export interface IGetUserActivityHourlyDistributionV1Params { + stat_date: string; +} + +export interface IGetUserActivityHourlyDistributionV1Return { + code?: number; + message?: string; + data?: IUserActivityHourlyDistributionItem[]; +} + +export interface IUserActivityUserItem { + user_uid?: string; + user_name?: string; + active_days?: number; + request_count?: number; + top_module_code?: string; + top_module_name?: string; + last_active_at?: string; +} + +export interface IGetUserActivityUsersV1Params { + filter_date_from: string; + filter_date_to: string; + page_index: number; + page_size: number; +} + +export interface IGetUserActivityUsersV1Return { + code?: number; + message?: string; + data?: IUserActivityUserItem[]; + total_nums?: number; +} diff --git a/packages/shared/lib/api/base/service/UserActivity/index.ts b/packages/shared/lib/api/base/service/UserActivity/index.ts new file mode 100644 index 0000000000..71ce92eef8 --- /dev/null +++ b/packages/shared/lib/api/base/service/UserActivity/index.ts @@ -0,0 +1,84 @@ +/* tslint:disable no-identical-functions */ +/* tslint:disable no-useless-cast */ +/* tslint:disable no-unnecessary-type-assertion */ +/* tslint:disable no-big-function */ +/* tslint:disable no-duplicate-string */ +import ServiceBase from '../Service.base'; +import { AxiosRequestConfig } from 'axios'; + +import { + IGetUserActivityDailyTrendV1Params, + IGetUserActivityDailyTrendV1Return, + IGetUserActivityHourlyDistributionV1Params, + IGetUserActivityHourlyDistributionV1Return, + IGetUserActivityModuleDistributionV1Params, + IGetUserActivityModuleDistributionV1Return, + IGetUserActivitySummaryV1Params, + IGetUserActivitySummaryV1Return, + IGetUserActivityUsersV1Params, + IGetUserActivityUsersV1Return +} from './index.d'; + +class UserActivityService extends ServiceBase { + public GetUserActivitySummaryV1( + params: IGetUserActivitySummaryV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + return this.get( + '/v1/dms/statistic/user_activity/summary', + paramsData, + options + ); + } + + public GetUserActivityDailyTrendV1( + params: IGetUserActivityDailyTrendV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + return this.get( + '/v1/dms/statistic/user_activity/daily_trend', + paramsData, + options + ); + } + + public GetUserActivityModuleDistributionV1( + params: IGetUserActivityModuleDistributionV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + return this.get( + '/v1/dms/statistic/user_activity/module_distribution', + paramsData, + options + ); + } + + public GetUserActivityHourlyDistributionV1( + params: IGetUserActivityHourlyDistributionV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + return this.get( + '/v1/dms/statistic/user_activity/hourly_distribution', + paramsData, + options + ); + } + + public GetUserActivityUsersV1( + params: IGetUserActivityUsersV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + return this.get( + '/v1/dms/statistic/user_activity/users', + paramsData, + options + ); + } +} + +export default new UserActivityService(); diff --git a/packages/shared/lib/features/usePermission/__tests__/__snapshots__/index.test.ts.snap b/packages/shared/lib/features/usePermission/__tests__/__snapshots__/index.test.ts.snap index c787d96a4f..621a68b2f0 100644 --- a/packages/shared/lib/features/usePermission/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/shared/lib/features/usePermission/__tests__/__snapshots__/index.test.ts.snap @@ -266,6 +266,7 @@ exports[`usePermission should match snapshot 1`] = ` "RESOURCE_OVERVIEW": "page:resource_overview", "SYNC_DATA_SOURCE": "page:sync_data_source", "SYSTEM_SETTING": "page:system_setting", + "USER_ACTIVITY": "page:user_activity", "USER_CENTER": "page:user_center", }, "SQLE": { @@ -2062,6 +2063,14 @@ exports[`usePermission should match snapshot 2`] = ` ], "type": "page", }, + "page:user_activity": { + "id": "page:user_activity", + "role": [ + "admin", + "systemAdministrator", + ], + "type": "page", + }, "page:user_center": { "id": "page:user_center", "role": [ diff --git a/packages/shared/lib/features/usePermission/permissionManifest.ts b/packages/shared/lib/features/usePermission/permissionManifest.ts index c03cd88333..abf2e274cb 100644 --- a/packages/shared/lib/features/usePermission/permissionManifest.ts +++ b/packages/shared/lib/features/usePermission/permissionManifest.ts @@ -225,6 +225,11 @@ export const PERMISSION_MANIFEST: Record< SystemRole.certainProjectManager ] }, + [PERMISSIONS.PAGES.BASE.USER_ACTIVITY]: { + id: PERMISSIONS.PAGES.BASE.USER_ACTIVITY, + type: 'page', + role: [SystemRole.admin, SystemRole.systemAdministrator] + }, [PERMISSIONS.PAGES.SQLE.PROJECT_RULE_TEMPLATE]: { id: PERMISSIONS.PAGES.SQLE.PROJECT_RULE_TEMPLATE, type: 'page', diff --git a/packages/shared/lib/features/usePermission/permissions.ts b/packages/shared/lib/features/usePermission/permissions.ts index a4a2cb68da..1369dd12bb 100644 --- a/packages/shared/lib/features/usePermission/permissions.ts +++ b/packages/shared/lib/features/usePermission/permissions.ts @@ -8,7 +8,8 @@ export const PERMISSIONS = { SYSTEM_SETTING: 'page:system_setting', RESOURCE_OVERVIEW: 'page:resource_overview', MEMBER: 'page:member', - DB_SERVICE: 'page:db_service' + DB_SERVICE: 'page:db_service', + USER_ACTIVITY: 'page:user_activity' }, SQLE: { OPERATION_RECORD: 'page:operation_record', diff --git a/packages/shared/lib/testUtil/mockApi/base/index.ts b/packages/shared/lib/testUtil/mockApi/base/index.ts index fd2646599c..0d93d3e7b5 100644 --- a/packages/shared/lib/testUtil/mockApi/base/index.ts +++ b/packages/shared/lib/testUtil/mockApi/base/index.ts @@ -7,6 +7,7 @@ export { default as global } from './global'; export { default as member } from './member'; export { default as project } from './project'; export { default as resourceOverview } from './resourceOverview'; +export { default as userActivity } from './userActivity'; export { default as sms } from './sms'; export { default as syncTaskList } from './syncTaskList'; export { default as system } from './system'; diff --git a/packages/shared/lib/testUtil/mockApi/base/userActivity/data.ts b/packages/shared/lib/testUtil/mockApi/base/userActivity/data.ts new file mode 100644 index 0000000000..b6db0a7cc2 --- /dev/null +++ b/packages/shared/lib/testUtil/mockApi/base/userActivity/data.ts @@ -0,0 +1,83 @@ +import { IUserActivitySummary } from '../../../../api/base/service/UserActivity/index.d'; + +export const mockUserActivitySummary: IUserActivitySummary = { + dau: 3, + request_count: 13, + avg_request_per_user: 4.33, + error_count: 1, + error_rate: 1 / 13, + peak_hour: 9, + peak_hour_requests: 4 +}; + +export const mockUserActivityDailyTrend = [ + { + stat_date: '2026-06-20', + dau: 2, + request_count: 5, + error_count: 0 + }, + { + stat_date: '2026-06-21', + dau: 2, + request_count: 6, + error_count: 0 + }, + { + stat_date: '2026-06-22', + dau: 3, + request_count: 13, + error_count: 1 + } +]; + +export const mockUserActivityModuleDistribution = [ + { + module_code: 'WORKBENCH', + module_name: 'SQL工作台', + request_count: 4, + percent: 4 / 13 + }, + { + module_code: 'WORKFLOW', + module_name: 'SQL工单', + request_count: 2, + percent: 2 / 13 + }, + { + module_code: 'SQL_AUDIT', + module_name: '快捷审核', + request_count: 2, + percent: 2 / 13 + } +]; + +export const mockUserActivityHourlyDistribution = Array.from( + { length: 24 }, + (_, hour) => ({ + stat_hour: hour, + request_count: hour === 9 ? 4 : hour < 3 ? 1 : 0, + active_users: hour === 9 ? 2 : hour < 3 ? 1 : 0 + }) +); + +export const mockUserActivityUsers = [ + { + user_uid: 'u1', + user_name: 'admin', + active_days: 3, + request_count: 6, + top_module_code: 'WORKBENCH', + top_module_name: 'SQL工作台', + last_active_at: '2026-06-22T09:30:00Z' + }, + { + user_uid: 'u2', + user_name: 'tester', + active_days: 2, + request_count: 4, + top_module_code: 'WORKFLOW', + top_module_name: 'SQL工单', + last_active_at: '2026-06-22T08:15:00Z' + } +]; diff --git a/packages/shared/lib/testUtil/mockApi/base/userActivity/index.ts b/packages/shared/lib/testUtil/mockApi/base/userActivity/index.ts new file mode 100644 index 0000000000..777a63efa4 --- /dev/null +++ b/packages/shared/lib/testUtil/mockApi/base/userActivity/index.ts @@ -0,0 +1,70 @@ +import { createSpySuccessResponse, MockSpyApy } from '../../common'; +import { UserActivityService } from '../../../../api/base'; +import { + mockUserActivityDailyTrend, + mockUserActivityHourlyDistribution, + mockUserActivityModuleDistribution, + mockUserActivitySummary, + mockUserActivityUsers +} from './data'; + +class MockUserActivityApi implements MockSpyApy { + public mockAllApi(): void { + this.getUserActivitySummaryV1(); + this.getUserActivityDailyTrendV1(); + this.getUserActivityModuleDistributionV1(); + this.getUserActivityHourlyDistributionV1(); + this.getUserActivityUsersV1(); + } + + public getUserActivitySummaryV1() { + const spy = jest.spyOn(UserActivityService, 'GetUserActivitySummaryV1'); + spy.mockImplementation(() => + createSpySuccessResponse({ data: mockUserActivitySummary }) + ); + return spy; + } + + public getUserActivityDailyTrendV1() { + const spy = jest.spyOn(UserActivityService, 'GetUserActivityDailyTrendV1'); + spy.mockImplementation(() => + createSpySuccessResponse({ data: mockUserActivityDailyTrend }) + ); + return spy; + } + + public getUserActivityModuleDistributionV1() { + const spy = jest.spyOn( + UserActivityService, + 'GetUserActivityModuleDistributionV1' + ); + spy.mockImplementation(() => + createSpySuccessResponse({ data: mockUserActivityModuleDistribution }) + ); + return spy; + } + + public getUserActivityHourlyDistributionV1() { + const spy = jest.spyOn( + UserActivityService, + 'GetUserActivityHourlyDistributionV1' + ); + spy.mockImplementation(() => + createSpySuccessResponse({ data: mockUserActivityHourlyDistribution }) + ); + return spy; + } + + public getUserActivityUsersV1() { + const spy = jest.spyOn(UserActivityService, 'GetUserActivityUsersV1'); + spy.mockImplementation(() => + createSpySuccessResponse({ + data: mockUserActivityUsers, + total_nums: mockUserActivityUsers.length + }) + ); + return spy; + } +} + +export default new MockUserActivityApi();