Merge branch 'feature/ui-and-backend-improvements'
This commit is contained in:
@@ -371,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify ownership of all requested API keys
|
// Limit the number of API key IDs to prevent SQL parameter overflow
|
||||||
userApiKeys, _, err := h.apiKeyService.List(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: 1, PageSize: 1000})
|
if len(req.ApiKeyIDs) > 100 {
|
||||||
if err != nil {
|
response.BadRequest(c, "Too many API key IDs (maximum 100 allowed)")
|
||||||
response.ErrorFrom(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userApiKeyIDs := make(map[int64]bool)
|
validApiKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.ApiKeyIDs)
|
||||||
for _, key := range userApiKeys {
|
if err != nil {
|
||||||
userApiKeyIDs[key.ID] = true
|
response.ErrorFrom(c, err)
|
||||||
}
|
return
|
||||||
|
|
||||||
// Filter to only include user's own API keys
|
|
||||||
validApiKeyIDs := make([]int64, 0)
|
|
||||||
for _, id := range req.ApiKeyIDs {
|
|
||||||
if userApiKeyIDs[id] {
|
|
||||||
validApiKeyIDs = append(validApiKeyIDs, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(validApiKeyIDs) == 0 {
|
if len(validApiKeyIDs) == 0 {
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
|
|||||||
return outKeys, paginationResultFromTotal(total, params), nil
|
return outKeys, paginationResultFromTotal(total, params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(apiKeyIDs))
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Model(&apiKeyModel{}).
|
||||||
|
Where("user_id = ? AND id IN ?", userID, apiKeyIDs).
|
||||||
|
Pluck("id", &ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error
|
err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
|||||||
@@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats
|
|||||||
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||||
var stats DashboardStats
|
var stats DashboardStats
|
||||||
today := timezone.Today()
|
today := timezone.Today()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
// 总用户数
|
// 合并用户统计查询
|
||||||
r.db.WithContext(ctx).Model(&userModel{}).Count(&stats.TotalUsers)
|
var userStats struct {
|
||||||
|
TotalUsers int64 `gorm:"column:total_users"`
|
||||||
|
TodayNewUsers int64 `gorm:"column:today_new_users"`
|
||||||
|
ActiveUsers int64 `gorm:"column:active_users"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN created_at >= ? THEN 1 END) as today_new_users,
|
||||||
|
(SELECT COUNT(DISTINCT user_id) FROM usage_logs WHERE created_at >= ?) as active_users
|
||||||
|
FROM users
|
||||||
|
`, today, today).Scan(&userStats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalUsers = userStats.TotalUsers
|
||||||
|
stats.TodayNewUsers = userStats.TodayNewUsers
|
||||||
|
stats.ActiveUsers = userStats.ActiveUsers
|
||||||
|
|
||||||
// 今日新增用户数
|
// 合并API Key统计查询
|
||||||
r.db.WithContext(ctx).Model(&userModel{}).
|
var apiKeyStats struct {
|
||||||
Where("created_at >= ?", today).
|
TotalApiKeys int64 `gorm:"column:total_api_keys"`
|
||||||
Count(&stats.TodayNewUsers)
|
ActiveApiKeys int64 `gorm:"column:active_api_keys"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_api_keys,
|
||||||
|
COUNT(CASE WHEN status = ? THEN 1 END) as active_api_keys
|
||||||
|
FROM api_keys
|
||||||
|
`, service.StatusActive).Scan(&apiKeyStats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalApiKeys = apiKeyStats.TotalApiKeys
|
||||||
|
stats.ActiveApiKeys = apiKeyStats.ActiveApiKeys
|
||||||
|
|
||||||
// 今日活跃用户数 (今日有请求的用户)
|
// 合并账户统计查询
|
||||||
r.db.WithContext(ctx).Model(&usageLogModel{}).
|
var accountStats struct {
|
||||||
Distinct("user_id").
|
TotalAccounts int64 `gorm:"column:total_accounts"`
|
||||||
Where("created_at >= ?", today).
|
NormalAccounts int64 `gorm:"column:normal_accounts"`
|
||||||
Count(&stats.ActiveUsers)
|
ErrorAccounts int64 `gorm:"column:error_accounts"`
|
||||||
|
RateLimitAccounts int64 `gorm:"column:ratelimit_accounts"`
|
||||||
// 总 API Key 数
|
OverloadAccounts int64 `gorm:"column:overload_accounts"`
|
||||||
r.db.WithContext(ctx).Model(&apiKeyModel{}).Count(&stats.TotalApiKeys)
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
// 活跃 API Key 数
|
SELECT
|
||||||
r.db.WithContext(ctx).Model(&apiKeyModel{}).
|
COUNT(*) as total_accounts,
|
||||||
Where("status = ?", service.StatusActive).
|
COUNT(CASE WHEN status = ? AND schedulable = true THEN 1 END) as normal_accounts,
|
||||||
Count(&stats.ActiveApiKeys)
|
COUNT(CASE WHEN status = ? THEN 1 END) as error_accounts,
|
||||||
|
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
|
||||||
// 总账户数
|
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).Count(&stats.TotalAccounts)
|
FROM accounts
|
||||||
|
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
|
||||||
// 正常账户数 (schedulable=true, status=active)
|
return nil, err
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
}
|
||||||
Where("status = ? AND schedulable = ?", service.StatusActive, true).
|
stats.TotalAccounts = accountStats.TotalAccounts
|
||||||
Count(&stats.NormalAccounts)
|
stats.NormalAccounts = accountStats.NormalAccounts
|
||||||
|
stats.ErrorAccounts = accountStats.ErrorAccounts
|
||||||
// 异常账户数 (status=error)
|
stats.RateLimitAccounts = accountStats.RateLimitAccounts
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
stats.OverloadAccounts = accountStats.OverloadAccounts
|
||||||
Where("status = ?", service.StatusError).
|
|
||||||
Count(&stats.ErrorAccounts)
|
|
||||||
|
|
||||||
// 限流账户数
|
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
|
||||||
Where("rate_limited_at IS NOT NULL AND rate_limit_reset_at > ?", time.Now()).
|
|
||||||
Count(&stats.RateLimitAccounts)
|
|
||||||
|
|
||||||
// 过载账户数
|
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
|
||||||
Where("overload_until IS NOT NULL AND overload_until > ?", time.Now()).
|
|
||||||
Count(&stats.OverloadAccounts)
|
|
||||||
|
|
||||||
// 累计 Token 统计
|
// 累计 Token 统计
|
||||||
var totalStats struct {
|
var totalStats struct {
|
||||||
@@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID
|
|||||||
return usageLogModelsToService(logs), nil, err
|
return usageLogModelsToService(logs), nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation
|
||||||
|
func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
var stats struct {
|
||||||
|
TotalRequests int64 `gorm:"column:total_requests"`
|
||||||
|
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
|
||||||
|
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
|
||||||
|
TotalCost float64 `gorm:"column:total_cost"`
|
||||||
|
TotalActualCost float64 `gorm:"column:total_actual_cost"`
|
||||||
|
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
|
||||||
|
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(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
|
||||||
|
`).
|
||||||
|
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, startTime, endTime).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation
|
||||||
|
func (r *usageLogRepository) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
var stats struct {
|
||||||
|
TotalRequests int64 `gorm:"column:total_requests"`
|
||||||
|
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
|
||||||
|
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
|
||||||
|
TotalCost float64 `gorm:"column:total_cost"`
|
||||||
|
TotalActualCost float64 `gorm:"column:total_actual_cost"`
|
||||||
|
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
|
||||||
|
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(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
|
||||||
|
`).
|
||||||
|
Where("api_key_id = ? AND created_at >= ? AND created_at < ?", apiKeyID, startTime, endTime).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||||
var logs []usageLogModel
|
var logs []usageLogModel
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
|||||||
@@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(apiKeyIDs))
|
||||||
|
out := make([]int64, 0, len(apiKeyIDs))
|
||||||
|
for _, id := range apiKeyIDs {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
key, ok := r.byID[id]
|
||||||
|
if ok && key.UserID == userID {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
for _, key := range r.byID {
|
for _, key := range r.byID {
|
||||||
@@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
|
|||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
logs := r.userLogs[userID]
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return &usagestats.UsageStats{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRequests int64
|
||||||
|
var totalInputTokens int64
|
||||||
|
var totalOutputTokens int64
|
||||||
|
var totalCacheTokens int64
|
||||||
|
var totalCost float64
|
||||||
|
var totalActualCost float64
|
||||||
|
var totalDuration int64
|
||||||
|
var durationCount int64
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
totalRequests++
|
||||||
|
totalInputTokens += int64(log.InputTokens)
|
||||||
|
totalOutputTokens += int64(log.OutputTokens)
|
||||||
|
totalCacheTokens += int64(log.CacheCreationTokens + log.CacheReadTokens)
|
||||||
|
totalCost += log.TotalCost
|
||||||
|
totalActualCost += log.ActualCost
|
||||||
|
if log.DurationMs != nil {
|
||||||
|
totalDuration += int64(*log.DurationMs)
|
||||||
|
durationCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var avgDuration float64
|
||||||
|
if durationCount > 0 {
|
||||||
|
avgDuration = float64(totalDuration) / float64(durationCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: totalRequests,
|
||||||
|
TotalInputTokens: totalInputTokens,
|
||||||
|
TotalOutputTokens: totalOutputTokens,
|
||||||
|
TotalCacheTokens: totalCacheTokens,
|
||||||
|
TotalTokens: totalInputTokens + totalOutputTokens + totalCacheTokens,
|
||||||
|
TotalCost: totalCost,
|
||||||
|
TotalActualCost: totalActualCost,
|
||||||
|
AverageDurationMs: avgDuration,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ type UsageLogRepository interface {
|
|||||||
|
|
||||||
// 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)
|
||||||
|
|
||||||
|
// Aggregated stats (optimized)
|
||||||
|
GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
|
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageCache 用于缓存usage数据
|
// usageCache 用于缓存usage数据
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type ApiKeyRepository interface {
|
|||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
||||||
|
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
|
||||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
||||||
ExistsByKey(ctx context.Context, key string) (bool, error)
|
ExistsByKey(ctx context.Context, key string) (bool, error)
|
||||||
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
||||||
@@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio
|
|||||||
return keys, pagination, nil
|
return keys, pagination, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validIDs, err := s.apiKeyRepo.VerifyOwnership(ctx, userID, apiKeyIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verify api key ownership: %w", err)
|
||||||
|
}
|
||||||
|
return validIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID 根据ID获取API Key
|
// GetByID 根据ID获取API Key
|
||||||
func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) {
|
func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) {
|
||||||
apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
|
apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
|
||||||
|
|||||||
@@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param
|
|||||||
|
|
||||||
// GetStatsByUser 获取用户的使用统计
|
// GetStatsByUser 获取用户的使用统计
|
||||||
func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
||||||
logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, startTime, endTime)
|
stats, err := s.usageRepo.GetUserStatsAggregated(ctx, userID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list usage logs: %w", err)
|
return nil, fmt.Errorf("get user stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.calculateStats(logs), nil
|
return &UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatsByApiKey 获取API Key的使用统计
|
// GetStatsByApiKey 获取API Key的使用统计
|
||||||
func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
||||||
logs, _, err := s.usageRepo.ListByApiKeyAndTimeRange(ctx, apiKeyID, startTime, endTime)
|
stats, err := s.usageRepo.GetApiKeyStatsAggregated(ctx, apiKeyID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list usage logs: %w", err)
|
return nil, fmt.Errorf("get api key stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.calculateStats(logs), nil
|
return &UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatsByAccount 获取账号的使用统计
|
// GetStatsByAccount 获取账号的使用统计
|
||||||
|
|||||||
@@ -31,8 +31,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import Modal from './Modal.vue'
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
title: string
|
title: string
|
||||||
@@ -47,12 +51,13 @@ interface Emits {
|
|||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
confirmText: 'Confirm',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
danger: false
|
danger: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const confirmText = computed(() => props.confirmText || t('common.confirm'))
|
||||||
|
const cancelText = computed(() => props.cancelText || t('common.cancel'))
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const { t } = useI18n()
|
|||||||
// 表格容器引用
|
// 表格容器引用
|
||||||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||||
const isScrollable = ref(false)
|
const isScrollable = ref(false)
|
||||||
|
const actionsColumnNeedsExpanding = ref(false)
|
||||||
|
|
||||||
// 检查是否可滚动
|
// 检查是否可滚动
|
||||||
const checkScrollable = () => {
|
const checkScrollable = () => {
|
||||||
@@ -160,17 +161,49 @@ const checkScrollable = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查操作列是否需要展开
|
||||||
|
const checkActionsColumnWidth = () => {
|
||||||
|
if (!tableWrapperRef.value) return
|
||||||
|
|
||||||
|
// 查找操作列的表头单元格
|
||||||
|
const actionsHeader = tableWrapperRef.value.querySelector('th:has(button[title*="Expand"], button[title*="展开"])')
|
||||||
|
if (!actionsHeader) return
|
||||||
|
|
||||||
|
// 查找第一行的操作列单元格
|
||||||
|
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
|
||||||
|
if (!firstActionCell) return
|
||||||
|
|
||||||
|
// 获取操作列内容的实际宽度
|
||||||
|
const actionsContent = firstActionCell.querySelector('div')
|
||||||
|
if (!actionsContent) return
|
||||||
|
|
||||||
|
// 比较内容宽度和单元格宽度
|
||||||
|
const contentWidth = actionsContent.scrollWidth
|
||||||
|
const cellWidth = (firstActionCell as HTMLElement).clientWidth
|
||||||
|
|
||||||
|
// 如果内容宽度超过单元格宽度,说明需要展开
|
||||||
|
actionsColumnNeedsExpanding.value = contentWidth > cellWidth
|
||||||
|
}
|
||||||
|
|
||||||
// 监听尺寸变化
|
// 监听尺寸变化
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkScrollable()
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
||||||
resizeObserver = new ResizeObserver(checkScrollable)
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
})
|
||||||
resizeObserver.observe(tableWrapperRef.value)
|
resizeObserver.observe(tableWrapperRef.value)
|
||||||
} else {
|
} else {
|
||||||
// 降级方案:不支持 ResizeObserver 时使用 window resize
|
// 降级方案:不支持 ResizeObserver 时使用 window resize
|
||||||
window.addEventListener('resize', checkScrollable)
|
const handleResize = () => {
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,6 +238,7 @@ watch(
|
|||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
checkScrollable()
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
},
|
},
|
||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
@@ -234,7 +268,11 @@ const sortedData = computed(() => {
|
|||||||
|
|
||||||
// 检查是否有可展开的操作列
|
// 检查是否有可展开的操作列
|
||||||
const hasExpandableActions = computed(() => {
|
const hasExpandableActions = computed(() => {
|
||||||
return props.expandableActions && props.columns.some((col) => col.key === 'actions')
|
return (
|
||||||
|
props.expandableActions &&
|
||||||
|
props.columns.some((col) => col.key === 'actions') &&
|
||||||
|
actionsColumnNeedsExpanding.value
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换操作列展开/折叠状态
|
// 切换操作列展开/折叠状态
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h3 class="empty-state-title">
|
<h3 class="empty-state-title">
|
||||||
{{ title }}
|
{{ displayTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -61,8 +61,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: Component | string
|
icon?: Component | string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -73,11 +77,12 @@ interface Props {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: 'No data found',
|
|
||||||
description: '',
|
description: '',
|
||||||
actionIcon: true
|
actionIcon: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayTitle = computed(() => props.title || t('common.noData'))
|
||||||
|
|
||||||
defineEmits(['action'])
|
defineEmits(['action'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string {
|
|||||||
const diff = expires.getTime() - now.getTime()
|
const diff = expires.getTime() - now.getTime()
|
||||||
if (diff < 0) return t('subscriptionProgress.expired')
|
if (diff < 0) return t('subscriptionProgress.expired')
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
if (days === 0) return t('subscriptionProgress.expirestoday')
|
if (days === 0) return t('subscriptionProgress.expiresToday')
|
||||||
if (days === 1) return t('subscriptionProgress.expiresTomorrow')
|
if (days === 1) return t('subscriptionProgress.expiresTomorrow')
|
||||||
return t('subscriptionProgress.daysRemaining', { days })
|
return t('subscriptionProgress.daysRemaining', { days })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export default {
|
|||||||
password: 'Password',
|
password: 'Password',
|
||||||
databaseName: 'Database Name',
|
databaseName: 'Database Name',
|
||||||
sslMode: 'SSL Mode',
|
sslMode: 'SSL Mode',
|
||||||
|
passwordPlaceholder: 'Password',
|
||||||
ssl: {
|
ssl: {
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
require: 'Require',
|
require: 'Require',
|
||||||
@@ -64,13 +65,17 @@ export default {
|
|||||||
host: 'Host',
|
host: 'Host',
|
||||||
port: 'Port',
|
port: 'Port',
|
||||||
password: 'Password (optional)',
|
password: 'Password (optional)',
|
||||||
database: 'Database'
|
database: 'Database',
|
||||||
|
passwordPlaceholder: 'Password'
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: 'Admin Account',
|
title: 'Admin Account',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
confirmPassword: 'Confirm Password'
|
confirmPassword: 'Confirm Password',
|
||||||
|
passwordPlaceholder: 'Min 6 characters',
|
||||||
|
confirmPasswordPlaceholder: 'Confirm password',
|
||||||
|
passwordMismatch: 'Passwords do not match'
|
||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
title: 'Ready to Install',
|
title: 'Ready to Install',
|
||||||
@@ -127,7 +132,14 @@ export default {
|
|||||||
searchPlaceholder: 'Search...',
|
searchPlaceholder: 'Search...',
|
||||||
noOptionsFound: 'No options found',
|
noOptionsFound: 'No options found',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
refresh: 'Refresh'
|
refresh: 'Refresh',
|
||||||
|
time: {
|
||||||
|
never: 'Never',
|
||||||
|
justNow: 'Just now',
|
||||||
|
minutesAgo: '{n}m ago',
|
||||||
|
hoursAgo: '{n}h ago',
|
||||||
|
daysAgo: '{n}d ago'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -263,7 +275,7 @@ export default {
|
|||||||
created: 'Created',
|
created: 'Created',
|
||||||
copyToClipboard: 'Copy to clipboard',
|
copyToClipboard: 'Copy to clipboard',
|
||||||
copied: 'Copied!',
|
copied: 'Copied!',
|
||||||
importToCcSwitch: 'Import to CC Switch',
|
importToCcSwitch: 'Import to CCS',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
nameLabel: 'Name',
|
nameLabel: 'Name',
|
||||||
@@ -517,6 +529,7 @@ export default {
|
|||||||
actual: 'Actual',
|
actual: 'Actual',
|
||||||
standard: 'Standard',
|
standard: 'Standard',
|
||||||
noDataAvailable: 'No data available',
|
noDataAvailable: 'No data available',
|
||||||
|
recentUsage: 'Recent Usage',
|
||||||
failedToLoad: 'Failed to load dashboard statistics'
|
failedToLoad: 'Failed to load dashboard statistics'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -569,9 +582,13 @@ export default {
|
|||||||
noSubscription: 'No subscription',
|
noSubscription: 'No subscription',
|
||||||
daysRemaining: '{days}d',
|
daysRemaining: '{days}d',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
|
disable: 'Disable',
|
||||||
|
enable: 'Enable',
|
||||||
disableUser: 'Disable User',
|
disableUser: 'Disable User',
|
||||||
enableUser: 'Enable User',
|
enableUser: 'Enable User',
|
||||||
viewApiKeys: 'View API Keys',
|
viewApiKeys: 'View API Keys',
|
||||||
|
groups: 'Groups',
|
||||||
|
apiKeys: 'API Keys',
|
||||||
userApiKeys: 'User API Keys',
|
userApiKeys: 'User API Keys',
|
||||||
noApiKeys: 'This user has no API keys',
|
noApiKeys: 'This user has no API keys',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default {
|
|||||||
password: '密码',
|
password: '密码',
|
||||||
databaseName: '数据库名称',
|
databaseName: '数据库名称',
|
||||||
sslMode: 'SSL 模式',
|
sslMode: 'SSL 模式',
|
||||||
|
passwordPlaceholder: '密码',
|
||||||
ssl: {
|
ssl: {
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
require: '要求',
|
require: '要求',
|
||||||
@@ -61,13 +62,17 @@ export default {
|
|||||||
host: '主机',
|
host: '主机',
|
||||||
port: '端口',
|
port: '端口',
|
||||||
password: '密码(可选)',
|
password: '密码(可选)',
|
||||||
database: '数据库'
|
database: '数据库',
|
||||||
|
passwordPlaceholder: '密码'
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: '管理员账户',
|
title: '管理员账户',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
confirmPassword: '确认密码'
|
confirmPassword: '确认密码',
|
||||||
|
passwordPlaceholder: '至少 6 个字符',
|
||||||
|
confirmPasswordPlaceholder: '确认密码',
|
||||||
|
passwordMismatch: '密码不匹配'
|
||||||
},
|
},
|
||||||
ready: {
|
ready: {
|
||||||
title: '准备安装',
|
title: '准备安装',
|
||||||
@@ -124,7 +129,14 @@ export default {
|
|||||||
searchPlaceholder: '搜索...',
|
searchPlaceholder: '搜索...',
|
||||||
noOptionsFound: '无匹配选项',
|
noOptionsFound: '无匹配选项',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
refresh: '刷新'
|
refresh: '刷新',
|
||||||
|
time: {
|
||||||
|
never: '从未',
|
||||||
|
justNow: '刚刚',
|
||||||
|
minutesAgo: '{n}分钟前',
|
||||||
|
hoursAgo: '{n}小时前',
|
||||||
|
daysAgo: '{n}天前'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -260,7 +272,7 @@ export default {
|
|||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
copyToClipboard: '复制到剪贴板',
|
copyToClipboard: '复制到剪贴板',
|
||||||
copied: '已复制!',
|
copied: '已复制!',
|
||||||
importToCcSwitch: '导入到 CC Switch',
|
importToCcSwitch: '导入到 CCS',
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
nameLabel: '名称',
|
nameLabel: '名称',
|
||||||
@@ -589,9 +601,13 @@ export default {
|
|||||||
noSubscription: '暂无订阅',
|
noSubscription: '暂无订阅',
|
||||||
daysRemaining: '{days}天',
|
daysRemaining: '{days}天',
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
|
disable: '禁用',
|
||||||
|
enable: '启用',
|
||||||
disableUser: '禁用用户',
|
disableUser: '禁用用户',
|
||||||
enableUser: '启用用户',
|
enableUser: '启用用户',
|
||||||
viewApiKeys: '查看 API 密钥',
|
viewApiKeys: '查看 API 密钥',
|
||||||
|
groups: '分组',
|
||||||
|
apiKeys: 'API密钥',
|
||||||
userApiKeys: '用户 API 密钥',
|
userApiKeys: '用户 API 密钥',
|
||||||
noApiKeys: '此用户暂无 API 密钥',
|
noApiKeys: '此用户暂无 API 密钥',
|
||||||
group: '分组',
|
group: '分组',
|
||||||
@@ -727,10 +743,13 @@ export default {
|
|||||||
priorityHint: '数值越高优先级越高,用于账号调度',
|
priorityHint: '数值越高优先级越高,用于账号调度',
|
||||||
statusLabel: '状态'
|
statusLabel: '状态'
|
||||||
},
|
},
|
||||||
exclusive: {
|
exclusiveObj: {
|
||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否'
|
no: '否'
|
||||||
},
|
},
|
||||||
|
exclusive: '独占',
|
||||||
|
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
|
||||||
|
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||||
platforms: {
|
platforms: {
|
||||||
all: '全部平台',
|
all: '全部平台',
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
@@ -876,6 +895,7 @@ export default {
|
|||||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||||
refreshCookie: '刷新 Cookie',
|
refreshCookie: '刷新 Cookie',
|
||||||
testAccount: '测试账号',
|
testAccount: '测试账号',
|
||||||
|
searchAccounts: '搜索账号...',
|
||||||
// Filter options
|
// Filter options
|
||||||
allPlatforms: '全部平台',
|
allPlatforms: '全部平台',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
@@ -903,6 +923,19 @@ export default {
|
|||||||
lastUsed: '最近使用',
|
lastUsed: '最近使用',
|
||||||
actions: '操作'
|
actions: '操作'
|
||||||
},
|
},
|
||||||
|
clearRateLimit: '清除速率限制',
|
||||||
|
testConnection: '测试连接',
|
||||||
|
reAuthorize: '重新授权',
|
||||||
|
refreshToken: '刷新令牌',
|
||||||
|
noAccountsYet: '暂无账号',
|
||||||
|
createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。',
|
||||||
|
tokenRefreshed: 'Token 刷新成功',
|
||||||
|
accountDeleted: '账号删除成功',
|
||||||
|
rateLimitCleared: '速率限制已清除',
|
||||||
|
setupToken: 'Setup Token',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。",
|
||||||
|
failedToClearRateLimit: '清除速率限制失败',
|
||||||
platforms: {
|
platforms: {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
|
|||||||
@@ -3,30 +3,32 @@
|
|||||||
* 参考 CRS 项目的 format.js 实现
|
* 参考 CRS 项目的 format.js 实现
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化相对时间
|
* 格式化相对时间
|
||||||
* @param date 日期字符串或 Date 对象
|
* @param date 日期字符串或 Date 对象
|
||||||
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
||||||
*/
|
*/
|
||||||
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||||
if (!date) return 'Never'
|
if (!date) return i18n.global.t('common.time.never')
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const past = new Date(date)
|
const past = new Date(date)
|
||||||
const diffMs = now.getTime() - past.getTime()
|
const diffMs = now.getTime() - past.getTime()
|
||||||
|
|
||||||
// 处理未来时间或无效日期
|
// 处理未来时间或无效日期
|
||||||
if (diffMs < 0 || isNaN(diffMs)) return 'Never'
|
if (diffMs < 0 || isNaN(diffMs)) return i18n.global.t('common.time.never')
|
||||||
|
|
||||||
const diffSecs = Math.floor(diffMs / 1000)
|
const diffSecs = Math.floor(diffMs / 1000)
|
||||||
const diffMins = Math.floor(diffSecs / 60)
|
const diffMins = Math.floor(diffSecs / 60)
|
||||||
const diffHours = Math.floor(diffMins / 60)
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
if (diffDays > 0) return `${diffDays}d ago`
|
if (diffDays > 0) return i18n.global.t('common.time.daysAgo', { n: diffDays })
|
||||||
if (diffHours > 0) return `${diffHours}h ago`
|
if (diffHours > 0) return i18n.global.t('common.time.hoursAgo', { n: diffHours })
|
||||||
if (diffMins > 0) return `${diffMins}m ago`
|
if (diffMins > 0) return i18n.global.t('common.time.minutesAgo', { n: diffMins })
|
||||||
return 'Just now'
|
return i18n.global.t('common.time.justNow')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -280,8 +280,7 @@
|
|||||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -296,11 +295,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -315,6 +314,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 次要操作:展开时显示 -->
|
<!-- 次要操作:展开时显示 -->
|
||||||
@@ -323,8 +323,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="row.status === 'error'"
|
v-if="row.status === 'error'"
|
||||||
@click="handleResetStatus(row)"
|
@click="handleResetStatus(row)"
|
||||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('admin.accounts.resetStatus')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -339,13 +338,13 @@
|
|||||||
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.resetStatus') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Clear Rate Limit button -->
|
<!-- Clear Rate Limit button -->
|
||||||
<button
|
<button
|
||||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||||
@click="handleClearRateLimit(row)"
|
@click="handleClearRateLimit(row)"
|
||||||
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
||||||
:title="t('admin.accounts.clearRateLimit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -360,12 +359,12 @@
|
|||||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.clearRateLimit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Test Connection button -->
|
<!-- Test Connection button -->
|
||||||
<button
|
<button
|
||||||
@click="handleTest(row)"
|
@click="handleTest(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
:title="t('admin.accounts.testConnection')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -380,12 +379,12 @@
|
|||||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.testConnection') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- View Stats button -->
|
<!-- View Stats button -->
|
||||||
<button
|
<button
|
||||||
@click="handleViewStats(row)"
|
@click="handleViewStats(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
||||||
:title="t('admin.accounts.viewStats')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -400,12 +399,12 @@
|
|||||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.viewStats') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||||
@click="handleReAuth(row)"
|
@click="handleReAuth(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
:title="t('admin.accounts.reAuthorize')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -420,12 +419,12 @@
|
|||||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.reAuthorize') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||||
@click="handleRefreshToken(row)"
|
@click="handleRefreshToken(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||||
:title="t('admin.accounts.refreshToken')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -440,6 +439,7 @@
|
|||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.refreshToken') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,8 +166,7 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -182,11 +181,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -201,6 +200,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -112,8 +112,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="handleTestConnection(row)"
|
@click="handleTestConnection(row)"
|
||||||
:disabled="testingProxyIds.has(row.id)"
|
:disabled="testingProxyIds.has(row.id)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||||
:title="t('admin.proxies.testConnection')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="testingProxyIds.has(row.id)"
|
v-if="testingProxyIds.has(row.id)"
|
||||||
@@ -149,11 +148,11 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -168,11 +167,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -187,6 +186,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -161,8 +161,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="row.status === 'unused'"
|
v-if="row.status === 'unused'"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -172,6 +171,7 @@
|
|||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,8 +257,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@click="handleExtend(row)"
|
@click="handleExtend(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
:title="t('admin.subscriptions.extend')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -273,12 +272,12 @@
|
|||||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@click="handleRevoke(row)"
|
@click="handleRevoke(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('admin.subscriptions.revoke')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -293,6 +292,7 @@
|
|||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.subscriptions.revoke') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -455,7 +455,11 @@
|
|||||||
${{ row.actual_cost.toFixed(6) }}
|
${{ row.actual_cost.toFixed(6) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Cost Detail Tooltip -->
|
<!-- Cost Detail Tooltip -->
|
||||||
<div class="group relative">
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
<div
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -471,60 +475,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tooltip Content (right side) -->
|
|
||||||
<div
|
|
||||||
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<!-- Cost Breakdown -->
|
|
||||||
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
|
||||||
<div class="text-xs font-semibold text-gray-300 mb-1">成本明细</div>
|
|
||||||
<div v-if="row.input_cost > 0" class="flex items-center justify-between gap-4">
|
|
||||||
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.input_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="row.output_cost > 0" class="flex items-center justify-between gap-4">
|
|
||||||
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.output_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="row.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.cache_creation_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="row.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
|
||||||
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.cache_read_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Rate and Summary -->
|
|
||||||
<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"
|
|
||||||
>{{ (row.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">${{ row.total_cost.toFixed(6) }}</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"
|
|
||||||
>${{ row.actual_cost.toFixed(6) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tooltip Arrow (left side) -->
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -587,6 +537,66 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<!-- Cost Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">成本明细</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rate and Summary -->
|
||||||
|
<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) }}</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) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<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">
|
||||||
@@ -615,6 +625,11 @@ import type {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Tooltip state
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -1038,6 +1053,22 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
loadFilterOptions()
|
loadFilterOptions()
|
||||||
|
|||||||
@@ -203,8 +203,7 @@
|
|||||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -219,12 +218,12 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.role !== 'admin'"
|
v-if="row.role !== 'admin'"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -239,6 +238,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 次要操作:展开时显示 -->
|
<!-- 次要操作:展开时显示 -->
|
||||||
@@ -248,16 +248,11 @@
|
|||||||
v-if="row.role !== 'admin'"
|
v-if="row.role !== 'admin'"
|
||||||
@click="handleToggleStatus(row)"
|
@click="handleToggleStatus(row)"
|
||||||
:class="[
|
:class="[
|
||||||
'rounded-lg p-2 transition-colors',
|
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
||||||
row.status === 'active'
|
row.status === 'active'
|
||||||
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
||||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||||
]"
|
]"
|
||||||
:title="
|
|
||||||
row.status === 'active'
|
|
||||||
? t('admin.users.disableUser')
|
|
||||||
: t('admin.users.enableUser')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@@ -287,12 +282,12 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Allowed Groups -->
|
<!-- Allowed Groups -->
|
||||||
<button
|
<button
|
||||||
@click="handleAllowedGroups(row)"
|
@click="handleAllowedGroups(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
:title="t('admin.users.setAllowedGroups')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -307,12 +302,12 @@
|
|||||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.users.groups') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- View API Keys -->
|
<!-- View API Keys -->
|
||||||
<button
|
<button
|
||||||
@click="handleViewApiKeys(row)"
|
@click="handleViewApiKeys(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||||
:title="t('admin.users.viewApiKeys')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -324,15 +319,15 @@
|
|||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1221.75 8.25z"
|
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Deposit -->
|
<!-- Deposit -->
|
||||||
<button
|
<button
|
||||||
@click="handleDeposit(row)"
|
@click="handleDeposit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||||
:title="t('admin.users.deposit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -343,12 +338,12 @@
|
|||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Withdraw -->
|
<!-- Withdraw -->
|
||||||
<button
|
<button
|
||||||
@click="handleWithdraw(row)"
|
@click="handleWithdraw(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('admin.users.withdraw')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -359,6 +354,7 @@
|
|||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
v-model="formData.database.password"
|
v-model="formData.database.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Password"
|
:placeholder="t('setup.database.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
v-model="formData.redis.password"
|
v-model="formData.redis.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Password"
|
:placeholder="t('setup.redis.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
v-model="formData.admin.password"
|
v-model="formData.admin.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Min 6 characters"
|
:placeholder="t('setup.admin.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,13 +330,13 @@
|
|||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Confirm password"
|
:placeholder="t('setup.admin.confirmPasswordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="confirmPassword && formData.admin.password !== confirmPassword"
|
v-if="confirmPassword && formData.admin.password !== confirmPassword"
|
||||||
class="input-error-text"
|
class="input-error-text"
|
||||||
>
|
>
|
||||||
Passwords do not match
|
{{ t('setup.admin.passwordMismatch') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,8 +154,7 @@
|
|||||||
<!-- Use Key Button -->
|
<!-- Use Key Button -->
|
||||||
<button
|
<button
|
||||||
@click="openUseKeyModal(row)"
|
@click="openUseKeyModal(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
:title="t('keys.useKey')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -170,12 +169,12 @@
|
|||||||
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
|
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('keys.useKey') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Import to CC Switch Button -->
|
<!-- Import to CC Switch Button -->
|
||||||
<button
|
<button
|
||||||
@click="importToCcswitch(row.key)"
|
@click="importToCcswitch(row.key)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
:title="t('keys.importToCcSwitch')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -190,17 +189,17 @@
|
|||||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('keys.importToCcSwitch') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Toggle Status Button -->
|
<!-- Toggle Status Button -->
|
||||||
<button
|
<button
|
||||||
@click="toggleKeyStatus(row)"
|
@click="toggleKeyStatus(row)"
|
||||||
:class="[
|
:class="[
|
||||||
'rounded-lg p-2 transition-colors',
|
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
||||||
row.status === 'active'
|
row.status === 'active'
|
||||||
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
|
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
|
||||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||||
]"
|
]"
|
||||||
:title="row.status === 'active' ? t('keys.disable') : t('keys.enable')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@@ -230,12 +229,12 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Edit Button -->
|
<!-- Edit Button -->
|
||||||
<button
|
<button
|
||||||
@click="editKey(row)"
|
@click="editKey(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -250,12 +249,12 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<button
|
<button
|
||||||
@click="confirmDelete(row)"
|
@click="confirmDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -270,6 +269,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -294,7 +294,11 @@
|
|||||||
${{ row.actual_cost.toFixed(6) }}
|
${{ row.actual_cost.toFixed(6) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Cost Detail Tooltip -->
|
<!-- Cost Detail Tooltip -->
|
||||||
<div class="group relative">
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
<div
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -310,39 +314,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tooltip Content (right side) -->
|
|
||||||
<div
|
|
||||||
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>{{ (row.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">${{ row.total_cost.toFixed(6) }}</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"
|
|
||||||
>${{ row.actual_cost.toFixed(6) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tooltip Arrow (left side) -->
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -399,6 +370,45 @@
|
|||||||
</template>
|
</template>
|
||||||
</TablePageLayout>
|
</TablePageLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- 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) }}</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) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<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">
|
||||||
@@ -420,6 +430,11 @@ import { formatDateTime } from '@/utils/format'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Tooltip state
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -629,6 +644,23 @@ const exportToCSV = () => {
|
|||||||
appStore.showSuccess(t('usage.exportSuccess'))
|
appStore.showSuccess(t('usage.exportSuccess'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip functions
|
||||||
|
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
tooltipData.value = row
|
||||||
|
// Position to the right of the icon, vertically centered
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
|
|||||||
Reference in New Issue
Block a user