From 254f12543cecd0e56af552fcb628a8a6d9390555 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Sat, 27 Dec 2025 10:50:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=89=8D=E7=AB=AF=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=BC=98=E5=8C=96=E4=B8=8E=E4=BD=BF=E7=94=A8=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 方法调用 --- .../internal/handler/admin/usage_handler.go | 56 +++- backend/internal/handler/usage_handler.go | 65 +++- .../pkg/usagestats/usage_log_types.go | 13 +- backend/internal/repository/usage_log_repo.go | 19 +- backend/internal/server/api_contract_test.go | 5 +- .../components/account/AccountStatsModal.vue | 4 +- .../account/AccountStatusIndicator.vue | 10 +- .../account/AccountTodayStatsCell.vue | 15 +- .../components/account/AccountUsageCell.vue | 5 +- .../components/account/CreateAccountModal.vue | 10 +- .../components/account/ReAuthAccountModal.vue | 8 +- .../components/account/UsageProgressBar.vue | 5 +- frontend/src/components/common/DataTable.vue | 295 ++++++++++++++++- .../src/components/common/DateRangePicker.vue | 31 +- .../src/components/common/GroupSelector.vue | 5 +- frontend/src/components/common/Pagination.vue | 4 +- frontend/src/components/common/Select.vue | 16 +- .../src/components/common/VersionBadge.vue | 2 +- .../src/components/layout/TablePageLayout.vue | 114 +++++++ frontend/src/i18n/locales/en.ts | 105 +++++- frontend/src/i18n/locales/zh.ts | 98 +++++- frontend/src/style.css | 11 + frontend/src/types/index.ts | 21 ++ frontend/src/utils/format.ts | 27 ++ frontend/src/views/HomeView.vue | 6 +- frontend/src/views/NotFoundView.vue | 6 +- frontend/src/views/admin/AccountsView.vue | 306 +++++++++--------- frontend/src/views/admin/GroupsView.vue | 145 +++++---- frontend/src/views/admin/ProxiesView.vue | 53 +-- frontend/src/views/admin/RedeemView.vue | 68 ++-- frontend/src/views/admin/SettingsView.vue | 27 +- .../src/views/admin/SubscriptionsView.vue | 28 +- frontend/src/views/admin/UsageView.vue | 227 +++++++++++-- frontend/src/views/admin/UsersView.vue | 290 ++++++++--------- frontend/src/views/auth/EmailVerifyView.vue | 25 +- frontend/src/views/auth/OAuthCallbackView.vue | 8 +- frontend/src/views/setup/SetupWizardView.vue | 67 ++-- frontend/src/views/user/DashboardView.vue | 17 +- frontend/src/views/user/KeysView.vue | 48 ++- frontend/src/views/user/ProfileView.vue | 12 +- frontend/src/views/user/RedeemView.vue | 15 +- frontend/src/views/user/SubscriptionsView.vue | 7 +- frontend/src/views/user/UsageView.vue | 66 ++-- 43 files changed, 1673 insertions(+), 692 deletions(-) create mode 100644 frontend/src/components/layout/TablePageLayout.vue diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index fc9a944c..9a8e3244 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) // Parse filters - var userID, apiKeyID int64 + var userID, apiKeyID, accountID, groupID int64 if userIDStr := c.Query("user_id"); userIDStr != "" { id, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { @@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) { 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 var startTime, endTime *time.Time if startDateStr := c.Query("start_date"); startDateStr != "" { @@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} filters := usagestats.UsageLogFilters{ - UserID: userID, - ApiKeyID: apiKeyID, - StartTime: startTime, - EndTime: endTime, + UserID: userID, + ApiKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + Stream: stream, + BillingType: billingType, + StartTime: startTime, + EndTime: endTime, } records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters) diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index dd8340e7..15b30bbb 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -8,6 +8,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "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" "github.com/Wei-Shaw/sub2api/internal/service" @@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) { apiKeyID = id } - params := pagination.PaginationParams{Page: page, PageSize: pageSize} - var records []service.UsageLog - var result *pagination.PaginationResult - var err error + // Parse additional filters + model := c.Query("model") - if apiKeyID > 0 { - records, result, err = h.usageService.ListByApiKey(c.Request.Context(), apiKeyID, params) - } else { - records, result, err = h.usageService.ListByUser(c.Request.Context(), subject.UserID, params) + 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 + 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 { response.ErrorFrom(c, err) return diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index fb3ac2fc..946501d4 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -127,10 +127,15 @@ type UserDashboardStats struct { // UsageLogFilters represents filters for usage log queries type UsageLogFilters struct { - UserID int64 - ApiKeyID int64 - StartTime *time.Time - EndTime *time.Time + UserID int64 + ApiKeyID int64 + AccountID int64 + GroupID int64 + Model string + Stream *bool + BillingType *int8 + StartTime *time.Time + EndTime *time.Time } // UsageStats represents usage statistics diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index f16e5fd7..dd912c5a 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -631,6 +631,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat if filters.ApiKeyID > 0 { 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 { db = db.Where("created_at >= ?", *filters.StartTime) } @@ -642,8 +657,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat return nil, nil, err } - // Preload user and api_key for display - if err := db.Preload("User").Preload("ApiKey"). + // Preload user, api_key, account, and group for display + if err := db.Preload("User").Preload("ApiKey").Preload("Account").Preload("Group"). Offset(params.Offset()).Limit(params.Limit()). Order("id DESC").Find(&logs).Error; err != nil { return nil, nil, err diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 8833a4e6..1aeedf8d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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) { - 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) { diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue index 632036e4..a82bbfb2 100644 --- a/frontend/src/components/account/AccountStatsModal.vue +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -226,7 +226,9 @@ }}
- Tokens + {{ + t('admin.accounts.stats.tokens') + }} {{ formatTokens(stats.summary.today?.tokens || 0) }} diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 9bc208c3..c1ca08fa 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -89,6 +89,7 @@ diff --git a/frontend/src/components/account/AccountTodayStatsCell.vue b/frontend/src/components/account/AccountTodayStatsCell.vue index c70a2c0f..b8bbc618 100644 --- a/frontend/src/components/account/AccountTodayStatsCell.vue +++ b/frontend/src/components/account/AccountTodayStatsCell.vue @@ -16,21 +16,27 @@
- Req: + {{ t('admin.accounts.stats.requests') }}: {{ formatNumber(stats.requests) }}
- Tok: + {{ t('admin.accounts.stats.tokens') }}: {{ formatTokens(stats.tokens) }}
- Cost: + {{ t('admin.accounts.stats.cost') }}: {{ formatCurrency(stats.cost) }} @@ -44,6 +50,7 @@ + + diff --git a/frontend/src/components/common/DateRangePicker.vue b/frontend/src/components/common/DateRangePicker.vue index fd93d6e9..be641f9b 100644 --- a/frontend/src/components/common/DateRangePicker.vue +++ b/frontend/src/components/common/DateRangePicker.vue @@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate) const localEndDate = ref(props.endDate) const activePreset = ref('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[] = [ { @@ -152,7 +167,7 @@ const presets: DatePreset[] = [ getRange: () => { const d = new Date() d.setDate(d.getDate() - 1) - const yesterday = d.toISOString().split('T')[0] + const yesterday = formatDateToString(d) return { start: yesterday, end: yesterday } } }, @@ -163,7 +178,7 @@ const presets: DatePreset[] = [ const end = today.value const d = new Date() d.setDate(d.getDate() - 6) - const start = d.toISOString().split('T')[0] + const start = formatDateToString(d) return { start, end } } }, @@ -174,7 +189,7 @@ const presets: DatePreset[] = [ const end = today.value const d = new Date() d.setDate(d.getDate() - 13) - const start = d.toISOString().split('T')[0] + const start = formatDateToString(d) return { start, end } } }, @@ -185,7 +200,7 @@ const presets: DatePreset[] = [ const end = today.value const d = new Date() d.setDate(d.getDate() - 29) - const start = d.toISOString().split('T')[0] + const start = formatDateToString(d) return { start, end } } }, @@ -194,7 +209,7 @@ const presets: DatePreset[] = [ value: 'thisMonth', getRange: () => { 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 } } }, @@ -203,8 +218,8 @@ const presets: DatePreset[] = [ value: 'lastMonth', getRange: () => { const now = new Date() - const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0] - const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0] + const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1)) + const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0)) return { start, end } } } diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index 7b064573..b6d88ddd 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -11,7 +11,7 @@ v-for="group in filteredGroups" :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" - :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 })" > import { computed } from 'vue' +import { useI18n } from 'vue-i18n' import GroupBadge from './GroupBadge.vue' import type { Group, GroupPlatform } from '@/types' +const { t } = useI18n() + interface Props { modelValue: number[] groups: Group[] diff --git a/frontend/src/components/common/Pagination.vue b/frontend/src/components/common/Pagination.vue index 1242988d..9e76e97f 100644 --- a/frontend/src/components/common/Pagination.vue +++ b/frontend/src/components/common/Pagination.vue @@ -202,8 +202,8 @@ const goToPage = (newPage: number) => { } } -const handlePageSizeChange = (value: string | number | null) => { - if (value === null) return +const handlePageSizeChange = (value: string | number | boolean | null) => { + if (value === null || typeof value === 'boolean') return const newPageSize = typeof value === 'string' ? parseInt(value) : value emit('update:pageSize', newPageSize) // Reset to first page when page size changes diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 5b7bcc78..d0e52541 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -60,7 +60,7 @@
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() export interface SelectOption { - value: string | number | null + value: string | number | boolean | null label: string disabled?: boolean [key: string]: unknown } interface Props { - modelValue: string | number | null | undefined + modelValue: string | number | boolean | null | undefined options: SelectOption[] | Array> placeholder?: string disabled?: boolean @@ -116,8 +116,8 @@ interface Props { } interface Emits { - (e: 'update:modelValue', value: string | number | null): void - (e: 'change', value: string | number | null, option: SelectOption | null): void + (e: 'update:modelValue', value: string | number | boolean | null): void + (e: 'change', value: string | number | boolean | null, option: SelectOption | null): void } const props = withDefaults(defineProps(), { @@ -144,11 +144,11 @@ const searchInputRef = ref(null) const getOptionValue = ( option: SelectOption | Record -): string | number | null | undefined => { +): string | number | boolean | null | undefined => { 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 => { diff --git a/frontend/src/components/common/VersionBadge.vue b/frontend/src/components/common/VersionBadge.vue index 151cf789..3fcfd9c2 100644 --- a/frontend/src/components/common/VersionBadge.vue +++ b/frontend/src/components/common/VersionBadge.vue @@ -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-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')" > v{{ currentVersion }} +
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+
+ + + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9e26e99b..269f1a1a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -30,13 +30,56 @@ export default { title: 'Supported Providers', description: 'Unified API interface for AI services', supported: 'Supported', - soon: 'Soon' + soon: 'Soon', + claude: 'Claude', + gemini: 'Gemini', + more: 'More' }, footer: { 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: { loading: 'Loading...', @@ -142,7 +185,20 @@ export default { accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', turnstileExpired: 'Verification expired, 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 @@ -377,6 +433,12 @@ export default { noData: 'No data found' }, + // Table + table: { + expandActions: 'Expand More Actions', + collapseActions: 'Collapse Actions' + }, + // Pagination pagination: { showing: 'Showing', @@ -584,6 +646,7 @@ export default { actions: 'Actions', billingType: 'Billing Type' }, + rateAndAccounts: '{rate}x rate · {count} accounts', accountsCount: '{count} accounts', form: { name: 'Name', @@ -742,6 +805,13 @@ export default { openai: 'OpenAI', gemini: 'Gemini' }, + types: { + oauth: 'OAuth', + chatgptOauth: 'ChatGPT OAuth', + responsesApi: 'Responses API', + googleOauth: 'Google OAuth', + codeAssist: 'Code Assist' + }, columns: { name: 'Name', platformType: 'Platform/Type', @@ -1022,6 +1092,7 @@ export default { todayOverview: 'Today Overview', cost: 'Cost', requests: 'Requests', + tokens: 'Tokens', highestCostDay: 'Highest Cost Day', highestRequestDay: 'Highest Request Day', date: 'Date', @@ -1037,6 +1108,9 @@ export default { todayCost: 'Today Cost', usageTrend: '30-Day Cost & Request Trend', noData: 'No usage data available for this account' + }, + usageWindow: { + statsTitle: '5-Hour Window Usage Statistics' } }, @@ -1070,6 +1144,10 @@ export default { enterProxyName: 'Enter proxy name', leaveEmptyToKeep: 'Leave empty to keep current', optionalAuth: 'Optional authentication', + form: { + hostPlaceholder: 'proxy.example.com', + portPlaceholder: '8080' + }, noProxiesYet: 'No proxies yet', createFirstProxy: 'Create your first proxy to route traffic through it.', // Batch import @@ -1174,6 +1252,18 @@ export default { searchUserPlaceholder: 'Search user by email...', selectedUser: 'Selected', 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' }, @@ -1211,16 +1301,20 @@ export default { title: 'Site Settings', description: 'Customize site branding', siteName: 'Site Name', + siteNamePlaceholder: 'Sub2API', siteNameHint: 'Displayed in emails and page titles', siteSubtitle: 'Site Subtitle', + siteSubtitlePlaceholder: 'Subscription to API Conversion Platform', siteSubtitleHint: 'Displayed on login and register pages', apiBaseUrl: 'API Base URL', + apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlHint: 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', contactInfo: 'Contact Info', contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', docUrl: 'Documentation URL', + docUrlPlaceholder: 'https://docs.example.com', docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.', siteLogo: 'Site Logo', uploadImage: 'Upload Image', @@ -1236,12 +1330,18 @@ export default { testConnection: 'Test Connection', testing: 'Testing...', host: 'SMTP Host', + hostPlaceholder: 'smtp.gmail.com', port: 'SMTP Port', + portPlaceholder: '587', username: 'SMTP Username', + usernamePlaceholder: 'your-email@gmail.com', password: 'SMTP Password', + passwordPlaceholder: '********', passwordHint: 'Leave empty to keep existing password', fromEmail: 'From Email', + fromEmailPlaceholder: 'noreply@example.com', fromName: 'From Name', + fromNamePlaceholder: 'Sub2API', useTls: 'Use TLS', useTlsHint: 'Enable TLS encryption for SMTP connection' }, @@ -1249,6 +1349,7 @@ export default { title: 'Send Test Email', description: 'Send a test email to verify your SMTP configuration', recipientEmail: 'Recipient Email', + recipientEmailPlaceholder: 'test@example.com', sendTestEmail: 'Send Test Email', sending: 'Sending...', enterRecipientHint: 'Please enter a recipient email address' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b0d3585a..baf851ce 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -27,13 +27,56 @@ export default { title: '支持的服务商', description: 'AI 服务的统一 API 接口', supported: '已支持', - soon: '即将推出' + soon: '即将推出', + claude: 'Claude', + gemini: 'Gemini', + more: '更多' }, footer: { allRightsReserved: '保留所有权利。' } }, + // Setup Wizard + setup: { + title: 'Sub2API 安装向导', + description: '配置您的 Sub2API 实例', + database: { + title: '数据库配置', + host: '主机', + port: '端口', + username: '用户名', + password: '密码', + databaseName: '数据库名称', + sslMode: 'SSL 模式', + ssl: { + disable: '禁用', + require: '要求', + verifyCa: '验证 CA', + verifyFull: '完全验证' + } + }, + redis: { + title: 'Redis 配置', + host: '主机', + port: '端口', + password: '密码(可选)', + database: '数据库' + }, + admin: { + title: '管理员账户', + email: '邮箱', + password: '密码', + confirmPassword: '确认密码' + }, + ready: { + title: '准备安装', + database: '数据库', + redis: 'Redis', + adminEmail: '管理员邮箱' + } + }, + // Common common: { loading: '加载中...', @@ -139,7 +182,20 @@ export default { accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', turnstileExpired: '验证已过期,请重试', turnstileFailed: '验证失败,请重试', - completeVerification: '请完成验证' + completeVerification: '请完成验证', + verifyYourEmail: '验证您的邮箱', + sessionExpired: '会话已过期', + sessionExpiredDesc: '请返回注册页面重新开始。', + verificationCode: '验证码', + verificationCodeHint: '请输入发送到您邮箱的6位验证码', + sendingCode: '发送中...', + clickToResend: '点击重新发送验证码', + resendCode: '重新发送验证码', + oauth: { + code: '授权码', + state: '状态', + fullUrl: '完整URL' + } }, // Dashboard @@ -373,6 +429,12 @@ export default { noData: '暂无数据' }, + // Table + table: { + expandActions: '展开更多操作', + collapseActions: '收起操作' + }, + // Pagination pagination: { showing: '显示', @@ -689,6 +751,7 @@ export default { exclusiveFilter: '独占', nonExclusive: '非独占', public: '公开', + rateAndAccounts: '{rate}x 费率 · {count} 个账号', accountsCount: '{count} 个账号', enterGroupName: '请输入分组名称', optionalDescription: '可选描述', @@ -848,6 +911,10 @@ export default { }, types: { oauth: 'OAuth', + chatgptOauth: 'ChatGPT OAuth', + responsesApi: 'Responses API', + googleOauth: 'Google OAuth', + codeAssist: 'Code Assist', api_key: 'API Key', cookie: 'Cookie' }, @@ -857,6 +924,9 @@ export default { error: '错误', cooldown: '冷却中' }, + usageWindow: { + statsTitle: '5小时窗口用量统计' + }, form: { nameLabel: '账号名称', namePlaceholder: '请输入账号名称', @@ -1125,6 +1195,7 @@ export default { todayOverview: '今日概览', cost: '费用', requests: '请求', + tokens: 'Token', highestCostDay: '最高费用日', highestRequestDay: '最高请求日', date: '日期', @@ -1364,6 +1435,18 @@ export default { searchUserPlaceholder: '按邮箱搜索用户...', selectedUser: '已选择', user: '用户', + account: '账户', + group: '分组', + requestId: '请求ID', + allModels: '全部模型', + allAccounts: '全部账户', + allGroups: '全部分组', + allTypes: '全部类型', + allBillingTypes: '全部计费', + inputCost: '输入成本', + outputCost: '输出成本', + cacheCreationCost: '缓存创建成本', + cacheReadCost: '缓存读取成本', failedToLoad: '加载使用记录失败' }, @@ -1402,15 +1485,19 @@ export default { description: '自定义站点品牌', siteName: '站点名称', siteNameHint: '显示在邮件和页面标题中', + siteNamePlaceholder: 'Sub2API', siteSubtitle: '站点副标题', siteSubtitleHint: '显示在登录和注册页面', + siteSubtitlePlaceholder: '订阅转 API 转换平台', apiBaseUrl: 'API 端点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', + apiBaseUrlPlaceholder: 'https://api.example.com', contactInfo: '客服联系方式', contactInfoPlaceholder: '例如:QQ: 123456789', contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', docUrl: '文档链接', docUrlHint: '文档网站的链接。留空则隐藏文档链接。', + docUrlPlaceholder: 'https://docs.example.com', siteLogo: '站点Logo', uploadImage: '上传图片', remove: '移除', @@ -1425,12 +1512,18 @@ export default { testConnection: '测试连接', testing: '测试中...', host: 'SMTP 主机', + hostPlaceholder: 'smtp.gmail.com', port: 'SMTP 端口', + portPlaceholder: '587', username: 'SMTP 用户名', + usernamePlaceholder: 'your-email@gmail.com', password: 'SMTP 密码', + passwordPlaceholder: '********', passwordHint: '留空以保留现有密码', fromEmail: '发件人邮箱', + fromEmailPlaceholder: 'noreply@example.com', fromName: '发件人名称', + fromNamePlaceholder: 'Sub2API', useTls: '使用 TLS', useTlsHint: '为 SMTP 连接启用 TLS 加密' }, @@ -1438,6 +1531,7 @@ export default { title: '发送测试邮件', description: '发送测试邮件以验证 SMTP 配置', recipientEmail: '收件人邮箱', + recipientEmailPlaceholder: 'test@example.com', sendTestEmail: '发送测试邮件', sending: '发送中...', enterRecipientHint: '请输入收件人邮箱地址' diff --git a/frontend/src/style.css b/frontend/src/style.css index 2c52407b..7c32af5b 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -488,6 +488,17 @@ @apply bg-gray-900 text-gray-100; @apply overflow-x-auto rounded-xl p-4; } + + /* ============ 表格页面布局优化 ============ */ + /* 表格容器 - 默认仅支持水平滚动 */ + .table-wrapper { + overflow-x: auto; + } + + /* 表头固定时添加底部阴影,增强视觉层次 */ + .table-wrapper thead.sticky { + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + } } @layer utilities { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4d004eb0..f43cba42 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -442,22 +442,38 @@ export interface UsageLog { user_id: number api_key_id: number account_id: number | null + request_id: string model: string + + group_id: number | null + subscription_id: number | null + input_tokens: number output_tokens: number cache_creation_tokens: number cache_read_tokens: number + cache_creation_5m_tokens: number + cache_creation_1h_tokens: number + + input_cost: number + output_cost: number + cache_creation_cost: number + cache_read_cost: number total_cost: number actual_cost: number rate_multiplier: number + billing_type: BillingType stream: boolean duration_ms: number first_token_ms: number | null created_at: string + user?: User api_key?: ApiKey account?: Account + group?: Group + subscription?: UserSubscription } export interface RedeemCode { @@ -677,6 +693,11 @@ export interface UsageQueryParams { page_size?: number api_key_id?: number user_id?: number + account_id?: number + group_id?: number + model?: string + stream?: boolean + billing_type?: number start_date?: string end_date?: string } diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 7bdfda47..bd545d42 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -114,3 +114,30 @@ export function formatDate( .replace('mm', minutes) .replace('ss', seconds) } + +/** + * 格式化日期(只显示日期部分) + * @param date 日期字符串或 Date 对象 + * @returns 格式化后的日期字符串,格式为 YYYY-MM-DD + */ +export function formatDateOnly(date: string | Date | null | undefined): string { + return formatDate(date, 'YYYY-MM-DD') +} + +/** + * 格式化日期时间(完整格式) + * @param date 日期字符串或 Date 对象 + * @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss + */ +export function formatDateTime(date: string | Date | null | undefined): string { + return formatDate(date, 'YYYY-MM-DD HH:mm:ss') +} + +/** + * 格式化时间(只显示时分) + * @param date 日期字符串或 Date 对象 + * @returns 格式化后的时间字符串,格式为 HH:mm + */ +export function formatTime(date: string | Date | null | undefined): string { + return formatDate(date, 'HH:mm') +} diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index b5ab3c6a..8eccd3c2 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -385,7 +385,7 @@ > C
- Claude + {{ t('home.providers.claude') }} {{ t('home.providers.supported') }} G
- Gemini + {{ t('home.providers.gemini') }} {{ t('home.providers.supported') }} +
- More + {{ t('home.providers.more') }} {{ t('home.providers.soon') }}
-

Page Not Found

+

+ {{ t('errors.pageNotFound') }} +

The page you are looking for doesn't exist or has been moved.

@@ -100,8 +102,10 @@