Merge pull request #316 from yokowu/feat-dashboard

feat: 统计支持时间范围筛选
This commit is contained in:
Yoko
2025-08-29 18:13:09 +08:00
committed by GitHub
7 changed files with 142 additions and 106 deletions

View File

@@ -34,15 +34,34 @@ type Statistics struct {
}
type StatisticsFilter struct {
Precision string `json:"precision" query:"precision" validate:"required,oneof=hour day" default:"day"` // 精度: "hour", "day"
Duration int `json:"duration" query:"duration" validate:"gte=24,lte=90" default:"90"` // 持续时间 (小时或天数)`
UserID string `json:"user_id,omitempty" query:"user_id"` // 用户ID可选参数
Precision string `json:"precision" query:"precision"` // 精度: "hour", "day"
Duration int `json:"duration" query:"duration"` // 持续时间 (小时或天数)`
StartAt int64 `json:"start_at" query:"start_at"` // 开始时间, 时间范围优先级高于精度选择
EndAt int64 `json:"end_at" query:"end_at"` // 结束时间, 时间范围优先级高于精度选择
UserID string `json:"user_id,omitempty" query:"user_id"` // 用户ID可选参数
}
func (s StatisticsFilter) MustPrecision() string {
if s.Precision == "" {
return "day"
}
return s.Precision
}
func (s StatisticsFilter) StartTime() time.Time {
if s.StartAt > 0 {
return time.Unix(s.StartAt, 0)
}
return time.Now().Add(-24 * time.Hour)
}
func (s StatisticsFilter) EndTime() time.Time {
if s.EndAt > 0 {
return time.Unix(s.EndAt, 0)
}
return time.Now()
}
type UserHeatmapResp struct {
MaxCount int64 `json:"max_count"`
Points []*UserHeatmap `json:"points"`

View File

@@ -31,6 +31,7 @@ func (d *DashboardRepo) CategoryStat(ctx context.Context, req domain.StatisticsF
var cs []domain.CategoryPoint
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.WorkModeNEQ("")).
Modify(func(s *sql.Selector) {
s.Select(
@@ -45,6 +46,7 @@ func (d *DashboardRepo) CategoryStat(ctx context.Context, req domain.StatisticsF
var ps []domain.CategoryPoint
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.ProgramLanguageNEQ("")).
Where(task.IsSuggested(true)).
Modify(func(s *sql.Selector) {
@@ -95,6 +97,7 @@ func (d *DashboardRepo) TimeStat(ctx context.Context, req domain.StatisticsFilte
udv := make([]DateValue, 0)
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Aggregate(func(s *sql.Selector) string {
return sql.As("COUNT(DISTINCT user_id)", "count")
}).
@@ -106,9 +109,10 @@ func (d *DashboardRepo) TimeStat(ctx context.Context, req domain.StatisticsFilte
ds := make([]DateValue, 0)
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Modify(func(s *sql.Selector) {
s.Select(
sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.Precision), "date"),
sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.MustPrecision()), "date"),
sql.As("COUNT(DISTINCT user_id)", "user_count"),
sql.As("COUNT(*) FILTER (WHERE model_type = 'llm')", "llm_count"),
sql.As("COUNT(*) FILTER (WHERE is_suggested = true AND model_type = 'coder')", "code_count"),
@@ -206,6 +210,7 @@ func (d *DashboardRepo) UserCodeRank(ctx context.Context, req domain.StatisticsF
var rs []UserCodeRank
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.IsAccept(true)).
Modify(func(s *sql.Selector) {
s.Select(
@@ -252,6 +257,7 @@ func (d *DashboardRepo) UserEvents(ctx context.Context, req domain.StatisticsFil
WithTaskRecords().
Where(task.ModelType(consts.ModelTypeLLM)).
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.HasUserWith(user.ID(id))).
Order(task.ByCreatedAt(sql.OrderDesc())).
Limit(100).
@@ -286,9 +292,10 @@ func (d *DashboardRepo) UserStat(ctx context.Context, req domain.StatisticsFilte
if err := d.db.Task.Query().
Where(task.HasUserWith(user.ID(id))).
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Modify(func(s *sql.Selector) {
s.Select(
sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.Precision), "date"),
sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.MustPrecision()), "date"),
sql.As("COUNT(DISTINCT user_id)", "user_count"),
sql.As("COUNT(*) FILTER (WHERE model_type = 'llm')", "llm_count"),
sql.As("COUNT(*) FILTER (WHERE is_suggested = true AND model_type = 'coder')", "code_count"),
@@ -305,6 +312,7 @@ func (d *DashboardRepo) UserStat(ctx context.Context, req domain.StatisticsFilte
var cs []domain.CategoryPoint
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.HasUserWith(user.ID(id))).
Where(task.WorkModeNEQ("")).
Modify(func(s *sql.Selector) {
@@ -321,6 +329,7 @@ func (d *DashboardRepo) UserStat(ctx context.Context, req domain.StatisticsFilte
var ps []domain.CategoryPoint
if err := d.db.Task.Query().
Where(task.CreatedAtGTE(req.StartTime())).
Where(task.CreatedAtLTE(req.EndTime())).
Where(task.HasUserWith(user.ID(id))).
Where(task.ProgramLanguageNEQ("")).
Where(task.IsSuggested(true)).

View File

@@ -217,58 +217,7 @@ const parseDateInput = (input: string) => {
};
const filterPresets = (obj: Record<string, any>, search: string) => {
if (!search) {
return obj;
}
const searchWords = search.toLowerCase().split("-").filter(Boolean);
const filtered = Object.fromEntries(
Object.entries(obj).filter(([_, value]) => {
const keyLower = value.text.toLowerCase();
return searchWords.every(word => keyLower.includes(word));
})
);
if (Object.entries(filtered).length > 0) {
return filtered;
}
const parsed = parseDateInput(search);
if (parsed) {
return parsed;
}
const numberMatch = search.match(/\d+/);
if (!numberMatch) {
return {};
}
const n = parseInt(numberMatch[0], 10);
const now = new Date();
return {
[`last-${n}-days`]: {
text: `Last ${n} Days`,
start: startOfDay(subDays(now, n)),
end: endOfDay(now)
},
[`last-${n}-weeks`]: {
text: `Last ${n} Weeks`,
start: startOfDay(subWeeks(now, n)),
end: endOfDay(now)
},
[`last-${n}-months`]: {
text: `Last ${n} Months`,
start: startOfDay(subMonths(now, n)),
end: endOfDay(now)
},
[`last-${n}-years`]: {
text: `Last ${n} Years`,
start: startOfDay(subYears(now, n)),
end: endOfDay(now)
}
};
return obj
};
const formatDateRange = (start: Date, end: Date, timezone: string) => {

View File

@@ -7,9 +7,8 @@ import {
import { SecondTimeRange } from '@/components/ui/calendar';
import {
getRecent60MinutesData,
getRecentDaysData,
getRecent24HoursData as getRecentHoursData,
getTimeRange,
getRangeData,
} from '@/utils';
import { Grid2 as Grid, styled } from '@mui/material';
import { useRequest } from 'ahooks';
@@ -55,15 +54,6 @@ const GlobalStatistic = ({
refreshDeps: [timeDuration],
});
const precision = useMemo(() => getTimeRange(timeDuration), [timeDuration]);
const getRangeData = (
data: Record<string, number>[],
precision: 'day' | 'hour',
label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' }
) => {
return precision === 'day'
? getRecentDaysData(data, label)
: getRecentHoursData(data, label);
};
const {
userActiveChartData,
@@ -83,12 +73,12 @@ const GlobalStatistic = ({
} = timeStatData || {};
const label = { valueLabel: 'value' };
return {
userActiveChartData: getRangeData(active_users, precision, label),
chatChartData: getRangeData(chats, precision, label),
codeCompletionChartData: getRangeData(code_completions, precision, label),
codeLineChartData: getRangeData(lines_of_code, precision, label),
userActiveChartData: getRangeData(timeDuration, active_users, precision, label),
chatChartData: getRangeData(timeDuration, chats, precision, label),
codeCompletionChartData: getRangeData(timeDuration, code_completions, precision, label),
codeLineChartData: getRangeData(timeDuration, lines_of_code, precision, label),
realTimeTokenChartData: getRecent60MinutesData(real_time_tokens, label),
acceptedPerChartData: getRangeData(accepted_per, precision, label),
acceptedPerChartData: getRangeData(timeDuration, accepted_per, precision, label),
};
}, [timeStatData, precision]);

View File

@@ -5,7 +5,7 @@ import {
} from '@/api/Dashboard';
import { DomainUser } from '@/api/types';
import { SecondTimeRange } from '@/components/ui/calendar';
import { getRecent24HoursData, getRecentDaysData, getTimeRange } from '@/utils';
import { getRangeData, getRecent24HoursData, getRecentDaysData, getTimeRange } from '@/utils';
import { Grid2 as Grid } from '@mui/material';
import { useRequest } from 'ahooks';
import { useMemo } from 'react';
@@ -16,8 +16,6 @@ import MemberInfo from './memberInfo';
import PieCharts from './pieCharts';
import { RecentActivityCard } from './statisticCard';
type Precision = 'day' | 'hour';
const MemberStatistic = ({
memberData,
userList,
@@ -67,16 +65,6 @@ const MemberStatistic = ({
}
);
const getRangeData = (
data: Record<string, number>[],
precision: Precision,
label: { keyLabel?: string; valueLabel?: string } = { valueLabel: 'value' }
) => {
return precision === 'day'
? getRecentDaysData(data, label)
: getRecent24HoursData(data, label);
};
const {
chatChartData,
codeCompletionChartData,
@@ -90,14 +78,15 @@ const MemberStatistic = ({
lines_of_code = [],
} = userStat || {};
const label = { valueLabel: 'value' };
const chatChartData = getRangeData(chats, precision, label);
const chatChartData = getRangeData(timeDuration, chats, precision, label);
const codeCompletionChartData = getRangeData(
timeDuration,
code_completions,
precision,
label
);
const codeLineChartData = getRangeData(lines_of_code, precision, label);
const acceptedPerChartData = getRangeData(accepted_per, precision, label);
const codeLineChartData = getRangeData(timeDuration, lines_of_code, precision, label);
const acceptedPerChartData = getRangeData(timeDuration, accepted_per, precision, label);
return {
chatChartData,
codeCompletionChartData,

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { getListUser } from '@/api/User';
import { Stack, MenuItem, Select, Box } from '@mui/material';
import { Stack, Box } from '@mui/material';
import { CusTabs } from '@c-x/ui';
import GlobalStatistic from './components/globalStatistic';
import { useRequest } from 'ahooks';
@@ -10,14 +10,16 @@ import { useNavigate } from 'react-router-dom';
import { DomainUser } from '@/api/types';
import { v1LicenseList } from '@/api';
import { Calendar, RangeValue } from '@/components/ui/calendar';
import { endOfDay, startOfDay, subDays, subMonths, subWeeks } from 'date-fns';
import { subDays, subMonths, subWeeks } from 'date-fns';
import { getTimeRange } from '@/utils';
const get24HoursRange = () => {
return {
start: startOfDay(subDays(new Date(), 1)),
end: endOfDay(new Date()),
start: subDays(new Date(), 1),
end: new Date(),
};
};
const presets = {
'last-1-days': {
text: '最近 24 小时',
@@ -25,23 +27,38 @@ const presets = {
},
'last-3-days': {
text: '最近 3 天',
start: startOfDay(subDays(new Date(), 3)),
end: endOfDay(new Date()),
start: subDays(new Date(), 3),
end: new Date(),
},
'last-7-days': {
text: '最近 7 天',
start: startOfDay(subWeeks(new Date(), 1)),
end: endOfDay(new Date()),
start: subWeeks(new Date(), 1),
end: new Date(),
},
'last-14-days': {
text: '最近 14 天',
start: startOfDay(subWeeks(new Date(), 2)),
end: endOfDay(new Date()),
start: subWeeks(new Date(), 2),
end: new Date(),
},
'last-month': {
text: '最近 1 月',
start: startOfDay(subMonths(new Date(), 1)),
end: endOfDay(new Date()),
start: subMonths(new Date(), 1),
end: new Date(),
},
'last-3-months': {
text: '最近 3 月',
start: subMonths(new Date(), 3),
end: new Date(),
},
'last-half-year': {
text: '最近半年',
start: subMonths(new Date(), 6),
end: new Date(),
},
'last-year': {
text: '最近一年',
start: subMonths(new Date(), 12),
end: new Date(),
},
};
export type TimeRange = '90d' | '24h';
@@ -96,22 +113,31 @@ const Dashboard = () => {
};
const handleTimeRangeChange = (value: any) => {
if (value) {
console.log(value)
setTimeRange(value);
} else {
setTimeRange(get24HoursRange());
}
};
const secondValue = useMemo(() => {
return {
start_at: timeRange.start
? Math.floor(timeRange.start?.getTime() / 1000)
: 0,
end_at: timeRange.end ? Math.floor(timeRange.end?.getTime() / 1000) : 0,
precision: getTimeRange({
start_at: timeRange.start
? Math.floor(timeRange.start?.getTime() / 1000)
: 0,
end_at: timeRange.end ? Math.floor(timeRange.end?.getTime() / 1000) : 0,
}),
};
}, [timeRange]);
return (
<Stack gap={2} sx={{ height: '100%' }}>
<Stack direction='row' gap={2} alignItems='center'>
<Stack direction='row' gap={2} justifyContent='space-between' alignItems='center'>
<CusTabs
value={tabValue}
onChange={onTabChange}
@@ -137,10 +163,11 @@ const Dashboard = () => {
/>
<Box sx={{ py: '4px', pr: 5 }}>
<Calendar
isDocsPage
allowClear
disabled={license?.edition !== 2}
onChange={handleTimeRangeChange}
presets={presets}
presetIndex={0}
value={timeRange}
/>
</Box>

View File

@@ -157,7 +157,8 @@ export const getRedirectUrl = (source: 'user' | 'admin' = 'admin') => {
export const getRecentDaysData = (
data: Record<string, number>[] = [],
label: { keyLabel?: string; valueLabel?: string } = {}
label: { keyLabel?: string; valueLabel?: string } = {},
durationDay: number = 90
) => {
const { keyLabel = 'timestamp', valueLabel = 'tokens' } = label;
const xData: string[] = [];
@@ -169,7 +170,7 @@ export const getRecentDaysData = (
item[valueLabel]!;
});
for (let i = 0; i < 90; i++) {
for (let i = 0; i < durationDay; i++) {
const time = dayjs().startOf('day').subtract(i, 'day').format('YYYY-MM-DD');
if (dateMap[time]) {
xData.unshift(time);
@@ -269,8 +270,60 @@ export const getBaseLanguageId = (languageId: string): string => {
export const getTimeRange = (timeDuration: SecondTimeRange) => {
const diff = timeDuration.end_at - timeDuration.start_at;
if (diff > 24 * 60 * 60 * 1000) {
if (diff > 365 * 24 * 60 * 60) {
return 'month';
}
if (diff > 17* 24 * 60 * 60) {
return 'day';
}
return 'hour';
};
export const getRangeData = (
timeDuration: { start_at: number; end_at: number },
data: { timestamp?: number; value?: number }[],
precision: 'month' | 'day' | 'hour',
label: { keyLabel?: string; valueLabel?: string } = { keyLabel: 'timestamp', valueLabel: 'value' }
) => {
const { keyLabel = 'timestamp', valueLabel = 'value' } = label;
const xData: (string | number)[] = [];
const yData: number[] = [];
const dateMap: Record<string, number> = {};
// 将原始数据转换为时间映射
data.forEach((item) => {
const timestampValue = item[keyLabel as keyof typeof item] as number;
const dataValue = item[valueLabel as keyof typeof item] as number;
if (timestampValue && dataValue !== undefined) {
// API返回的timestamp是秒直接使用dayjs.unix()
const timeKey = precision === 'day'
? dayjs.unix(timestampValue).format('YYYY-MM-DD')
: dayjs.unix(timestampValue).format('YYYY-MM-DD HH:00');
dateMap[timeKey] = dataValue;
}
});
// timeDuration中的时间戳是毫秒需要除以1000
const startTime = dayjs.unix(timeDuration.start_at);
const endTime = dayjs.unix(timeDuration.end_at);
// 根据 precision 对齐时间点
const startPoint = precision === 'day' ? startTime.startOf('day') : startTime.startOf('hour');
const endPoint = precision === 'day' ? endTime.startOf('day') : endTime.startOf('hour');
// 从开始时间到结束时间,逐个补齐数据点
let currentTime = startPoint;
while (currentTime.unix() <= endPoint.unix()) {
const timeKey = precision === 'day'
? currentTime.format('YYYY-MM-DD')
: currentTime.format('YYYY-MM-DD HH:00');
xData.push(timeKey);
yData.push(dateMap[timeKey] || 0);
currentTime = currentTime.add(1, precision);
}
return { xData, yData };
};