diff --git a/controller/log.go b/controller/log.go
index cf3825f16d5c..bb51d9bd8327 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -112,9 +112,12 @@ func GetLogsStat(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
- "quota": stat.Quota,
- "rpm": stat.Rpm,
- "tpm": stat.Tpm,
+ "quota": stat.Quota,
+ "token": stat.Token,
+ "prompt_tokens": stat.PromptTokens,
+ "completion_tokens": stat.CompletionTokens,
+ "rpm": stat.Rpm,
+ "tpm": stat.Tpm,
},
})
return
@@ -139,9 +142,12 @@ func GetLogsSelfStat(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
- "quota": quotaNum.Quota,
- "rpm": quotaNum.Rpm,
- "tpm": quotaNum.Tpm,
+ "quota": quotaNum.Quota,
+ "token": quotaNum.Token,
+ "prompt_tokens": quotaNum.PromptTokens,
+ "completion_tokens": quotaNum.CompletionTokens,
+ "rpm": quotaNum.Rpm,
+ "tpm": quotaNum.Tpm,
//"token": tokenNum,
},
})
diff --git a/controller/usedata.go b/controller/usedata.go
index 816988a2bbcb..7f3a7944928f 100644
--- a/controller/usedata.go
+++ b/controller/usedata.go
@@ -14,7 +14,12 @@ func GetAllQuotaDates(c *gin.Context) {
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
username := c.Query("username")
- dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
+ modelName := c.Query("model_name")
+ userId, _ := strconv.Atoi(c.Query("user_id"))
+ if userId == 0 {
+ userId, _ = strconv.Atoi(c.Query("userId"))
+ }
+ dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username, userId, modelName)
if err != nil {
common.ApiError(c, err)
return
@@ -31,6 +36,7 @@ func GetUserQuotaDates(c *gin.Context) {
userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
+ modelName := c.Query("model_name")
// 判断时间跨度是否超过 1 个月
if endTimestamp-startTimestamp > 2592000 {
c.JSON(http.StatusOK, gin.H{
@@ -39,7 +45,7 @@ func GetUserQuotaDates(c *gin.Context) {
})
return
}
- dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
+ dates, err := model.GetQuotaDataByUserIdAndModel(userId, modelName, startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
diff --git a/model/log.go b/model/log.go
index 68bc650469bc..c1ca56d9d6fa 100644
--- a/model/log.go
+++ b/model/log.go
@@ -375,16 +375,19 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
}
type Stat struct {
- Quota int `json:"quota"`
- Rpm int `json:"rpm"`
- Tpm int `json:"tpm"`
+ Quota int `json:"quota"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ Token int `json:"token"`
+ Rpm int `json:"rpm"`
+ Tpm int `json:"tpm"`
}
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
- tx := LOG_DB.Table("logs").Select("sum(quota) quota")
+ tx := LOG_DB.Table("logs").Select("COALESCE(SUM(quota), 0) quota, COALESCE(SUM(prompt_tokens), 0) prompt_tokens, COALESCE(SUM(completion_tokens), 0) completion_tokens")
// 为rpm和tpm创建单独的查询
- rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
+ rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0) tpm")
if username != "" {
tx = tx.Where("username = ?", username)
@@ -428,16 +431,25 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
common.SysError("failed to query log stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
- if err := rpmTpmQuery.Scan(&stat).Error; err != nil {
+
+ stat.Token = stat.PromptTokens + stat.CompletionTokens
+
+ var rpmTpmStat struct {
+ Rpm int `json:"rpm"`
+ Tpm int `json:"tpm"`
+ }
+ if err := rpmTpmQuery.Scan(&rpmTpmStat).Error; err != nil {
common.SysError("failed to query rpm/tpm stat: " + err.Error())
return stat, errors.New("查询统计数据失败")
}
+ stat.Rpm = rpmTpmStat.Rpm
+ stat.Tpm = rpmTpmStat.Tpm
return stat, nil
}
func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) {
- tx := LOG_DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)")
+ tx := LOG_DB.Table("logs").Select("COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0)")
if username != "" {
tx = tx.Where("username = ?", username)
}
diff --git a/model/usedata.go b/model/usedata.go
index f84beb8d9044..ab31969a808e 100644
--- a/model/usedata.go
+++ b/model/usedata.go
@@ -102,27 +102,48 @@ func increaseQuotaData(userId int, username string, modelName string, count int,
}
func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
+ return GetQuotaDataByUsernameAndModel(username, "", startTime, endTime)
+}
+
+func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
+ return GetQuotaDataByUserIdAndModel(userId, "", startTime, endTime)
+}
+
+func GetQuotaDataByUsernameAndModel(username string, modelName string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
- // 从quota_data表中查询数据
- err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error
+ tx := DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime)
+ if modelName != "" {
+ tx = tx.Where("model_name = ?", modelName)
+ }
+ err = tx.Find("aDatas).Error
return quotaDatas, err
}
-func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
+func GetQuotaDataByUserIdAndModel(userId int, modelName string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
var quotaDatas []*QuotaData
- // 从quota_data表中查询数据
- err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error
+ tx := DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime)
+ if modelName != "" {
+ tx = tx.Where("model_name = ?", modelName)
+ }
+ err = tx.Find("aDatas).Error
return quotaDatas, err
}
-func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
+func GetAllQuotaDates(startTime int64, endTime int64, username string, userId int, modelName string) (quotaData []*QuotaData, err error) {
+ if userId != 0 {
+ return GetQuotaDataByUserIdAndModel(userId, modelName, startTime, endTime)
+ }
if username != "" {
- return GetQuotaDataByUsername(username, startTime, endTime)
+ return GetQuotaDataByUsernameAndModel(username, modelName, startTime, endTime)
}
var quotaDatas []*QuotaData
// 从quota_data表中查询数据
// only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at;
//err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find("aDatas).Error
- err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error
+ tx := DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime)
+ if modelName != "" {
+ tx = tx.Where("model_name = ?", modelName)
+ }
+ err = tx.Group("model_name, created_at").Find("aDatas).Error
return quotaDatas, err
}
diff --git a/web/src/components/dashboard/modals/SearchModal.jsx b/web/src/components/dashboard/modals/SearchModal.jsx
index f619831dccbb..91cdf9eabc84 100644
--- a/web/src/components/dashboard/modals/SearchModal.jsx
+++ b/web/src/components/dashboard/modals/SearchModal.jsx
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef } from 'react';
-import { Modal, Form } from '@douyinfe/semi-ui';
+import { Modal, Form, Radio } from '@douyinfe/semi-ui';
const SearchModal = ({
searchModalVisible,
@@ -42,7 +42,15 @@ const SearchModal = ({
);
- const { start_timestamp, end_timestamp, username } = inputs;
+ const {
+ start_timestamp,
+ end_timestamp,
+ username,
+ user_id,
+ user_search_type,
+ model_name,
+ } = inputs;
+ const userSearchType = user_search_type || 'username';
return (
handleInputChange(value, 'user_search_type')}
+ >
+ {t('用户名')}
+ {t('用户ID')}
+
+ )}
+
{isAdminUser &&
+ userSearchType === 'username' &&
createFormField(Form.Input, {
field: 'username',
- label: t('用户名称'),
+ noLabel: true,
value: username,
placeholder: t('可选值'),
name: 'username',
onChange: (value) => handleInputChange(value, 'username'),
})}
+
+ {isAdminUser &&
+ userSearchType === 'user_id' &&
+ createFormField(Form.InputNumber, {
+ field: 'user_id',
+ noLabel: true,
+ value: user_id,
+ placeholder: t('可选值'),
+ name: 'user_id',
+ min: 1,
+ step: 1,
+ precision: 0,
+ onChange: (value) => handleInputChange(value, 'user_id'),
+ })}
+
+ {createFormField(Form.Input, {
+ field: 'model_name',
+ label: t('模型名称'),
+ value: model_name,
+ placeholder: t('可选值'),
+ name: 'model_name',
+ onChange: (value) => handleInputChange(value, 'model_name'),
+ })}
);
diff --git a/web/src/components/table/usage-logs/UsageLogsActions.jsx b/web/src/components/table/usage-logs/UsageLogsActions.jsx
index d921483ed283..4d49909d2192 100644
--- a/web/src/components/table/usage-logs/UsageLogsActions.jsx
+++ b/web/src/components/table/usage-logs/UsageLogsActions.jsx
@@ -57,6 +57,7 @@ const LogsActions = ({
>
{t('消耗额度')}: {renderQuota(stat.quota)}
+
{
const navigate = useNavigate();
const isMobile = useIsMobile();
const initialized = useRef(false);
+ const userIdLookupTimerRef = useRef(null);
+ const userIdLookupSeqRef = useRef(0);
// ========== 基础状态 ==========
const [loading, setLoading] = useState(false);
@@ -40,6 +42,8 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
// ========== 输入状态 ==========
const [inputs, setInputs] = useState({
+ user_search_type: 'username',
+ user_id: null,
username: '',
token_name: '',
model_name: '',
@@ -82,7 +86,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== 常量 ==========
- const now = new Date();
const isAdminUser = isAdmin();
// ========== Panel enable flags ==========
@@ -144,6 +147,44 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
localStorage.setItem('data_export_default_time', value);
return;
}
+ if (name === 'user_search_type') {
+ const nextType = value === 'user_id' ? 'user_id' : 'username';
+ setInputs((inputs) => ({
+ ...inputs,
+ user_search_type: nextType,
+ ...(nextType === 'username'
+ ? { user_id: null }
+ : { username: '' }),
+ }));
+ return;
+ }
+ if (name === 'username') {
+ const text = (value ?? '').toString().trim();
+ setInputs((inputs) => ({
+ ...inputs,
+ username: text,
+ }));
+ return;
+ }
+ if (name === 'user_id') {
+ const v = value === undefined ? null : value;
+ const parsed =
+ v === null || v === ''
+ ? null
+ : typeof v === 'number'
+ ? v
+ : Number.parseInt(String(v), 10);
+ const normalizedUserId =
+ parsed !== null && Number.isFinite(parsed) && parsed >= 1
+ ? Math.trunc(parsed)
+ : null;
+ setInputs((inputs) => ({
+ ...inputs,
+ user_id: normalizedUserId,
+ ...(inputs.user_search_type === 'user_id' ? { username: '' } : {}),
+ }));
+ return;
+ }
setInputs((inputs) => ({ ...inputs, [name]: value }));
}, []);
@@ -155,19 +196,107 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
setSearchModalVisible(false);
}, []);
+ // ========== Admin UX: user_id -> username resolve ==========
+ useEffect(() => {
+ if (!isAdminUser) {
+ return;
+ }
+ if (inputs.user_search_type !== 'user_id') {
+ return;
+ }
+ const userId = Number(inputs.user_id);
+ if (!userId || !Number.isFinite(userId) || userId < 1) {
+ return;
+ }
+
+ const abortController = new AbortController();
+
+ // Debounce while typing.
+ if (userIdLookupTimerRef.current) {
+ clearTimeout(userIdLookupTimerRef.current);
+ }
+ const seq = ++userIdLookupSeqRef.current;
+ userIdLookupTimerRef.current = setTimeout(async () => {
+ try {
+ const res = await API.get(`/api/user/${userId}`, {
+ disableDuplicate: true,
+ skipErrorHandler: true,
+ signal: abortController.signal,
+ });
+ const { success, message, data } = res.data || {};
+ if (seq !== userIdLookupSeqRef.current) {
+ return;
+ }
+ if (success) {
+ setInputs((inputs) => {
+ if (String(inputs.user_id) !== String(userId)) {
+ return inputs;
+ }
+ return { ...inputs, username: data?.username || '' };
+ });
+ } else if (message) {
+ const msg = String(message || '');
+ if (msg.toLowerCase().includes('record not found')) {
+ showError(t('找不到该值'));
+ } else {
+ showError(msg);
+ }
+ }
+ } catch (err) {
+ if (
+ err?.name === 'CanceledError' ||
+ err?.code === 'ERR_CANCELED' ||
+ abortController.signal.aborted
+ ) {
+ return;
+ }
+ if (seq !== userIdLookupSeqRef.current) {
+ return;
+ }
+ showError(err?.message || t('查询失败'));
+ }
+ }, 400);
+
+ return () => {
+ if (userIdLookupTimerRef.current) {
+ clearTimeout(userIdLookupTimerRef.current);
+ }
+ abortController.abort();
+ };
+ }, [inputs.user_id, inputs.user_search_type, isAdminUser, t]);
+
// ========== API 调用函数 ==========
const loadQuotaData = useCallback(async () => {
setLoading(true);
try {
let url = '';
- const { start_timestamp, end_timestamp, username } = inputs;
+ const { start_timestamp, end_timestamp, username, user_id, model_name } =
+ inputs;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+ const params = new URLSearchParams({
+ start_timestamp: String(localStartTimestamp),
+ end_timestamp: String(localEndTimestamp),
+ default_time: String(dataExportDefaultTime || ''),
+ });
+
+ if (model_name) {
+ params.set('model_name', model_name);
+ }
+
if (isAdminUser) {
- url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+ if (inputs.user_search_type === 'user_id') {
+ const parsedUserId = Number(user_id);
+ if (Number.isFinite(parsedUserId) && parsedUserId > 0) {
+ params.set('user_id', String(parsedUserId));
+ }
+ } else if (username) {
+ params.set('username', username);
+ }
+ url = `/api/data/?${params.toString()}`;
} else {
- url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+ url = `/api/data/self/?${params.toString()}`;
}
const res = await API.get(url);
@@ -175,11 +304,12 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
if (success) {
setQuotaData(data);
if (data.length === 0) {
+ const now = Date.now() / 1000;
data.push({
count: 0,
model_name: '无数据',
quota: 0,
- created_at: now.getTime() / 1000,
+ created_at: now,
});
}
data.sort((a, b) => a.created_at - b.created_at);
@@ -191,7 +321,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
} finally {
setLoading(false);
}
- }, [inputs, dataExportDefaultTime, isAdminUser, now]);
+ }, [inputs, dataExportDefaultTime, isAdminUser]);
const loadUptimeData = useCallback(async () => {
setUptimeLoading(true);
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index e406b2ab8f7b..b060bd3fcd83 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -88,6 +88,10 @@ export const useLogsData = () => {
const [stat, setStat] = useState({
quota: 0,
token: 0,
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ rpm: 0,
+ tpm: 0,
});
// Form state
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index aade2fd39032..2dc9668f3bb6 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -3377,5 +3377,7 @@
"剪贴板中未检测到连接信息": "No connection info found in clipboard",
"连接信息已填入": "Connection info applied",
"无法读取剪贴板": "Cannot read clipboard"
- }
+ },
+ "找不到该值": "Value not found",
+ "用户ID": "User ID"
}
diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json
index ec0689fe0bac..79142aca0489 100644
--- a/web/src/i18n/locales/fr.json
+++ b/web/src/i18n/locales/fr.json
@@ -3333,5 +3333,7 @@
"剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
"连接信息已填入": "Informations de connexion appliquées",
"无法读取剪贴板": "Impossible de lire le presse-papiers"
- }
+ },
+ "找不到该值": "Valeur introuvable",
+ "用户ID": "ID utilisateur"
}
diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json
index 73ba69e58091..8d8e4e834cc8 100644
--- a/web/src/i18n/locales/ja.json
+++ b/web/src/i18n/locales/ja.json
@@ -3314,5 +3314,7 @@
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
- }
+ },
+ "找不到该值": "値が見つかりません",
+ "用户ID": "ユーザーID"
}
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index f05905381dc5..0444a8c36877 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -3347,5 +3347,7 @@
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
- }
+ },
+ "找不到该值": "Значение не найдено",
+ "用户ID": "ID пользователя"
}
diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json
index 936ebc8d8a94..5c6968c3debd 100644
--- a/web/src/i18n/locales/vi.json
+++ b/web/src/i18n/locales/vi.json
@@ -3882,6 +3882,8 @@
"从剪贴板粘贴配置": "Dán cấu hình",
"剪贴板中未检测到连接信息": "Không tìm thấy thông tin kết nối trong bộ nhớ tạm",
"连接信息已填入": "Đã áp dụng thông tin kết nối",
- "无法读取剪贴板": "Không thể đọc bộ nhớ tạm"
+ "无法读取剪贴板": "Không thể đọc bộ nhớ tạm",
+ "找不到该值": "Không tìm thấy giá trị",
+ "用户ID": "ID người dùng"
}
}