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" } }