Unverified Commit 254f1254 authored by IanShaw's avatar IanShaw Committed by GitHub
Browse files

feat(frontend): 前端界面优化与使用统计功能增强 (#46)

* feat(frontend): 前端界面优化与使用统计功能增强

主要改动:

1. 表格布局统一优化
   - 新增 TablePageLayout 通用布局组件
   - 统一所有管理页面的表格样式和交互
   - 优化 DataTable、Pagination、Select 等通用组件

2. 使用统计功能增强
   - 管理端: 添加完整的筛选和显示功能
   - 用户端: 完善 API Key 列显示
   - 后端: 优化使用统计数据结构和查询

3. 账户组件优化
   - 优化 AccountStatsModal、AccountUsageCell 等组件
   - 统一进度条和统计显示样式

4. 其他改进
   - 完善中英文国际化
   - 统一页面样式和交互体验
   - 优化各视图页面的响应式布局

* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub

测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。

* feat(frontend): 统一日期时间显示格式

**主要改动**:
1. 增强 utils/format.ts:
   - 新增 formatDateOnly() - 格式: YYYY-MM-DD
   - 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss

2. 全局替换视图中的格式化函数:
   - 移除各视图中的自定义 formatDate 函数
   - 统一导入使用 @/utils/format 中的函数
   - created_at/updated_at 使用 formatDateTime
   - expires_at 使用 formatDateOnly

3. 受影响的视图 (8个):
   - frontend/src/views/user/KeysView.vue
   - frontend/src/views/user/DashboardView.vue
   - frontend/src/views/user/UsageView.vue
   - frontend/src/views/user/RedeemView.vue
   - frontend/src/views/admin/UsersView.vue
   - frontend/src/views/admin/UsageView.vue
   - frontend/src/views/admin/RedeemView.vue
   - frontend/src/views/admin/SubscriptionsView.vue

**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致

* fix(frontend): 补充遗漏的时间格式化统一

**补充修复**(基于 code review 发现的遗漏):

1. 增强 utils/format.ts:
   - 新增 formatTime() - 格式: HH:mm

2. 修复 4 个遗漏的文件:
   - src/views/admin/UsersView.vue
     * 删除 formatExpiresAt(),改用 formatDateTime()
     * 修复订阅过期时间 tooltip 显示格式不一致问题

   - src/views/user/ProfileView.vue
     * 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
     * 统一会员起始时间显示格式

   - src/views/user/SubscriptionsView.vue
     * 修改 formatExpirationDate() 使用 formatDateOnly()
     * 保留天数计算逻辑

   - src/components/account/AccountStatusIndicator.vue
     * 删除本地 formatTime(),改用 utils/format 中的统一函数
     * 修复 rate limit 和 overload 重置时间显示

**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓

**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
parent cf8a6452
...@@ -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) {
......
...@@ -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
......
<template>
<div class="table-page-layout" :class="{ 'mobile-mode': isMobile }">
<!-- 固定区域:操作按钮 -->
<div v-if="$slots.actions" class="layout-section-fixed">
<slot name="actions" />
</div>
<!-- 固定区域:搜索和过滤器 -->
<div v-if="$slots.filters" class="layout-section-fixed">
<slot name="filters" />
</div>
<!-- 滚动区域:表格 -->
<div class="layout-section-scrollable">
<div class="card table-scroll-container">
<slot name="table" />
</div>
</div>
<!-- 固定区域:分页器 -->
<div v-if="$slots.pagination" class="layout-section-fixed">
<slot name="pagination" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
/* 桌面端:Flexbox 布局 */
.table-page-layout {
@apply flex flex-col gap-6;
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
}
.layout-section-fixed {
@apply flex-shrink-0;
}
.layout-section-scrollable {
@apply flex-1 min-h-0 flex flex-col;
}
/* 表格滚动容器 - 增强版表体滚动方案 */
.table-scroll-container {
@apply flex flex-col overflow-hidden h-full bg-white dark:bg-dark-800 rounded-2xl border border-gray-200 dark:border-dark-700 shadow-sm;
}
.table-scroll-container :deep(.table-wrapper) {
@apply flex-1 overflow-x-auto overflow-y-auto;
/* 确保横向滚动条显示在最底部 */
scrollbar-gutter: stable;
}
.table-scroll-container :deep(table) {
@apply w-full;
min-width: max-content; /* 关键:确保表格宽度根据内容撑开,从而触发横向滚动 */
display: table; /* 使用标准 table 布局以支持 sticky 列 */
}
.table-scroll-container :deep(thead) {
@apply bg-gray-50/80 dark:bg-dark-800/80 backdrop-blur-sm;
}
.table-scroll-container :deep(tbody) {
/* 保持默认 table-row-group 显示,不使用 block */
}
.table-scroll-container :deep(th) {
/* 表头高度和文字加粗优化 */
@apply px-5 py-4 text-left text-sm font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-dark-700;
@apply uppercase tracking-wider; /* 让表头更有设计感 */
}
.table-scroll-container :deep(td) {
@apply px-5 py-4 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-dark-800;
}
/* 移动端:恢复正常滚动 */
.table-page-layout.mobile-mode .table-scroll-container {
@apply h-auto overflow-visible border-none shadow-none bg-transparent;
}
.table-page-layout.mobile-mode .layout-section-scrollable {
@apply flex-none min-h-fit;
}
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
@apply overflow-visible;
}
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
@apply flex-none;
display: table;
min-width: 100%;
}
</style>
...@@ -30,13 +30,56 @@ export default { ...@@ -30,13 +30,56 @@ export default {
title: 'Supported Providers', title: 'Supported Providers',
description: 'Unified API interface for AI services', description: 'Unified API interface for AI services',
supported: 'Supported', supported: 'Supported',
soon: 'Soon' soon: 'Soon',
claude: 'Claude',
gemini: 'Gemini',
more: 'More'
}, },
footer: { footer: {
allRightsReserved: 'All rights reserved.' allRightsReserved: 'All rights reserved.'
} }
}, },
// Setup Wizard
setup: {
title: 'Sub2API Setup',
description: 'Configure your Sub2API instance',
database: {
title: 'Database Configuration',
host: 'Host',
port: 'Port',
username: 'Username',
password: 'Password',
databaseName: 'Database Name',
sslMode: 'SSL Mode',
ssl: {
disable: 'Disable',
require: 'Require',
verifyCa: 'Verify CA',
verifyFull: 'Verify Full'
}
},
redis: {
title: 'Redis Configuration',
host: 'Host',
port: 'Port',
password: 'Password (optional)',
database: 'Database'
},
admin: {
title: 'Admin Account',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password'
},
ready: {
title: 'Ready to Install',
database: 'Database',
redis: 'Redis',
adminEmail: 'Admin Email'
}
},
// Common // Common
common: { common: {
loading: 'Loading...', loading: 'Loading...',
...@@ -142,7 +185,20 @@ export default { ...@@ -142,7 +185,20 @@ export default {
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
turnstileExpired: 'Verification expired, please try again', turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again', turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification' completeVerification: 'Please complete the verification',
verifyYourEmail: 'Verify Your Email',
sessionExpired: 'Session expired',
sessionExpiredDesc: 'Please go back to the registration page and start again.',
verificationCode: 'Verification Code',
verificationCodeHint: 'Enter the 6-digit code sent to your email',
sendingCode: 'Sending...',
clickToResend: 'Click to resend code',
resendCode: 'Resend verification code',
oauth: {
code: 'Code',
state: 'State',
fullUrl: 'Full URL'
}
}, },
// Dashboard // Dashboard
...@@ -377,6 +433,12 @@ export default { ...@@ -377,6 +433,12 @@ export default {
noData: 'No data found' noData: 'No data found'
}, },
// Table
table: {
expandActions: 'Expand More Actions',
collapseActions: 'Collapse Actions'
},
// Pagination // Pagination
pagination: { pagination: {
showing: 'Showing', showing: 'Showing',
...@@ -584,6 +646,7 @@ export default { ...@@ -584,6 +646,7 @@ export default {
actions: 'Actions', actions: 'Actions',
billingType: 'Billing Type' billingType: 'Billing Type'
}, },
rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts', accountsCount: '{count} accounts',
form: { form: {
name: 'Name', name: 'Name',
...@@ -742,6 +805,13 @@ export default { ...@@ -742,6 +805,13 @@ export default {
openai: 'OpenAI', openai: 'OpenAI',
gemini: 'Gemini' gemini: 'Gemini'
}, },
types: {
oauth: 'OAuth',
chatgptOauth: 'ChatGPT OAuth',
responsesApi: 'Responses API',
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist'
},
columns: { columns: {
name: 'Name', name: 'Name',
platformType: 'Platform/Type', platformType: 'Platform/Type',
...@@ -1022,6 +1092,7 @@ export default { ...@@ -1022,6 +1092,7 @@ export default {
todayOverview: 'Today Overview', todayOverview: 'Today Overview',
cost: 'Cost', cost: 'Cost',
requests: 'Requests', requests: 'Requests',
tokens: 'Tokens',
highestCostDay: 'Highest Cost Day', highestCostDay: 'Highest Cost Day',
highestRequestDay: 'Highest Request Day', highestRequestDay: 'Highest Request Day',
date: 'Date', date: 'Date',
...@@ -1037,6 +1108,9 @@ export default { ...@@ -1037,6 +1108,9 @@ export default {
todayCost: 'Today Cost', todayCost: 'Today Cost',
usageTrend: '30-Day Cost & Request Trend', usageTrend: '30-Day Cost & Request Trend',
noData: 'No usage data available for this account' noData: 'No usage data available for this account'
},
usageWindow: {
statsTitle: '5-Hour Window Usage Statistics'
} }
}, },
...@@ -1070,6 +1144,10 @@ export default { ...@@ -1070,6 +1144,10 @@ export default {
enterProxyName: 'Enter proxy name', enterProxyName: 'Enter proxy name',
leaveEmptyToKeep: 'Leave empty to keep current', leaveEmptyToKeep: 'Leave empty to keep current',
optionalAuth: 'Optional authentication', optionalAuth: 'Optional authentication',
form: {
hostPlaceholder: 'proxy.example.com',
portPlaceholder: '8080'
},
noProxiesYet: 'No proxies yet', noProxiesYet: 'No proxies yet',
createFirstProxy: 'Create your first proxy to route traffic through it.', createFirstProxy: 'Create your first proxy to route traffic through it.',
// Batch import // Batch import
...@@ -1174,6 +1252,18 @@ export default { ...@@ -1174,6 +1252,18 @@ export default {
searchUserPlaceholder: 'Search user by email...', searchUserPlaceholder: 'Search user by email...',
selectedUser: 'Selected', selectedUser: 'Selected',
user: 'User', user: 'User',
account: 'Account',
group: 'Group',
requestId: 'Request ID',
allModels: 'All Models',
allAccounts: 'All Accounts',
allGroups: 'All Groups',
allTypes: 'All Types',
allBillingTypes: 'All Billing',
inputCost: 'Input Cost',
outputCost: 'Output Cost',
cacheCreationCost: 'Cache Creation Cost',
cacheReadCost: 'Cache Read Cost',
failedToLoad: 'Failed to load usage records' failedToLoad: 'Failed to load usage records'
}, },
...@@ -1211,16 +1301,20 @@ export default { ...@@ -1211,16 +1301,20 @@ export default {
title: 'Site Settings', title: 'Site Settings',
description: 'Customize site branding', description: 'Customize site branding',
siteName: 'Site Name', siteName: 'Site Name',
siteNamePlaceholder: 'Sub2API',
siteNameHint: 'Displayed in emails and page titles', siteNameHint: 'Displayed in emails and page titles',
siteSubtitle: 'Site Subtitle', siteSubtitle: 'Site Subtitle',
siteSubtitlePlaceholder: 'Subscription to API Conversion Platform',
siteSubtitleHint: 'Displayed on login and register pages', siteSubtitleHint: 'Displayed on login and register pages',
apiBaseUrl: 'API Base URL', apiBaseUrl: 'API Base URL',
apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
contactInfo: 'Contact Info', contactInfo: 'Contact Info',
contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoPlaceholder: 'e.g., QQ: 123456789',
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
docUrl: 'Documentation URL', docUrl: 'Documentation URL',
docUrlPlaceholder: 'https://docs.example.com',
docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.', docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.',
siteLogo: 'Site Logo', siteLogo: 'Site Logo',
uploadImage: 'Upload Image', uploadImage: 'Upload Image',
...@@ -1236,12 +1330,18 @@ export default { ...@@ -1236,12 +1330,18 @@ export default {
testConnection: 'Test Connection', testConnection: 'Test Connection',
testing: 'Testing...', testing: 'Testing...',
host: 'SMTP Host', host: 'SMTP Host',
hostPlaceholder: 'smtp.gmail.com',
port: 'SMTP Port', port: 'SMTP Port',
portPlaceholder: '587',
username: 'SMTP Username', username: 'SMTP Username',
usernamePlaceholder: 'your-email@gmail.com',
password: 'SMTP Password', password: 'SMTP Password',
passwordPlaceholder: '********',
passwordHint: 'Leave empty to keep existing password', passwordHint: 'Leave empty to keep existing password',
fromEmail: 'From Email', fromEmail: 'From Email',
fromEmailPlaceholder: 'noreply@example.com',
fromName: 'From Name', fromName: 'From Name',
fromNamePlaceholder: 'Sub2API',
useTls: 'Use TLS', useTls: 'Use TLS',
useTlsHint: 'Enable TLS encryption for SMTP connection' useTlsHint: 'Enable TLS encryption for SMTP connection'
}, },
...@@ -1249,6 +1349,7 @@ export default { ...@@ -1249,6 +1349,7 @@ export default {
title: 'Send Test Email', title: 'Send Test Email',
description: 'Send a test email to verify your SMTP configuration', description: 'Send a test email to verify your SMTP configuration',
recipientEmail: 'Recipient Email', recipientEmail: 'Recipient Email',
recipientEmailPlaceholder: 'test@example.com',
sendTestEmail: 'Send Test Email', sendTestEmail: 'Send Test Email',
sending: 'Sending...', sending: 'Sending...',
enterRecipientHint: 'Please enter a recipient email address' enterRecipientHint: 'Please enter a recipient email address'
......
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