From 606a4eee96ef08e0843d20ba4c776a5258fe0c61 Mon Sep 17 00:00:00 2001 From: CaIon Date: Wed, 8 Apr 2026 15:43:29 +0800 Subject: [PATCH] feat(dashboard): add admin user analytics and fix chart labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/data/users endpoint for user-grouped quota data (admin only) - Add user consumption ranking (horizontal bar, top 10) and user consumption trend (area chart) tabs visible only to admin users - Fix mislabeled "消耗趋势" tab to "调用趋势" (shows call counts, not quota) - Add processUserData helper for user ranking and trend data extraction - Add i18n keys for new tabs across all 7 locales --- controller/usedata.go | 15 ++ model/usedata.go | 10 ++ router/api-router.go | 1 + web/src/components/dashboard/ChartsPanel.jsx | 17 ++- web/src/components/dashboard/index.jsx | 15 ++ web/src/helpers/dashboard.jsx | 55 ++++++++ .../hooks/dashboard/useDashboardCharts.jsx | 131 +++++++++++++++++- web/src/hooks/dashboard/useDashboardData.js | 22 +++ web/src/i18n/locales/en.json | 4 + web/src/i18n/locales/fr.json | 4 + web/src/i18n/locales/ja.json | 4 + web/src/i18n/locales/ru.json | 4 + web/src/i18n/locales/vi.json | 4 + web/src/i18n/locales/zh-CN.json | 4 + web/src/i18n/locales/zh-TW.json | 4 + 15 files changed, 287 insertions(+), 7 deletions(-) diff --git a/controller/usedata.go b/controller/usedata.go index 816988a2..5e194c51 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) { return } +func GetQuotaDatesByUser(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) +} + func GetUserQuotaDates(c *gin.Context) { userId := c.GetInt("id") startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) diff --git a/model/usedata.go b/model/usedata.go index f84beb8d..f0ea055a 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData return quotaDatas, err } +func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + var quotaDatas []*QuotaData + err = DB.Table("quota_data"). + Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("created_at >= ? and created_at <= ?", startTime, endTime). + Group("username, created_at"). + Find("aDatas).Error + return quotaDatas, err +} + func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) { if username != "" { return GetQuotaDataByUsername(username, startTime, endTime) diff --git a/router/api-router.go b/router/api-router.go index bff158a8..acc2241b 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -293,6 +293,7 @@ func SetApiRouter(router *gin.Engine) { dataRoute := apiRouter.Group("/data") dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) + dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser) dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) diff --git a/web/src/components/dashboard/ChartsPanel.jsx b/web/src/components/dashboard/ChartsPanel.jsx index 0992adac..0034ffd9 100644 --- a/web/src/components/dashboard/ChartsPanel.jsx +++ b/web/src/components/dashboard/ChartsPanel.jsx @@ -29,6 +29,9 @@ const ChartsPanel = ({ spec_model_line, spec_pie, spec_rank_bar, + spec_user_rank, + spec_user_trend, + isAdminUser, CARD_PROPS, CHART_CONFIG, FLEX_CENTER_GAP2, @@ -51,9 +54,15 @@ const ChartsPanel = ({ onChange={setActiveChartTab} > {t('消耗分布')}} itemKey='1' /> - {t('消耗趋势')}} itemKey='2' /> + {t('调用趋势')}} itemKey='2' /> {t('调用次数分布')}} itemKey='3' /> {t('调用次数排行')}} itemKey='4' /> + {isAdminUser && ( + {t('用户消耗排行')}} itemKey='5' /> + )} + {isAdminUser && ( + {t('用户消耗趋势')}} itemKey='6' /> + )} } @@ -72,6 +81,12 @@ const ChartsPanel = ({ {activeChartTab === '4' && ( )} + {activeChartTab === '5' && isAdminUser && ( + + )} + {activeChartTab === '6' && isAdminUser && ( + + )} ); diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx index b032d07d..811e23ca 100644 --- a/web/src/components/dashboard/index.jsx +++ b/web/src/components/dashboard/index.jsx @@ -86,12 +86,22 @@ const Dashboard = () => { ); // ========== 数据处理 ========== + const loadUserData = async () => { + if (dashboardData.isAdminUser) { + const userData = await dashboardData.loadUserQuotaData(); + if (userData && userData.length > 0) { + dashboardCharts.updateUserChartData(userData); + } + } + }; + const initChart = async () => { await dashboardData.loadQuotaData().then((data) => { if (data && data.length > 0) { dashboardCharts.updateChartData(data); } }); + await loadUserData(); await dashboardData.loadUptimeData(); }; @@ -100,10 +110,12 @@ const Dashboard = () => { if (data && data.length > 0) { dashboardCharts.updateChartData(data); } + await loadUserData(); }; const handleSearchConfirm = async () => { await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData); + await loadUserData(); }; // ========== 数据准备 ========== @@ -182,6 +194,9 @@ const Dashboard = () => { spec_model_line={dashboardCharts.spec_model_line} spec_pie={dashboardCharts.spec_pie} spec_rank_bar={dashboardCharts.spec_rank_bar} + spec_user_rank={dashboardCharts.spec_user_rank} + spec_user_trend={dashboardCharts.spec_user_trend} + isAdminUser={dashboardData.isAdminUser} CARD_PROPS={CARD_PROPS} CHART_CONFIG={CHART_CONFIG} FLEX_CENTER_GAP2={FLEX_CENTER_GAP2} diff --git a/web/src/helpers/dashboard.jsx b/web/src/helpers/dashboard.jsx index d93d0461..a7a30bf6 100644 --- a/web/src/helpers/dashboard.jsx +++ b/web/src/helpers/dashboard.jsx @@ -387,3 +387,58 @@ export const generateChartTimePoints = ( return chartTimePoints; }; + +// ========== 用户维度数据处理 ========== +export const processUserData = (data, dataExportDefaultTime, limit = 10) => { + const userQuotaTotal = new Map(); + data.forEach((item) => { + const prev = userQuotaTotal.get(item.username) || 0; + userQuotaTotal.set(item.username, prev + item.quota); + }); + + const sorted = Array.from(userQuotaTotal.entries()).sort( + (a, b) => b[1] - a[1], + ); + const topUsers = sorted.slice(0, limit).map(([u]) => u); + const topUserSet = new Set(topUsers); + + const rankingData = sorted.slice(0, limit).map(([username, quota]) => ({ + User: username, + Quota: quota, + })); + + const showYear = isDataCrossYear(data.map((item) => item.created_at)); + + const timeUserMap = new Map(); + const allTimePoints = new Set(); + + data.forEach((item) => { + const timeKey = timestamp2string1( + item.created_at, + dataExportDefaultTime, + showYear, + ); + allTimePoints.add(timeKey); + const user = topUserSet.has(item.username) ? item.username : null; + if (!user) return; + const key = `${timeKey}-${user}`; + const prev = timeUserMap.get(key) || { quota: 0 }; + timeUserMap.set(key, { quota: prev.quota + item.quota }); + }); + + const sortedTimePoints = Array.from(allTimePoints).sort(); + const trendData = []; + sortedTimePoints.forEach((time) => { + topUsers.forEach((user) => { + const key = `${time}-${user}`; + const val = timeUserMap.get(key); + trendData.push({ + Time: time, + User: user, + Quota: val?.quota || 0, + }); + }); + }); + + return { rankingData, trendData, topUsers }; +}; diff --git a/web/src/hooks/dashboard/useDashboardCharts.jsx b/web/src/hooks/dashboard/useDashboardCharts.jsx index 6144b7f9..d101a6c5 100644 --- a/web/src/hooks/dashboard/useDashboardCharts.jsx +++ b/web/src/hooks/dashboard/useDashboardCharts.jsx @@ -34,8 +34,14 @@ import { updateChartSpec, updateMapValue, initializeMaps, + processUserData, } from '../../helpers/dashboard'; +const USER_COLORS = [ + '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', + '#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6', +]; + export const useDashboardCharts = ( dataExportDefaultTime, setTrendData, @@ -179,7 +185,6 @@ export const useDashboardCharts = ( }, }); - // 模型消耗趋势折线图 const [spec_model_line, setSpecModelLine] = useState({ type: 'line', data: [ @@ -197,7 +202,7 @@ export const useDashboardCharts = ( }, title: { visible: true, - text: t('模型消耗趋势'), + text: t('调用趋势'), subtext: '', }, tooltip: { @@ -215,7 +220,6 @@ export const useDashboardCharts = ( }, }); - // 模型调用次数排行柱状图 const [spec_rank_bar, setSpecRankBar] = useState({ type: 'bar', data: [ @@ -259,6 +263,76 @@ export const useDashboardCharts = ( }, }); + // ========== Admin: 用户消耗排行 ========== + const [spec_user_rank, setSpecUserRank] = useState({ + type: 'bar', + data: [{ id: 'userRankData', values: [] }], + xField: 'rawQuota', + yField: 'User', + seriesField: 'User', + direction: 'horizontal', + legends: { visible: false }, + title: { + visible: true, + text: t('用户消耗排行'), + subtext: '', + }, + bar: { + state: { hover: { stroke: '#000', lineWidth: 1 } }, + }, + label: { + visible: true, + position: 'outside', + formatMethod: (value, datum) => renderQuota(datum['rawQuota'] || 0, 2), + }, + axes: [{ + orient: 'left', + type: 'band', + label: { visible: true }, + }, { + orient: 'bottom', + type: 'linear', + visible: false, + }], + tooltip: { + mark: { + content: [{ + key: (datum) => datum['User'], + value: (datum) => renderQuota(datum['rawQuota'] || 0, 4), + }], + }, + }, + color: { type: 'ordinal', range: USER_COLORS }, + }); + + // ========== Admin: 用户消耗趋势 ========== + const [spec_user_trend, setSpecUserTrend] = useState({ + type: 'area', + data: [{ id: 'userTrendData', values: [] }], + xField: 'Time', + yField: 'rawQuota', + seriesField: 'User', + stack: false, + legends: { visible: true, selectMode: 'single' }, + title: { + visible: true, + text: t('用户消耗趋势'), + subtext: '', + }, + area: { style: { fillOpacity: 0.15 } }, + line: { style: { lineWidth: 2 } }, + point: { visible: false }, + tooltip: { + mark: { + content: [{ + key: (datum) => datum['User'], + value: (datum) => renderQuota(datum['rawQuota'] || 0, 4), + }], + }, + }, + color: { type: 'ordinal', range: USER_COLORS }, + }); + // ========== 数据处理函数 ========== const generateModelColors = useCallback((uniqueModels, modelColors) => { const newModelColors = {}; @@ -426,6 +500,51 @@ export const useDashboardCharts = ( ], ); + // ========== 用户维度图表数据处理 ========== + const updateUserChartData = useCallback( + (data) => { + const { rankingData, trendData: userTrend } = processUserData( + data, + dataExportDefaultTime, + 10, + ); + + const userRankValues = rankingData.map((item) => ({ + User: item.User, + rawQuota: item.Quota, + Quota: getQuotaWithUnit(item.Quota, 4), + })).sort((a, b) => a.rawQuota - b.rawQuota); + + const totalUserQuota = rankingData.reduce((s, i) => s + i.Quota, 0); + + setSpecUserRank((prev) => ({ + ...prev, + data: [{ id: 'userRankData', values: userRankValues }], + title: { + ...prev.title, + subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`, + }, + })); + + const userTrendValues = userTrend.map((item) => ({ + Time: item.Time, + User: item.User, + rawQuota: item.Quota, + Usage: item.Quota ? getQuotaWithUnit(item.Quota, 4) : 0, + })); + + setSpecUserTrend((prev) => ({ + ...prev, + data: [{ id: 'userTrendData', values: userTrendValues }], + title: { + ...prev.title, + subtext: `${t('总计')}:${renderQuota(totalUserQuota, 2)}`, + }, + })); + }, + [dataExportDefaultTime, t], + ); + // ========== 初始化图表主题 ========== useEffect(() => { initVChartSemiTheme({ @@ -434,14 +553,14 @@ export const useDashboardCharts = ( }, []); return { - // 图表规格 spec_pie, spec_line, spec_model_line, spec_rank_bar, - - // 函数 + spec_user_rank, + spec_user_trend, updateChartData, + updateUserChartData, generateModelColors, }; }; diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index b51bcc40..e9b2cad8 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -213,6 +213,27 @@ export const useDashboardData = (userState, userDispatch, statusState) => { } }, [activeUptimeTab]); + const loadUserQuotaData = useCallback(async () => { + if (!isAdminUser) return []; + try { + const { start_timestamp, end_timestamp } = inputs; + const localStartTimestamp = Date.parse(start_timestamp) / 1000; + const localEndTimestamp = Date.parse(end_timestamp) / 1000; + const url = `/api/data/users?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; + const res = await API.get(url); + const { success, message, data } = res.data; + if (success) { + return data || []; + } else { + showError(message); + return []; + } + } catch (err) { + console.error(err); + return []; + } + }, [inputs, isAdminUser]); + const getUserData = useCallback(async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; @@ -311,6 +332,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => { showSearchModal, handleCloseModal, loadQuotaData, + loadUserQuotaData, loadUptimeData, getUserData, refresh, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7436fdd9..26b9d32d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3066,6 +3066,10 @@ "调用次数": "Call Count", "调用次数分布": "Models call distribution", "调用次数排行": "Models call ranking", + "调用趋势": "Call trend", + "模型排行": "Model ranking", + "用户消耗排行": "User consumption ranking", + "用户消耗趋势": "User consumption trend", "调试信息": "Debug information", "谨慎": "Cautious", "豆包": "Doubao", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 125b4b0f..5154cedb 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3039,6 +3039,10 @@ "调用次数": "Nombre d'appels", "调用次数分布": "Distribution des appels de modèles", "调用次数排行": "Classement des appels de modèles", + "调用趋势": "Tendance des appels", + "模型排行": "Classement des modèles", + "用户消耗排行": "Classement de consommation des utilisateurs", + "用户消耗趋势": "Tendance de consommation des utilisateurs", "调试信息": "Informations de débogage", "谨慎": "Prudent", "豆包": "Doubao", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 66212683..72b20439 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3020,6 +3020,10 @@ "调用次数": "呼び出し回数", "调用次数分布": "呼び出し回数分布", "调用次数排行": "呼び出し回数ランキング", + "调用趋势": "呼び出し推移", + "模型排行": "モデルランキング", + "用户消耗排行": "ユーザー消費ランキング", + "用户消耗趋势": "ユーザー消費推移", "调试信息": "デバッグ情報", "谨慎": "注意", "豆包": "豆包", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 024d2724..c6c26957 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3053,6 +3053,10 @@ "调用次数": "Количество вызовов", "调用次数分布": "Распределение количества вызовов", "调用次数排行": "Рейтинг количества вызовов", + "调用趋势": "Тенденция вызовов", + "模型排行": "Рейтинг моделей", + "用户消耗排行": "Рейтинг потребления пользователей", + "用户消耗趋势": "Тенденция потребления пользователей", "调试信息": "Отладочная информация", "谨慎": "Осторожно", "豆包": "Doubao", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 9552d37d..ddc6910d 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3472,6 +3472,10 @@ "调用次数": "Số lần gọi", "调用次数分布": "Phân phối số lần gọi", "调用次数排行": "Xếp hạng số lần gọi", + "调用趋势": "Xu hướng cuộc gọi", + "模型排行": "Xếp hạng mô hình", + "用户消耗排行": "Xếp hạng tiêu thụ người dùng", + "用户消耗趋势": "Xu hướng tiêu thụ người dùng", "调试信息": "Thông tin gỡ lỗi", "谨慎": "Thận trọng", "豆包": "Doubao", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index aeeefb5c..cc6bed8a 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2314,6 +2314,10 @@ "调用次数": "调用次数", "调用次数分布": "调用次数分布", "调用次数排行": "调用次数排行", + "调用趋势": "调用趋势", + "模型排行": "模型排行", + "用户消耗排行": "用户消耗排行", + "用户消耗趋势": "用户消耗趋势", "调试信息": "调试信息", "谨慎": "谨慎", "警告": "警告", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index a040c00b..7b0d2d1a 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2719,6 +2719,10 @@ "调用次数": "調用次數", "调用次数分布": "調用次數分佈", "调用次数排行": "調用次數排行", + "调用趋势": "調用趨勢", + "模型排行": "模型排行", + "用户消耗排行": "用戶消耗排行", + "用户消耗趋势": "用戶消耗趨勢", "调试信息": "除錯訊息", "谨慎": "謹慎", "豆包": "豆包",