Unverified Commit a413fa3b authored by 程序猿MT's avatar 程序猿MT Committed by GitHub
Browse files

Merge branch 'Wei-Shaw:main' into main

parents 3a8dbf5a 254f1254
...@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) { ...@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
// Parse filters // Parse filters
var userID, apiKeyID int64 var userID, apiKeyID, accountID, groupID int64
if userIDStr := c.Query("user_id"); userIDStr != "" { if userIDStr := c.Query("user_id"); userIDStr != "" {
id, err := strconv.ParseInt(userIDStr, 10, 64) id, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil { if err != nil {
...@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) { ...@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID = id apiKeyID = id
} }
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
id, err := strconv.ParseInt(accountIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account_id")
return
}
accountID = id
}
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
id, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group_id")
return
}
groupID = id
}
model := c.Query("model")
var stream *bool
if streamStr := c.Query("stream"); streamStr != "" {
val, err := strconv.ParseBool(streamStr)
if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
}
bt := int8(val)
billingType = &bt
}
// Parse date range // Parse date range
var startTime, endTime *time.Time var startTime, endTime *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" { if startDateStr := c.Query("start_date"); startDateStr != "" {
...@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) { ...@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize}
filters := usagestats.UsageLogFilters{ filters := usagestats.UsageLogFilters{
UserID: userID, UserID: userID,
ApiKeyID: apiKeyID, ApiKeyID: apiKeyID,
StartTime: startTime, AccountID: accountID,
EndTime: endTime, GroupID: groupID,
Model: model,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
} }
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters) records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
...@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) { ...@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID = id apiKeyID = id
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize} // Parse additional filters
var records []service.UsageLog model := c.Query("model")
var result *pagination.PaginationResult
var err error
if apiKeyID > 0 { var stream *bool
records, result, err = h.usageService.ListByApiKey(c.Request.Context(), apiKeyID, params) if streamStr := c.Query("stream"); streamStr != "" {
} else { val, err := strconv.ParseBool(streamStr)
records, result, err = h.usageService.ListByUser(c.Request.Context(), subject.UserID, params) if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
}
bt := int8(val)
billingType = &bt
}
// Parse date range
var startTime, endTime *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
startTime = &t
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// Set end time to end of day
t = t.Add(24*time.Hour - time.Nanosecond)
endTime = &t
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
filters := usagestats.UsageLogFilters{
UserID: subject.UserID, // Always filter by current user for security
ApiKeyID: apiKeyID,
Model: model,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
......
...@@ -127,10 +127,15 @@ type UserDashboardStats struct { ...@@ -127,10 +127,15 @@ type UserDashboardStats struct {
// UsageLogFilters represents filters for usage log queries // UsageLogFilters represents filters for usage log queries
type UsageLogFilters struct { type UsageLogFilters struct {
UserID int64 UserID int64
ApiKeyID int64 ApiKeyID int64
StartTime *time.Time AccountID int64
EndTime *time.Time GroupID int64
Model string
Stream *bool
BillingType *int8
StartTime *time.Time
EndTime *time.Time
} }
// UsageStats represents usage statistics // UsageStats represents usage statistics
......
...@@ -631,6 +631,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat ...@@ -631,6 +631,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
if filters.ApiKeyID > 0 { if filters.ApiKeyID > 0 {
db = db.Where("api_key_id = ?", filters.ApiKeyID) db = db.Where("api_key_id = ?", filters.ApiKeyID)
} }
if filters.AccountID > 0 {
db = db.Where("account_id = ?", filters.AccountID)
}
if filters.GroupID > 0 {
db = db.Where("group_id = ?", filters.GroupID)
}
if filters.Model != "" {
db = db.Where("model = ?", filters.Model)
}
if filters.Stream != nil {
db = db.Where("stream = ?", *filters.Stream)
}
if filters.BillingType != nil {
db = db.Where("billing_type = ?", *filters.BillingType)
}
if filters.StartTime != nil { if filters.StartTime != nil {
db = db.Where("created_at >= ?", *filters.StartTime) db = db.Where("created_at >= ?", *filters.StartTime)
} }
...@@ -642,8 +657,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat ...@@ -642,8 +657,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
return nil, nil, err return nil, nil, err
} }
// Preload user and api_key for display // Preload user, api_key, account, and group for display
if err := db.Preload("User").Preload("ApiKey"). if err := db.Preload("User").Preload("ApiKey").Preload("Account").Preload("Group").
Offset(params.Offset()).Limit(params.Limit()). Offset(params.Offset()).Limit(params.Limit()).
Order("id DESC").Find(&logs).Error; err != nil { Order("id DESC").Find(&logs).Error; err != nil {
return nil, nil, err return nil, nil, err
......
...@@ -924,7 +924,10 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64, ...@@ -924,7 +924,10 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
} }
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented") logs := r.userLogs[filters.UserID]
total := int64(len(logs))
out := paginateLogs(logs, params)
return out, paginationResult(total, params), nil
} }
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) { func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
......
...@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs ...@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
} }
c.Set(string(ContextKeyApiKey), apiKey) c.Set(string(ContextKeyApiKey), apiKey)
c.Set(string(ContextKeyUser), apiKey.User) c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
c.Next() c.Next()
} }
} }
......
...@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc { ...@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") || strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/setup/") || strings.HasPrefix(path, "/setup/") ||
path == "/health" { path == "/health" ||
path == "/responses" {
c.Next() c.Next()
return return
} }
......
...@@ -226,7 +226,9 @@ ...@@ -226,7 +226,9 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.tokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0) formatTokens(stats.summary.today?.tokens || 0)
}}</span> }}</span>
......
...@@ -89,6 +89,7 @@ ...@@ -89,6 +89,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Account } from '@/types' import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
const props = defineProps<{ const props = defineProps<{
account: Account account: Account
...@@ -139,13 +140,4 @@ const statusText = computed(() => { ...@@ -139,13 +140,4 @@ const statusText = computed(() => {
return props.account.status return props.account.status
}) })
// Format time helper
const formatTime = (dateStr: string | null | undefined) => {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
</script> </script>
...@@ -16,21 +16,27 @@ ...@@ -16,21 +16,27 @@
<div v-else-if="stats" class="space-y-0.5 text-xs"> <div v-else-if="stats" class="space-y-0.5 text-xs">
<!-- Requests --> <!-- Requests -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.requests') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(stats.requests) formatNumber(stats.requests)
}}</span> }}</span>
</div> </div>
<!-- Tokens --> <!-- Tokens -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.tokens') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(stats.tokens) formatTokens(stats.tokens)
}}</span> }}</span>
</div> </div>
<!-- Cost --> <!-- Cost -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.cost') }}:</span
>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ <span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost) formatCurrency(stats.cost)
}}</span> }}</span>
...@@ -44,6 +50,7 @@ ...@@ -44,6 +50,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, WindowStats } from '@/types' import type { Account, WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format' import { formatNumber, formatCurrency } from '@/utils/format'
...@@ -52,6 +59,8 @@ const props = defineProps<{ ...@@ -52,6 +59,8 @@ const props = defineProps<{
account: Account account: Account
}>() }>()
const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const stats = ref<WindowStats | null>(null) const stats = ref<WindowStats | null>(null)
......
...@@ -105,6 +105,7 @@ ...@@ -105,6 +105,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types' import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
...@@ -113,6 +114,8 @@ const props = defineProps<{ ...@@ -113,6 +114,8 @@ const props = defineProps<{
account: Account account: Account
}>() }>()
const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
...@@ -282,7 +285,7 @@ const loadUsage = async () => { ...@@ -282,7 +285,7 @@ const loadUsage = async () => {
try { try {
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id) usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
} catch (e: any) { } catch (e: any) {
error.value = 'Failed' error.value = t('common.error')
console.error('Failed to load usage:', e) console.error('Failed to load usage:', e)
} finally { } finally {
loading.value = false loading.value = false
......
...@@ -256,7 +256,7 @@ ...@@ -256,7 +256,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
</div> </div>
</button> </button>
...@@ -294,7 +294,7 @@ ...@@ -294,7 +294,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Responses API</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
</div> </div>
</button> </button>
</div> </div>
...@@ -338,7 +338,7 @@ ...@@ -338,7 +338,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Google OAuth</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
</div> </div>
</button> </button>
...@@ -408,7 +408,7 @@ ...@@ -408,7 +408,7 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span> <span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
</div> </div>
...@@ -488,7 +488,7 @@ ...@@ -488,7 +488,7 @@
value="oauth" value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500" class="mr-2 text-primary-600 focus:ring-primary-500"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.types.oauth') }}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
......
...@@ -63,7 +63,9 @@ ...@@ -63,7 +63,9 @@
value="oauth" value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500" class="mr-2 text-primary-600 focus:ring-primary-500"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.types.oauth')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
...@@ -116,7 +118,9 @@ ...@@ -116,7 +118,9 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.types.codeAssist')
}}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ <span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
t('admin.accounts.oauth.gemini.needsProjectId') t('admin.accounts.oauth.gemini.needsProjectId')
}}</span> }}</span>
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<div <div
v-if="windowStats" v-if="windowStats"
class="mb-0.5 flex items-center justify-between" class="mb-0.5 flex items-center justify-between"
:title="`5h 窗口用量统计`" :title="t('admin.accounts.usageWindow.statsTitle')"
> >
<div <div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400" class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
...@@ -51,6 +51,7 @@ ...@@ -51,6 +51,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types' import type { WindowStats } from '@/types'
const props = defineProps<{ const props = defineProps<{
...@@ -61,6 +62,8 @@ const props = defineProps<{ ...@@ -61,6 +62,8 @@ const props = defineProps<{
windowStats?: WindowStats | null windowStats?: WindowStats | null
}>() }>()
const { t } = useI18n()
// Label background colors // Label background colors
const labelClass = computed(() => { const labelClass = computed(() => {
const colors = { const colors = {
......
<template> <template>
<div class="overflow-x-auto"> <div
ref="tableWrapperRef"
class="table-wrapper"
:class="{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
}"
>
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800"> <thead class="table-header bg-gray-50 dark:bg-dark-800">
<tr> <tr>
<th <th
v-for="column in columns" v-for="(column, index) in columns"
:key="column.key" :key="column.key"
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400" :class="[
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }" 'sticky-header-cell px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index)
]"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span>{{ column.label }}</span> <span>{{ column.label }}</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if="column.key === 'actions' && hasExpandableActions"
type="button"
@click.stop="toggleActionsExpanded"
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态:收起图标 -->
<svg
v-if="actionsExpanded"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
<!-- 折叠状态:展开图标 -->
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg>
</button>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
...@@ -37,7 +78,7 @@ ...@@ -37,7 +78,7 @@
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"> <tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton --> <!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i"> <tr v-if="loading" v-for="i in 5" :key="i">
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4"> <td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
...@@ -84,11 +125,14 @@ ...@@ -84,11 +125,14 @@
class="hover:bg-gray-50 dark:hover:bg-dark-800" class="hover:bg-gray-50 dark:hover:bg-dark-800"
> >
<td <td
v-for="column in columns" v-for="(column, colIndex) in columns"
:key="column.key" :key="column.key"
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100" :class="[
'whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100',
getStickyColumnClass(column, colIndex)
]"
> >
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]"> <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }} {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot> </slot>
</td> </td>
...@@ -99,24 +143,71 @@ ...@@ -99,24 +143,71 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Column } from './types' import type { Column } from './types'
const { t } = useI18n() const { t } = useI18n()
// 表格容器引用
const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false)
// 检查是否可滚动
const checkScrollable = () => {
if (tableWrapperRef.value) {
isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth
}
}
// 监听尺寸变化
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkScrollable()
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(checkScrollable)
resizeObserver.observe(tableWrapperRef.value)
} else {
// 降级方案:不支持 ResizeObserver 时使用 window resize
window.addEventListener('resize', checkScrollable)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable)
})
interface Props { interface Props {
columns: Column[] columns: Column[]
data: any[] data: any[]
loading?: boolean loading?: boolean
stickyFirstColumn?: boolean
stickyActionsColumn?: boolean
expandableActions?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false loading: false,
stickyFirstColumn: true,
stickyActionsColumn: true,
expandableActions: true
}) })
const sortKey = ref<string>('') const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false)
// 数据/列/展开状态变化时重新检查滚动状态
watch(
[() => props.data.length, () => props.columns, actionsExpanded],
async () => {
await nextTick()
checkScrollable()
},
{ flush: 'post' }
)
const handleSort = (key: string) => { const handleSort = (key: string) => {
if (sortKey.value === key) { if (sortKey.value === key) {
...@@ -140,4 +231,186 @@ const sortedData = computed(() => { ...@@ -140,4 +231,186 @@ const sortedData = computed(() => {
return sortOrder.value === 'asc' ? comparison : -comparison return sortOrder.value === 'asc' ? comparison : -comparison
}) })
}) })
// 检查是否有可展开的操作列
const hasExpandableActions = computed(() => {
return props.expandableActions && props.columns.some((col) => col.key === 'actions')
})
// 切换操作列展开/折叠状态
const toggleActionsExpanded = () => {
actionsExpanded.value = !actionsExpanded.value
}
// 检查第一列是否为勾选列
const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select'
})
// 生成固定列的 CSS 类
const getStickyColumnClass = (column: Column, index: number) => {
const classes: string[] = []
if (props.stickyFirstColumn) {
// 如果第一列是勾选列,固定前两列(勾选+名称)
if (hasSelectColumn.value) {
if (index === 0) {
classes.push('sticky-col sticky-col-left-first')
} else if (index === 1) {
classes.push('sticky-col sticky-col-left-second')
}
} else {
// 否则只固定第一列
if (index === 0) {
classes.push('sticky-col sticky-col-left')
}
}
}
// 操作列固定(最后一列)
if (props.stickyActionsColumn && column.key === 'actions') {
classes.push('sticky-col sticky-col-right')
}
return classes.join(' ')
}
</script> </script>
<style scoped>
/* 表格横向滚动 */
.table-wrapper {
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
position: relative;
overflow-x: auto;
isolation: isolate;
}
/* 表头容器,确保在滚动时覆盖表体内容 */
.table-wrapper .table-header {
position: sticky;
top: 0;
z-index: 200;
background-color: rgb(249 250 251);
}
.dark .table-wrapper .table-header {
background-color: rgb(31 41 55);
}
/* 表体保持在表头下方 */
.table-body {
position: relative;
z-index: 0;
}
/* 所有表头单元格固定在顶部 */
.sticky-header-cell {
position: sticky;
top: 0;
z-index: 210; /* 必须高于所有表体内容 */
background-color: rgb(249 250 251);
}
.dark .sticky-header-cell {
background-color: rgb(31 41 55);
}
/* Sticky 列基础样式 */
.sticky-col {
position: sticky;
z-index: 20; /* 表体固定列 */
}
/* 单列固定(无勾选列时) */
.sticky-col-left {
left: 0;
}
/* 双列固定(有勾选列时):第一列(勾选) */
.sticky-col-left-first {
left: 0;
}
/* 双列固定(有勾选列时):第二列(名称) */
.sticky-col-left-second {
left: var(--select-col-width);
}
/* 操作列固定 */
.sticky-col-right {
right: 0;
}
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
.sticky-header-cell.sticky-col {
z-index: 220; /* 高于普通表头单元格和表体固定列 */
}
/* 表体 sticky 列背景 */
tbody .sticky-col {
background-color: white;
}
.dark tbody .sticky-col {
background-color: rgb(17 24 39);
}
/* hover 状态保持 */
tbody tr:hover .sticky-col {
background-color: rgb(249 250 251);
}
.dark tbody tr:hover .sticky-col {
background-color: rgb(31 41 55);
}
/* 阴影只在可滚动时显示 */
/* 单列固定右侧阴影 */
.is-scrollable .sticky-col-left::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
transform: translateX(100%);
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 双列固定:只在第二列显示阴影 */
.is-scrollable .sticky-col-left-second::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
transform: translateX(100%);
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 操作列左侧阴影 */
.is-scrollable .sticky-col-right::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 10px;
transform: translateX(-100%);
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 暗色模式阴影 */
.dark .is-scrollable .sticky-col-left::after,
.dark .is-scrollable .sticky-col-left-second::after {
background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent);
}
.dark .is-scrollable .sticky-col-right::before {
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
}
</style>
...@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate) ...@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
const localEndDate = ref(props.endDate) const localEndDate = ref(props.endDate)
const activePreset = ref<string | null>('7days') const activePreset = ref<string | null>('7days')
const today = computed(() => new Date().toISOString().split('T')[0]) const today = computed(() => {
// Use local timezone to avoid UTC timezone issues
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
})
// Helper function to format date to YYYY-MM-DD using local timezone
const formatDateToString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const presets: DatePreset[] = [ const presets: DatePreset[] = [
{ {
...@@ -152,7 +167,7 @@ const presets: DatePreset[] = [ ...@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
getRange: () => { getRange: () => {
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 1) d.setDate(d.getDate() - 1)
const yesterday = d.toISOString().split('T')[0] const yesterday = formatDateToString(d)
return { start: yesterday, end: yesterday } return { start: yesterday, end: yesterday }
} }
}, },
...@@ -163,7 +178,7 @@ const presets: DatePreset[] = [ ...@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 6) d.setDate(d.getDate() - 6)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
...@@ -174,7 +189,7 @@ const presets: DatePreset[] = [ ...@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 13) d.setDate(d.getDate() - 13)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
...@@ -185,7 +200,7 @@ const presets: DatePreset[] = [ ...@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 29) d.setDate(d.getDate() - 29)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
...@@ -194,7 +209,7 @@ const presets: DatePreset[] = [ ...@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
value: 'thisMonth', value: 'thisMonth',
getRange: () => { getRange: () => {
const now = new Date() const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0] const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
return { start, end: today.value } return { start, end: today.value }
} }
}, },
...@@ -203,8 +218,8 @@ const presets: DatePreset[] = [ ...@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
value: 'lastMonth', value: 'lastMonth',
getRange: () => { getRange: () => {
const now = new Date() const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0] const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0] const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
return { start, end } return { start, end }
} }
} }
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="group.id" :key="group.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`" :title="t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })"
> >
<input <input
type="checkbox" type="checkbox"
...@@ -40,9 +40,12 @@ ...@@ -40,9 +40,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue' import GroupBadge from './GroupBadge.vue'
import type { Group, GroupPlatform } from '@/types' import type { Group, GroupPlatform } from '@/types'
const { t } = useI18n()
interface Props { interface Props {
modelValue: number[] modelValue: number[]
groups: Group[] groups: Group[]
......
...@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => { ...@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
} }
} }
const handlePageSizeChange = (value: string | number | null) => { const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null) return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
// Reset to first page when page size changes // Reset to first page when page size changes
......
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
<div class="select-options"> <div class="select-options">
<div <div
v-for="option in filteredOptions" v-for="option in filteredOptions"
:key="getOptionValue(option) ?? undefined" :key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click="selectOption(option)" @click="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']" :class="['select-option', isSelected(option) && 'select-option-selected']"
> >
...@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n' ...@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
export interface SelectOption { export interface SelectOption {
value: string | number | null value: string | number | boolean | null
label: string label: string
disabled?: boolean disabled?: boolean
[key: string]: unknown [key: string]: unknown
} }
interface Props { interface Props {
modelValue: string | number | null | undefined modelValue: string | number | boolean | null | undefined
options: SelectOption[] | Array<Record<string, unknown>> options: SelectOption[] | Array<Record<string, unknown>>
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
...@@ -116,8 +116,8 @@ interface Props { ...@@ -116,8 +116,8 @@ interface Props {
} }
interface Emits { interface Emits {
(e: 'update:modelValue', value: string | number | null): void (e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | null, option: SelectOption | null): void (e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null) ...@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
const getOptionValue = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
): string | number | null | undefined => { ): string | number | boolean | null | undefined => {
if (typeof option === 'object' && option !== null) { if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | null | undefined return option[props.valueKey] as string | number | boolean | null | undefined
} }
return option as string | number | null return option as string | number | boolean | null
} }
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => { const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50' ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
]" ]"
:title="hasUpdate ? 'New version available' : 'Up to date'" :title="hasUpdate ? t('version.updateAvailable') : t('version.upToDate')"
> >
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span> <span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
<span <span
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment