feat(admin/usage): 优化管理员用量页面功能和体验
后端改进: - 新增 GetStatsWithFilters 方法支持完整筛选条件 - Stats 端点支持 account_id, group_id, model, stream, billing_type 参数 - 统一使用 filters 结构体,移除冗余的分支逻辑 前端改进: - 统计卡片添加"所选范围内"文字提示 - 优化总消费显示格式,清晰展示实际费用和标准计费 - Token 和费用列添加问号图标 tooltip 显示详细信息 - API Key 搜索框体验优化:点击即显示下拉选项 - 选择用户后自动加载该用户的所有 API Key
This commit is contained in:
@@ -152,8 +152,8 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
// Stats handles getting usage statistics with filters
|
// Stats handles getting usage statistics with filters
|
||||||
// GET /api/v1/admin/usage/stats
|
// GET /api/v1/admin/usage/stats
|
||||||
func (h *UsageHandler) Stats(c *gin.Context) {
|
func (h *UsageHandler) Stats(c *gin.Context) {
|
||||||
// Parse filters
|
// Parse filters - same as List endpoint
|
||||||
var userID, apiKeyID int64
|
var userID, apiKeyID, accountID, groupID int64
|
||||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||||
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,8 +172,49 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
apiKeyID = id
|
apiKeyID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(accountIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid account_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(groupIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid group_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
model := c.Query("model")
|
||||||
|
|
||||||
|
var stream *bool
|
||||||
|
if streamStr := c.Query("stream"); streamStr != "" {
|
||||||
|
val, err := strconv.ParseBool(streamStr)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid stream value, use true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream = &val
|
||||||
|
}
|
||||||
|
|
||||||
|
var billingType *int8
|
||||||
|
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||||
|
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid billing_type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bt := int8(val)
|
||||||
|
billingType = &bt
|
||||||
|
}
|
||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
userTZ := c.Query("timezone") // Get user's timezone from request
|
userTZ := c.Query("timezone")
|
||||||
now := timezone.NowInUserLocation(userTZ)
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
@@ -208,28 +249,20 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
endTime = now
|
endTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiKeyID > 0 {
|
// Build filters and call GetStatsWithFilters
|
||||||
stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
|
filters := usagestats.UsageLogFilters{
|
||||||
if err != nil {
|
UserID: userID,
|
||||||
response.ErrorFrom(c, err)
|
APIKeyID: apiKeyID,
|
||||||
return
|
AccountID: accountID,
|
||||||
}
|
GroupID: groupID,
|
||||||
response.Success(c, stats)
|
Model: model,
|
||||||
return
|
Stream: stream,
|
||||||
|
BillingType: billingType,
|
||||||
|
StartTime: &startTime,
|
||||||
|
EndTime: &endTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID > 0 {
|
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
|
||||||
stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime)
|
|
||||||
if err != nil {
|
|
||||||
response.ErrorFrom(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, stats)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get global stats
|
|
||||||
stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1388,6 +1388,81 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
|
|||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatsWithFilters gets usage statistics with optional filters
|
||||||
|
func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) {
|
||||||
|
conditions := make([]string, 0, 9)
|
||||||
|
args := make([]any, 0, 9)
|
||||||
|
|
||||||
|
if filters.UserID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.UserID)
|
||||||
|
}
|
||||||
|
if filters.APIKeyID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.APIKeyID)
|
||||||
|
}
|
||||||
|
if filters.AccountID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.AccountID)
|
||||||
|
}
|
||||||
|
if filters.GroupID > 0 {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.GroupID)
|
||||||
|
}
|
||||||
|
if filters.Model != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("model = $%d", len(args)+1))
|
||||||
|
args = append(args, filters.Model)
|
||||||
|
}
|
||||||
|
if filters.Stream != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.Stream)
|
||||||
|
}
|
||||||
|
if filters.BillingType != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
|
||||||
|
args = append(args, int16(*filters.BillingType))
|
||||||
|
}
|
||||||
|
if filters.StartTime != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.StartTime)
|
||||||
|
}
|
||||||
|
if filters.EndTime != nil {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("created_at <= $%d", len(args)+1))
|
||||||
|
args = append(args, *filters.EndTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||||
|
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
|
||||||
|
FROM usage_logs
|
||||||
|
%s
|
||||||
|
`, buildWhere(conditions))
|
||||||
|
|
||||||
|
stats := &UsageStats{}
|
||||||
|
if err := scanSingleRow(
|
||||||
|
ctx,
|
||||||
|
r.sql,
|
||||||
|
query,
|
||||||
|
args,
|
||||||
|
&stats.TotalRequests,
|
||||||
|
&stats.TotalInputTokens,
|
||||||
|
&stats.TotalOutputTokens,
|
||||||
|
&stats.TotalCacheTokens,
|
||||||
|
&stats.TotalCost,
|
||||||
|
&stats.TotalActualCost,
|
||||||
|
&stats.AverageDurationMs,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AccountUsageHistory represents daily usage history for an account
|
// AccountUsageHistory represents daily usage history for an account
|
||||||
type AccountUsageHistory = usagestats.AccountUsageHistory
|
type AccountUsageHistory = usagestats.AccountUsageHistory
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type UsageLogRepository interface {
|
|||||||
// Admin usage listing/stats
|
// Admin usage listing/stats
|
||||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
|
||||||
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
|
GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error)
|
||||||
|
|
||||||
// Account stats
|
// Account stats
|
||||||
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
||||||
|
|||||||
@@ -319,3 +319,12 @@ func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime ti
|
|||||||
}
|
}
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStatsWithFilters returns usage stats with optional filters.
|
||||||
|
func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
|
||||||
|
stats, err := s.usageRepo.GetStatsWithFilters(ctx, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get usage stats with filters: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,15 +54,21 @@ export async function list(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get usage statistics with optional filters (admin only)
|
* Get usage statistics with optional filters (admin only)
|
||||||
* @param params - Query parameters (user_id, api_key_id, period/date range)
|
* @param params - Query parameters for filtering
|
||||||
* @returns Usage statistics
|
* @returns Usage statistics
|
||||||
*/
|
*/
|
||||||
export async function getStats(params: {
|
export async function getStats(params: {
|
||||||
user_id?: number
|
user_id?: number
|
||||||
api_key_id?: number
|
api_key_id?: number
|
||||||
|
account_id?: number
|
||||||
|
group_id?: number
|
||||||
|
model?: string
|
||||||
|
stream?: boolean
|
||||||
|
billing_type?: number
|
||||||
period?: string
|
period?: string
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
|
timezone?: string
|
||||||
}): Promise<AdminUsageStatsResponse> {
|
}): Promise<AdminUsageStatsResponse> {
|
||||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||||
params
|
params
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
class="input pr-8"
|
class="input pr-8"
|
||||||
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
|
||||||
@input="debounceApiKeySearch"
|
@input="debounceApiKeySearch"
|
||||||
@focus="showApiKeyDropdown = true"
|
@focus="onApiKeyFocus"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="filters.api_key_id"
|
v-if="filters.api_key_id"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
|
v-if="showApiKeyDropdown && apiKeyResults.length > 0"
|
||||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -223,14 +223,10 @@ const debounceUserSearch = () => {
|
|||||||
const debounceApiKeySearch = () => {
|
const debounceApiKeySearch = () => {
|
||||||
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
|
||||||
apiKeySearchTimeout = setTimeout(async () => {
|
apiKeySearchTimeout = setTimeout(async () => {
|
||||||
if (!apiKeyKeyword.value) {
|
|
||||||
apiKeyResults.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
|
||||||
filters.value.user_id,
|
filters.value.user_id,
|
||||||
apiKeyKeyword.value
|
apiKeyKeyword.value || ''
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
apiKeyResults.value = []
|
apiKeyResults.value = []
|
||||||
@@ -238,11 +234,19 @@ const debounceApiKeySearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectUser = (u: SimpleUser) => {
|
const selectUser = async (u: SimpleUser) => {
|
||||||
userKeyword.value = u.email
|
userKeyword.value = u.email
|
||||||
showUserDropdown.value = false
|
showUserDropdown.value = false
|
||||||
filters.value.user_id = u.id
|
filters.value.user_id = u.id
|
||||||
clearApiKey()
|
clearApiKey()
|
||||||
|
|
||||||
|
// Auto-load API keys for this user
|
||||||
|
try {
|
||||||
|
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
|
||||||
|
} catch {
|
||||||
|
apiKeyResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
emitChange()
|
emitChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +278,14 @@ const onClearApiKey = () => {
|
|||||||
emitChange()
|
emitChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onApiKeyFocus = () => {
|
||||||
|
showApiKeyDropdown.value = true
|
||||||
|
// Trigger search if no results yet
|
||||||
|
if (apiKeyResults.value.length === 0) {
|
||||||
|
debounceApiKeySearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onDocumentClick = (e: MouseEvent) => {
|
const onDocumentClick = (e: MouseEvent) => {
|
||||||
const target = e.target as Node | null
|
const target = e.target as Node | null
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|||||||
@@ -4,17 +4,34 @@
|
|||||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
||||||
<Icon name="document" size="md" />
|
<Icon name="document" size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
|
||||||
|
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
|
<div>
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
|
||||||
|
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
|
||||||
|
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
||||||
<Icon name="dollar" size="md" />
|
<Icon name="dollar" size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
|
||||||
|
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-4 flex items-center gap-3">
|
<div class="card p-4 flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
||||||
|
|||||||
@@ -44,32 +44,56 @@
|
|||||||
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Token 请求 -->
|
<!-- Token 请求 -->
|
||||||
<div v-else class="space-y-1 text-sm">
|
<div v-else class="flex items-center gap-1.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="space-y-1 text-sm">
|
||||||
<div class="inline-flex items-center gap-1">
|
<div class="flex items-center gap-2">
|
||||||
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
<div class="inline-flex items-center gap-1">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1">
|
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
||||||
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
||||||
|
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||||
|
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||||
|
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
<!-- Token Detail Tooltip -->
|
||||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
<div
|
||||||
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
class="group relative"
|
||||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
@mouseenter="showTokenTooltip($event, row)"
|
||||||
</div>
|
@mouseleave="hideTokenTooltip"
|
||||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
>
|
||||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-cost="{ row }">
|
<template #cell-cost="{ row }">
|
||||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
<div class="flex items-center gap-1.5 text-sm">
|
||||||
|
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
<!-- Cost Detail Tooltip -->
|
||||||
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
|
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
|
||||||
|
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-billing_type="{ row }">
|
<template #cell-billing_type="{ row }">
|
||||||
@@ -106,6 +130,77 @@
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Token Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tokenTooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tokenTooltipPosition.x + 'px',
|
||||||
|
top: tokenTooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">Token {{ t('usage.details') }}</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
|
||||||
|
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Cost Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tooltipPosition.x + 'px',
|
||||||
|
top: tooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||||
|
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -116,12 +211,23 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import type { UsageLog } from '@/types'
|
||||||
|
|
||||||
defineProps(['data', 'loading'])
|
defineProps(['data', 'loading'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const copiedRequestId = ref<string | null>(null)
|
const copiedRequestId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Tooltip state - cost
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
|
// Tooltip state - token
|
||||||
|
const tokenTooltipVisible = ref(false)
|
||||||
|
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
const cols = computed(() => [
|
const cols = computed(() => [
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
@@ -160,4 +266,34 @@ const copyRequestId = async (requestId: string) => {
|
|||||||
appStore.showError(t('common.copyFailed'))
|
appStore.showError(t('common.copyFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cost tooltip functions
|
||||||
|
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
tooltipData.value = row
|
||||||
|
tooltipPosition.value.x = rect.right + 8
|
||||||
|
tooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
tooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token tooltip functions
|
||||||
|
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
tokenTooltipData.value = row
|
||||||
|
tokenTooltipPosition.value.x = rect.right + 8
|
||||||
|
tokenTooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tokenTooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTokenTooltip = () => {
|
||||||
|
tokenTooltipVisible.value = false
|
||||||
|
tokenTooltipData.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user