From 141a1cc98bffbb4a83457ce04b273c9224640a4a Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Thu, 28 Aug 2025 17:08:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E8=AE=A1=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/domain/dashboard.go | 25 +++++++- backend/internal/dashboard/repo/dashboard.go | 13 +++- ui/src/components/ui/calendar.tsx | 53 +---------------- .../dashboard/components/globalStatistic.tsx | 22 ++----- .../dashboard/components/memberStatistic.tsx | 21 ++----- ui/src/pages/dashboard/index.tsx | 55 ++++++++++++----- ui/src/utils/index.ts | 59 ++++++++++++++++++- 7 files changed, 142 insertions(+), 106 deletions(-) diff --git a/backend/domain/dashboard.go b/backend/domain/dashboard.go index 170d6ee..ef8b01f 100644 --- a/backend/domain/dashboard.go +++ b/backend/domain/dashboard.go @@ -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"` diff --git a/backend/internal/dashboard/repo/dashboard.go b/backend/internal/dashboard/repo/dashboard.go index 9cba2b9..54e322b 100644 --- a/backend/internal/dashboard/repo/dashboard.go +++ b/backend/internal/dashboard/repo/dashboard.go @@ -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)). diff --git a/ui/src/components/ui/calendar.tsx b/ui/src/components/ui/calendar.tsx index 90706de..1b11251 100644 --- a/ui/src/components/ui/calendar.tsx +++ b/ui/src/components/ui/calendar.tsx @@ -217,58 +217,7 @@ const parseDateInput = (input: string) => { }; const filterPresets = (obj: Record, 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) => { diff --git a/ui/src/pages/dashboard/components/globalStatistic.tsx b/ui/src/pages/dashboard/components/globalStatistic.tsx index 675810e..a325a98 100644 --- a/ui/src/pages/dashboard/components/globalStatistic.tsx +++ b/ui/src/pages/dashboard/components/globalStatistic.tsx @@ -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[], - 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]); diff --git a/ui/src/pages/dashboard/components/memberStatistic.tsx b/ui/src/pages/dashboard/components/memberStatistic.tsx index a7062bc..7d9faf2 100644 --- a/ui/src/pages/dashboard/components/memberStatistic.tsx +++ b/ui/src/pages/dashboard/components/memberStatistic.tsx @@ -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[], - 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, diff --git a/ui/src/pages/dashboard/index.tsx b/ui/src/pages/dashboard/index.tsx index d0a4d39..fa69d1b 100644 --- a/ui/src/pages/dashboard/index.tsx +++ b/ui/src/pages/dashboard/index.tsx @@ -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 ( - + { /> diff --git a/ui/src/utils/index.ts b/ui/src/utils/index.ts index fa2a628..d278dfc 100644 --- a/ui/src/utils/index.ts +++ b/ui/src/utils/index.ts @@ -157,7 +157,8 @@ export const getRedirectUrl = (source: 'user' | 'admin' = 'admin') => { export const getRecentDaysData = ( data: Record[] = [], - 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 = {}; + + // 将原始数据转换为时间映射 + 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 }; +}; \ No newline at end of file