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 @@