Przeglądaj źródła

feat(dashboard): add admin user analytics and fix chart labels

- 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
CaIon 2 miesięcy temu
rodzic
commit
606a4eee96

+ 15 - 0
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)

+ 10 - 0
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(&quotaDatas).Error
+	return quotaDatas, err
+}
+
 func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
 	if username != "" {
 		return GetQuotaDataByUsername(username, startTime, endTime)

+ 1 - 0
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())

+ 16 - 1
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}
           >
             <TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
-            <TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
+            <TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
             <TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
             <TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
+            {isAdminUser && (
+              <TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
+            )}
+            {isAdminUser && (
+              <TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
+            )}
           </Tabs>
         </div>
       }
@@ -72,6 +81,12 @@ const ChartsPanel = ({
         {activeChartTab === '4' && (
           <VChart spec={spec_rank_bar} option={CHART_CONFIG} />
         )}
+        {activeChartTab === '5' && isAdminUser && (
+          <VChart spec={spec_user_rank} option={CHART_CONFIG} />
+        )}
+        {activeChartTab === '6' && isAdminUser && (
+          <VChart spec={spec_user_trend} option={CHART_CONFIG} />
+        )}
       </div>
     </Card>
   );

+ 15 - 0
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}

+ 55 - 0
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 };
+};

+ 125 - 6
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,
   };
 };

+ 22 - 0
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,

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
web/src/i18n/locales/ja.json

@@ -3020,6 +3020,10 @@
     "调用次数": "呼び出し回数",
     "调用次数分布": "呼び出し回数分布",
     "调用次数排行": "呼び出し回数ランキング",
+    "调用趋势": "呼び出し推移",
+    "模型排行": "モデルランキング",
+    "用户消耗排行": "ユーザー消費ランキング",
+    "用户消耗趋势": "ユーザー消費推移",
     "调试信息": "デバッグ情報",
     "谨慎": "注意",
     "豆包": "豆包",

+ 4 - 0
web/src/i18n/locales/ru.json

@@ -3053,6 +3053,10 @@
     "调用次数": "Количество вызовов",
     "调用次数分布": "Распределение количества вызовов",
     "调用次数排行": "Рейтинг количества вызовов",
+    "调用趋势": "Тенденция вызовов",
+    "模型排行": "Рейтинг моделей",
+    "用户消耗排行": "Рейтинг потребления пользователей",
+    "用户消耗趋势": "Тенденция потребления пользователей",
     "调试信息": "Отладочная информация",
     "谨慎": "Осторожно",
     "豆包": "Doubao",

+ 4 - 0
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",

+ 4 - 0
web/src/i18n/locales/zh-CN.json

@@ -2314,6 +2314,10 @@
     "调用次数": "调用次数",
     "调用次数分布": "调用次数分布",
     "调用次数排行": "调用次数排行",
+    "调用趋势": "调用趋势",
+    "模型排行": "模型排行",
+    "用户消耗排行": "用户消耗排行",
+    "用户消耗趋势": "用户消耗趋势",
     "调试信息": "调试信息",
     "谨慎": "谨慎",
     "警告": "警告",

+ 4 - 0
web/src/i18n/locales/zh-TW.json

@@ -2719,6 +2719,10 @@
     "调用次数": "調用次數",
     "调用次数分布": "調用次數分佈",
     "调用次数排行": "調用次數排行",
+    "调用趋势": "調用趨勢",
+    "模型排行": "模型排行",
+    "用户消耗排行": "用戶消耗排行",
+    "用户消耗趋势": "用戶消耗趨勢",
     "调试信息": "除錯訊息",
     "谨慎": "謹慎",
     "豆包": "豆包",