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
This commit is contained in:
CaIon
2026-04-08 15:43:29 +08:00
parent 9ffb85a36b
commit 606a4eee96
15 changed files with 287 additions and 7 deletions
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -3020,6 +3020,10 @@
"调用次数": "呼び出し回数",
"调用次数分布": "呼び出し回数分布",
"调用次数排行": "呼び出し回数ランキング",
"调用趋势": "呼び出し推移",
"模型排行": "モデルランキング",
"用户消耗排行": "ユーザー消費ランキング",
"用户消耗趋势": "ユーザー消費推移",
"调试信息": "デバッグ情報",
"谨慎": "注意",
"豆包": "豆包",
+4
View File
@@ -3053,6 +3053,10 @@
"调用次数": "Количество вызовов",
"调用次数分布": "Распределение количества вызовов",
"调用次数排行": "Рейтинг количества вызовов",
"调用趋势": "Тенденция вызовов",
"模型排行": "Рейтинг моделей",
"用户消耗排行": "Рейтинг потребления пользователей",
"用户消耗趋势": "Тенденция потребления пользователей",
"调试信息": "Отладочная информация",
"谨慎": "Осторожно",
"豆包": "Doubao",
+4
View File
@@ -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
View File
@@ -2314,6 +2314,10 @@
"调用次数": "调用次数",
"调用次数分布": "调用次数分布",
"调用次数排行": "调用次数排行",
"调用趋势": "调用趋势",
"模型排行": "模型排行",
"用户消耗排行": "用户消耗排行",
"用户消耗趋势": "用户消耗趋势",
"调试信息": "调试信息",
"谨慎": "谨慎",
"警告": "警告",
+4
View File
@@ -2719,6 +2719,10 @@
"调用次数": "調用次數",
"调用次数分布": "調用次數分佈",
"调用次数排行": "調用次數排行",
"调用趋势": "調用趨勢",
"模型排行": "模型排行",
"用户消耗排行": "用戶消耗排行",
"用户消耗趋势": "用戶消耗趨勢",
"调试信息": "除錯訊息",
"谨慎": "謹慎",
"豆包": "豆包",