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 方法调用
This commit is contained in:
IanShaw
2025-12-27 10:50:25 +08:00
committed by GitHub
parent cf8a64528c
commit 254f12543c
43 changed files with 1673 additions and 692 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {
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>

View File

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

View File

@@ -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<{
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 () => {
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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,59 @@
<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 @@
</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 @@
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 @@
</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(() => {
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>

View File

@@ -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[] = [
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[] = [
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[] = [
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[] = [
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[] = [
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[] = [
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 }
} }
} }

View File

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

View File

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

View File

@@ -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'
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 {
} }
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)
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 => {

View File

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

View File

@@ -0,0 +1,114 @@
<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>

View File

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

View File

@@ -27,13 +27,56 @@ export default {
title: '支持的服务商', title: '支持的服务商',
description: 'AI 服务的统一 API 接口', description: 'AI 服务的统一 API 接口',
supported: '已支持', supported: '已支持',
soon: '即将推出' soon: '即将推出',
claude: 'Claude',
gemini: 'Gemini',
more: '更多'
}, },
footer: { footer: {
allRightsReserved: '保留所有权利。' 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
common: { common: {
loading: '加载中...', loading: '加载中...',
@@ -139,7 +182,20 @@ export default {
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。', accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
turnstileExpired: '验证已过期,请重试', turnstileExpired: '验证已过期,请重试',
turnstileFailed: '验证失败,请重试', turnstileFailed: '验证失败,请重试',
completeVerification: '请完成验证' completeVerification: '请完成验证',
verifyYourEmail: '验证您的邮箱',
sessionExpired: '会话已过期',
sessionExpiredDesc: '请返回注册页面重新开始。',
verificationCode: '验证码',
verificationCodeHint: '请输入发送到您邮箱的6位验证码',
sendingCode: '发送中...',
clickToResend: '点击重新发送验证码',
resendCode: '重新发送验证码',
oauth: {
code: '授权码',
state: '状态',
fullUrl: '完整URL'
}
}, },
// Dashboard // Dashboard
@@ -373,6 +429,12 @@ export default {
noData: '暂无数据' noData: '暂无数据'
}, },
// Table
table: {
expandActions: '展开更多操作',
collapseActions: '收起操作'
},
// Pagination // Pagination
pagination: { pagination: {
showing: '显示', showing: '显示',
@@ -689,6 +751,7 @@ export default {
exclusiveFilter: '独占', exclusiveFilter: '独占',
nonExclusive: '非独占', nonExclusive: '非独占',
public: '公开', public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号', accountsCount: '{count} 个账号',
enterGroupName: '请输入分组名称', enterGroupName: '请输入分组名称',
optionalDescription: '可选描述', optionalDescription: '可选描述',
@@ -848,6 +911,10 @@ export default {
}, },
types: { types: {
oauth: 'OAuth', oauth: 'OAuth',
chatgptOauth: 'ChatGPT OAuth',
responsesApi: 'Responses API',
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist',
api_key: 'API Key', api_key: 'API Key',
cookie: 'Cookie' cookie: 'Cookie'
}, },
@@ -857,6 +924,9 @@ export default {
error: '错误', error: '错误',
cooldown: '冷却中' cooldown: '冷却中'
}, },
usageWindow: {
statsTitle: '5小时窗口用量统计'
},
form: { form: {
nameLabel: '账号名称', nameLabel: '账号名称',
namePlaceholder: '请输入账号名称', namePlaceholder: '请输入账号名称',
@@ -1125,6 +1195,7 @@ export default {
todayOverview: '今日概览', todayOverview: '今日概览',
cost: '费用', cost: '费用',
requests: '请求', requests: '请求',
tokens: 'Token',
highestCostDay: '最高费用日', highestCostDay: '最高费用日',
highestRequestDay: '最高请求日', highestRequestDay: '最高请求日',
date: '日期', date: '日期',
@@ -1364,6 +1435,18 @@ export default {
searchUserPlaceholder: '按邮箱搜索用户...', searchUserPlaceholder: '按邮箱搜索用户...',
selectedUser: '已选择', selectedUser: '已选择',
user: '用户', user: '用户',
account: '账户',
group: '分组',
requestId: '请求ID',
allModels: '全部模型',
allAccounts: '全部账户',
allGroups: '全部分组',
allTypes: '全部类型',
allBillingTypes: '全部计费',
inputCost: '输入成本',
outputCost: '输出成本',
cacheCreationCost: '缓存创建成本',
cacheReadCost: '缓存读取成本',
failedToLoad: '加载使用记录失败' failedToLoad: '加载使用记录失败'
}, },
@@ -1402,15 +1485,19 @@ export default {
description: '自定义站点品牌', description: '自定义站点品牌',
siteName: '站点名称', siteName: '站点名称',
siteNameHint: '显示在邮件和页面标题中', siteNameHint: '显示在邮件和页面标题中',
siteNamePlaceholder: 'Sub2API',
siteSubtitle: '站点副标题', siteSubtitle: '站点副标题',
siteSubtitleHint: '显示在登录和注册页面', siteSubtitleHint: '显示在登录和注册页面',
siteSubtitlePlaceholder: '订阅转 API 转换平台',
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com',
contactInfo: '客服联系方式', contactInfo: '客服联系方式',
contactInfoPlaceholder: '例如QQ: 123456789', contactInfoPlaceholder: '例如QQ: 123456789',
contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置', contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
docUrl: '文档链接', docUrl: '文档链接',
docUrlHint: '文档网站的链接。留空则隐藏文档链接。', docUrlHint: '文档网站的链接。留空则隐藏文档链接。',
docUrlPlaceholder: 'https://docs.example.com',
siteLogo: '站点Logo', siteLogo: '站点Logo',
uploadImage: '上传图片', uploadImage: '上传图片',
remove: '移除', remove: '移除',
@@ -1425,12 +1512,18 @@ export default {
testConnection: '测试连接', testConnection: '测试连接',
testing: '测试中...', testing: '测试中...',
host: 'SMTP 主机', host: 'SMTP 主机',
hostPlaceholder: 'smtp.gmail.com',
port: 'SMTP 端口', port: 'SMTP 端口',
portPlaceholder: '587',
username: 'SMTP 用户名', username: 'SMTP 用户名',
usernamePlaceholder: 'your-email@gmail.com',
password: 'SMTP 密码', password: 'SMTP 密码',
passwordPlaceholder: '********',
passwordHint: '留空以保留现有密码', passwordHint: '留空以保留现有密码',
fromEmail: '发件人邮箱', fromEmail: '发件人邮箱',
fromEmailPlaceholder: 'noreply@example.com',
fromName: '发件人名称', fromName: '发件人名称',
fromNamePlaceholder: 'Sub2API',
useTls: '使用 TLS', useTls: '使用 TLS',
useTlsHint: '为 SMTP 连接启用 TLS 加密' useTlsHint: '为 SMTP 连接启用 TLS 加密'
}, },
@@ -1438,6 +1531,7 @@ export default {
title: '发送测试邮件', title: '发送测试邮件',
description: '发送测试邮件以验证 SMTP 配置', description: '发送测试邮件以验证 SMTP 配置',
recipientEmail: '收件人邮箱', recipientEmail: '收件人邮箱',
recipientEmailPlaceholder: 'test@example.com',
sendTestEmail: '发送测试邮件', sendTestEmail: '发送测试邮件',
sending: '发送中...', sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址' enterRecipientHint: '请输入收件人邮箱地址'

View File

@@ -488,6 +488,17 @@
@apply bg-gray-900 text-gray-100; @apply bg-gray-900 text-gray-100;
@apply overflow-x-auto rounded-xl p-4; @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 { @layer utilities {

View File

@@ -442,22 +442,38 @@ export interface UsageLog {
user_id: number user_id: number
api_key_id: number api_key_id: number
account_id: number | null account_id: number | null
request_id: string
model: string model: string
group_id: number | null
subscription_id: number | null
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
cache_creation_tokens: number cache_creation_tokens: number
cache_read_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 total_cost: number
actual_cost: number actual_cost: number
rate_multiplier: number rate_multiplier: number
billing_type: BillingType billing_type: BillingType
stream: boolean stream: boolean
duration_ms: number duration_ms: number
first_token_ms: number | null first_token_ms: number | null
created_at: string created_at: string
user?: User user?: User
api_key?: ApiKey api_key?: ApiKey
account?: Account account?: Account
group?: Group
subscription?: UserSubscription
} }
export interface RedeemCode { export interface RedeemCode {
@@ -677,6 +693,11 @@ export interface UsageQueryParams {
page_size?: number page_size?: number
api_key_id?: number api_key_id?: number
user_id?: number user_id?: number
account_id?: number
group_id?: number
model?: string
stream?: boolean
billing_type?: number
start_date?: string start_date?: string
end_date?: string end_date?: string
} }

View File

@@ -114,3 +114,30 @@ export function formatDate(
.replace('mm', minutes) .replace('mm', minutes)
.replace('ss', seconds) .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')
}

View File

@@ -385,7 +385,7 @@
> >
<span class="text-xs font-bold text-white">C</span> <span class="text-xs font-bold text-white">C</span>
</div> </div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Claude</span> <span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.claude') }}</span>
<span <span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400" class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span >{{ t('home.providers.supported') }}</span
@@ -415,7 +415,7 @@
> >
<span class="text-xs font-bold text-white">G</span> <span class="text-xs font-bold text-white">G</span>
</div> </div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Gemini</span> <span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.gemini') }}</span>
<span <span
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400" class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
>{{ t('home.providers.supported') }}</span >{{ t('home.providers.supported') }}</span
@@ -430,7 +430,7 @@
> >
<span class="text-xs font-bold text-white">+</span> <span class="text-xs font-bold text-white">+</span>
</div> </div>
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">More</span> <span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.more') }}</span>
<span <span
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400" class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
>{{ t('home.providers.soon') }}</span >{{ t('home.providers.soon') }}</span

View File

@@ -43,7 +43,9 @@
<!-- Text Content --> <!-- Text Content -->
<div class="mb-8"> <div class="mb-8">
<h1 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">Page Not Found</h1> <h1 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">
{{ t('errors.pageNotFound') }}
</h1>
<p class="text-gray-500 dark:text-dark-400"> <p class="text-gray-500 dark:text-dark-400">
The page you are looking for doesn't exist or has been moved. The page you are looking for doesn't exist or has been moved.
</p> </p>
@@ -100,8 +102,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter() const router = useRouter()
function goBack(): void { function goBack(): void {

View File

@@ -1,9 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadAccounts" @click="loadAccounts"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
@@ -23,7 +23,7 @@
/> />
</svg> </svg>
</button> </button>
<button @click="showCrsSyncModal = true" class="btn btn-secondary" title="从 CRS 同步"> <button @click="showCrsSyncModal = true" class="btn btn-secondary" :title="t('admin.accounts.syncFromCrs')">
<svg <svg
class="h-5 w-5" class="h-5 w-5"
fill="none" fill="none"
@@ -50,11 +50,12 @@
</svg> </svg>
{{ t('admin.accounts.createAccount') }} {{ t('admin.accounts.createAccount') }}
</button> </button>
</div> </div>
</template>
<!-- Search and Filters --> <template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <div class="relative max-w-md flex-1">
<svg <svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none" fill="none"
@@ -75,8 +76,8 @@
class="input pl-10" class="input pl-10"
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.platform" v-model="filters.platform"
:options="platformOptions" :options="platformOptions"
@@ -98,10 +99,12 @@
class="w-36" class="w-36"
@change="loadAccounts" @change="loadAccounts"
/> />
</div>
</div> </div>
</div> </template>
<!-- Bulk Actions Bar --> <template #table>
<!-- Bulk Actions Bar -->
<div <div
v-if="selectedAccountIds.length > 0" v-if="selectedAccountIds.length > 0"
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20" class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
@@ -162,9 +165,7 @@
</div> </div>
</div> </div>
<!-- Accounts Table --> <DataTable :columns="columns" :data="accounts" :loading="loading">
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="accounts" :loading="loading">
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input <input
type="checkbox" type="checkbox"
@@ -274,130 +275,9 @@
</span> </span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row, expanded }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Reset Status button for error accounts --> <!-- 主要操作编辑和删除始终显示 -->
<button
v-if="row.status === 'error'"
@click="handleResetStatus(row)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('admin.accounts.resetStatus')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
</button>
<!-- Clear Rate Limit button -->
<button
v-if="isRateLimited(row) || isOverloaded(row)"
@click="handleClearRateLimit(row)"
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
:title="t('admin.accounts.clearRateLimit')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Test Connection button -->
<button
@click="handleTest(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
:title="t('admin.accounts.testConnection')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
</button>
<!-- View Stats button -->
<button
@click="handleViewStats(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
:title="t('admin.accounts.viewStats')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleReAuth(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.accounts.reAuthorize')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleRefreshToken(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
:title="t('admin.accounts.refreshToken')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
<button <button
@click="handleEdit(row)" @click="handleEdit(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
@@ -436,6 +316,132 @@
/> />
</svg> </svg>
</button> </button>
<!-- 次要操作展开时显示 -->
<template v-if="expanded">
<!-- Reset Status button for error accounts -->
<button
v-if="row.status === 'error'"
@click="handleResetStatus(row)"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('admin.accounts.resetStatus')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
</button>
<!-- Clear Rate Limit button -->
<button
v-if="isRateLimited(row) || isOverloaded(row)"
@click="handleClearRateLimit(row)"
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
:title="t('admin.accounts.clearRateLimit')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Test Connection button -->
<button
@click="handleTest(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
:title="t('admin.accounts.testConnection')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
</button>
<!-- View Stats button -->
<button
@click="handleViewStats(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
:title="t('admin.accounts.viewStats')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleReAuth(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.accounts.reAuthorize')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
</button>
<button
v-if="row.type === 'oauth' || row.type === 'setup-token'"
@click="handleRefreshToken(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
:title="t('admin.accounts.refreshToken')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</template>
</div> </div>
</template> </template>
@@ -448,17 +454,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create Account Modal --> <!-- Create Account Modal -->
<CreateAccountModal <CreateAccountModal
@@ -541,6 +548,7 @@ import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'

View File

@@ -1,69 +1,70 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadGroups" @click="loadGroups"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
:title="t('common.refresh')" :title="t('common.refresh')"
>
<svg
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
> >
<path <svg
stroke-linecap="round" :class="['h-5 w-5', loading ? 'animate-spin' : '']"
stroke-linejoin="round" fill="none"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" viewBox="0 0 24 24"
/> stroke="currentColor"
</svg> stroke-width="1.5"
</button> >
<button @click="showCreateModal = true" class="btn btn-primary"> <path
<svg stroke-linecap="round"
class="mr-2 h-5 w-5" stroke-linejoin="round"
fill="none" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
viewBox="0 0 24 24" />
stroke="currentColor" </svg>
stroke-width="1.5" </button>
> <button @click="showCreateModal = true" class="btn btn-primary">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <svg
</svg> class="mr-2 h-5 w-5"
{{ t('admin.groups.createGroup') }} fill="none"
</button> viewBox="0 0 24 24"
</div> stroke="currentColor"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ t('admin.groups.createGroup') }}
</button>
</div>
</template>
<!-- Filters --> <template #filters>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.platform" v-model="filters.platform"
:options="platformFilterOptions" :options="platformFilterOptions"
placeholder="All Platforms" :placeholder="t('admin.groups.allPlatforms')"
class="w-44" class="w-44"
@change="loadGroups" @change="loadGroups"
/> />
<Select <Select
v-model="filters.status" v-model="filters.status"
:options="statusOptions" :options="statusOptions"
placeholder="All Status" :placeholder="t('admin.groups.allStatus')"
class="w-40" class="w-40"
@change="loadGroups" @change="loadGroups"
/> />
<Select <Select
v-model="filters.is_exclusive" v-model="filters.is_exclusive"
:options="exclusiveOptions" :options="exclusiveOptions"
placeholder="All Groups" :placeholder="t('admin.groups.allGroups')"
class="w-44" class="w-44"
@change="loadGroups" @change="loadGroups"
/> />
</div> </div>
</template>
<!-- Groups Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="groups" :loading="loading"> <DataTable :columns="columns" :data="groups" :loading="loading">
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
@@ -213,17 +214,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create Group Modal --> <!-- Create Group Modal -->
<Modal <Modal
@@ -541,6 +543,7 @@ import { adminAPI } from '@/api/admin'
import type { Group, GroupPlatform, SubscriptionType } from '@/types' import type { Group, GroupPlatform, SubscriptionType } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'

View File

@@ -1,9 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadProxies" @click="loadProxies"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
@@ -35,11 +35,12 @@
</svg> </svg>
{{ t('admin.proxies.createProxy') }} {{ t('admin.proxies.createProxy') }}
</button> </button>
</div> </div>
</template>
<!-- Search and Filters --> <template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <div class="relative max-w-md flex-1">
<svg <svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none" fill="none"
@@ -60,8 +61,8 @@
class="input pl-10" class="input pl-10"
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.protocol" v-model="filters.protocol"
:options="protocolOptions" :options="protocolOptions"
@@ -76,11 +77,11 @@
class="w-36" class="w-36"
@change="loadProxies" @change="loadProxies"
/> />
</div>
</div> </div>
</div> </template>
<!-- Proxies Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="proxies" :loading="loading"> <DataTable :columns="columns" :data="proxies" :loading="loading">
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
@@ -199,17 +200,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create Proxy Modal --> <!-- Create Proxy Modal -->
<Modal <Modal
@@ -291,7 +293,7 @@
v-model="createForm.host" v-model="createForm.host"
type="text" type="text"
required required
placeholder="proxy.example.com" :placeholder="t('admin.proxies.form.hostPlaceholder')"
class="input" class="input"
/> />
</div> </div>
@@ -303,7 +305,7 @@
required required
min="1" min="1"
max="65535" max="65535"
placeholder="8080" :placeholder="t('admin.proxies.form.portPlaceholder')"
class="input" class="input"
/> />
</div> </div>
@@ -577,6 +579,7 @@ import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types' import type { Proxy, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'

View File

@@ -1,9 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadCodes" @click="loadCodes"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
@@ -26,11 +26,12 @@
<button @click="showGenerateDialog = true" class="btn btn-primary"> <button @click="showGenerateDialog = true" class="btn btn-primary">
{{ t('admin.redeem.generateCodes') }} {{ t('admin.redeem.generateCodes') }}
</button> </button>
</div> </div>
</template>
<!-- Filters and Actions --> <template #filters>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-md flex-1"> <div class="max-w-md flex-1">
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
@@ -38,8 +39,8 @@
class="input" class="input"
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Select <Select
v-model="filters.type" v-model="filters.type"
:options="filterTypeOptions" :options="filterTypeOptions"
@@ -55,11 +56,11 @@
<button @click="handleExportCodes" class="btn btn-secondary"> <button @click="handleExportCodes" class="btn btn-secondary">
{{ t('admin.redeem.exportCsv') }} {{ t('admin.redeem.exportCsv') }}
</button> </button>
</div>
</div> </div>
</div> </template>
<!-- Redeem Codes Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable :columns="columns" :data="codes" :loading="loading">
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -151,7 +152,7 @@
<template #cell-used_at="{ value }"> <template #cell-used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ <span class="text-sm text-gray-500 dark:text-dark-400">{{
value ? formatDate(value) : '-' value ? formatDateTime(value) : '-'
}}</span> }}</span>
</template> </template>
@@ -176,24 +177,25 @@
</div> </div>
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
<!-- Batch Actions --> <!-- Batch Actions -->
<div v-if="filters.status === 'unused'" class="flex justify-end"> <div v-if="filters.status === 'unused'" class="flex justify-end">
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger"> <button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
{{ t('admin.redeem.deleteAllUnused') }} {{ t('admin.redeem.deleteAllUnused') }}
</button> </button>
</div> </div>
</div> </template>
</TablePageLayout>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -417,9 +419,11 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { RedeemCode, RedeemCodeType, Group } from '@/types' import type { RedeemCode, RedeemCodeType, Group } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
@@ -549,10 +553,6 @@ const generateForm = reactive({
validity_days: 30 validity_days: 30
}) })
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString()
}
const loadCodes = async () => { const loadCodes = async () => {
loading.value = true loading.value = true
try { try {

View File

@@ -326,7 +326,12 @@
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.site.siteName') }} {{ t('admin.settings.site.siteName') }}
</label> </label>
<input v-model="form.site_name" type="text" class="input" placeholder="Sub2API" /> <input
v-model="form.site_name"
type="text"
class="input"
:placeholder="t('admin.settings.site.siteNamePlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.siteNameHint') }} {{ t('admin.settings.site.siteNameHint') }}
</p> </p>
@@ -339,7 +344,7 @@
v-model="form.site_subtitle" v-model="form.site_subtitle"
type="text" type="text"
class="input" class="input"
placeholder="Subscription to API Conversion Platform" :placeholder="t('admin.settings.site.siteSubtitlePlaceholder')"
/> />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.siteSubtitleHint') }} {{ t('admin.settings.site.siteSubtitleHint') }}
@@ -356,7 +361,7 @@
v-model="form.api_base_url" v-model="form.api_base_url"
type="text" type="text"
class="input font-mono text-sm" class="input font-mono text-sm"
placeholder="https://api.example.com" :placeholder="t('admin.settings.site.apiBaseUrlPlaceholder')"
/> />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.apiBaseUrlHint') }} {{ t('admin.settings.site.apiBaseUrlHint') }}
@@ -388,7 +393,7 @@
v-model="form.doc_url" v-model="form.doc_url"
type="url" type="url"
class="input font-mono text-sm" class="input font-mono text-sm"
placeholder="https://docs.example.com" :placeholder="t('admin.settings.site.docUrlPlaceholder')"
/> />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.site.docUrlHint') }} {{ t('admin.settings.site.docUrlHint') }}
@@ -537,7 +542,7 @@
v-model="form.smtp_host" v-model="form.smtp_host"
type="text" type="text"
class="input" class="input"
placeholder="smtp.gmail.com" :placeholder="t('admin.settings.smtp.hostPlaceholder')"
/> />
</div> </div>
<div> <div>
@@ -550,7 +555,7 @@
min="1" min="1"
max="65535" max="65535"
class="input" class="input"
placeholder="587" :placeholder="t('admin.settings.smtp.portPlaceholder')"
/> />
</div> </div>
<div> <div>
@@ -561,7 +566,7 @@
v-model="form.smtp_username" v-model="form.smtp_username"
type="text" type="text"
class="input" class="input"
placeholder="your-email@gmail.com" :placeholder="t('admin.settings.smtp.usernamePlaceholder')"
/> />
</div> </div>
<div> <div>
@@ -572,7 +577,7 @@
v-model="form.smtp_password" v-model="form.smtp_password"
type="password" type="password"
class="input" class="input"
placeholder="********" :placeholder="t('admin.settings.smtp.passwordPlaceholder')"
/> />
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.smtp.passwordHint') }} {{ t('admin.settings.smtp.passwordHint') }}
@@ -586,7 +591,7 @@
v-model="form.smtp_from_email" v-model="form.smtp_from_email"
type="email" type="email"
class="input" class="input"
placeholder="noreply@example.com" :placeholder="t('admin.settings.smtp.fromEmailPlaceholder')"
/> />
</div> </div>
<div> <div>
@@ -597,7 +602,7 @@
v-model="form.smtp_from_name" v-model="form.smtp_from_name"
type="text" type="text"
class="input" class="input"
placeholder="Sub2API" :placeholder="t('admin.settings.smtp.fromNamePlaceholder')"
/> />
</div> </div>
</div> </div>
@@ -639,7 +644,7 @@
v-model="testEmailAddress" v-model="testEmailAddress"
type="email" type="email"
class="input" class="input"
placeholder="test@example.com" :placeholder="t('admin.settings.testEmail.recipientEmailPlaceholder')"
/> />
</div> </div>
<button <button

View File

@@ -1,7 +1,8 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <!-- Page Header Actions -->
<template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadSubscriptions" @click="loadSubscriptions"
@@ -36,8 +37,10 @@
{{ t('admin.subscriptions.assignSubscription') }} {{ t('admin.subscriptions.assignSubscription') }}
</button> </button>
</div> </div>
</template>
<!-- Filters --> <!-- Filters -->
<template #filters>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.status" v-model="filters.status"
@@ -54,9 +57,10 @@
@change="loadSubscriptions" @change="loadSubscriptions"
/> />
</div> </div>
</template>
<!-- Subscriptions Table --> <!-- Subscriptions Table -->
<div class="card overflow-hidden"> <template #table>
<DataTable :columns="columns" :data="subscriptions" :loading="loading"> <DataTable :columns="columns" :data="subscriptions" :loading="loading">
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -222,7 +226,7 @@
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
" "
> >
{{ formatDate(value) }} {{ formatDateOnly(value) }}
</span> </span>
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500"> <div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }} {{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
@@ -302,9 +306,10 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <!-- Pagination -->
<template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
@@ -312,7 +317,8 @@
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Assign Subscription Modal --> <!-- Assign Subscription Modal -->
<Modal <Modal
@@ -401,7 +407,7 @@
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ {{
extendingSubscription.expires_at extendingSubscription.expires_at
? formatDate(extendingSubscription.expires_at) ? formatDateOnly(extendingSubscription.expires_at)
: t('admin.subscriptions.noExpiration') : t('admin.subscriptions.noExpiration')
}} }}
</span> </span>
@@ -444,7 +450,9 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { UserSubscription, Group, User } from '@/types' import type { UserSubscription, Group, User } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { formatDateOnly } from '@/utils/format'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
@@ -640,14 +648,6 @@ const confirmRevoke = async () => {
} }
// Helper functions // Helper functions
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getDaysRemaining = (expiresAt: string): number | null => { const getDaysRemaining = (expiresAt: string): number | null => {
const now = new Date() const now = new Date()
const expires = new Date(expiresAt) const expires = new Date(expiresAt)

View File

@@ -1,10 +1,10 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <div class="space-y-6">
<!-- Summary Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests --> <!-- Total Requests -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg <svg
@@ -130,10 +130,10 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Charts Section --> <!-- Charts Section -->
<div class="space-y-4"> <div class="space-y-4">
<!-- Chart Controls --> <!-- Chart Controls -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -157,9 +157,9 @@
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters Section -->
<div class="card"> <div class="card">
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<!-- User Search --> <!-- User Search -->
<div class="min-w-[200px]"> <div class="min-w-[200px]">
@@ -229,6 +229,61 @@
/> />
</div> </div>
<!-- Model Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.model') }}</label>
<Select
v-model="filters.model"
:options="modelOptions"
:placeholder="t('admin.usage.allModels')"
@change="applyFilters"
/>
</div>
<!-- Account Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('admin.usage.account') }}</label>
<Select
v-model="filters.account_id"
:options="accountOptions"
:placeholder="t('admin.usage.allAccounts')"
@change="applyFilters"
/>
</div>
<!-- Stream Type Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select
v-model="filters.stream"
:options="streamOptions"
:placeholder="t('admin.usage.allTypes')"
@change="applyFilters"
/>
</div>
<!-- Billing Type Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('usage.billingType') }}</label>
<Select
v-model="filters.billing_type"
:options="billingTypeOptions"
:placeholder="t('admin.usage.allBillingTypes')"
@change="applyFilters"
/>
</div>
<!-- Group Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
<Select
v-model="filters.group_id"
:options="groupOptions"
:placeholder="t('admin.usage.allGroups')"
@change="applyFilters"
/>
</div>
<!-- Date Range Filter --> <!-- Date Range Filter -->
<div> <div>
<label class="input-label">{{ t('usage.timeRange') }}</label> <label class="input-label">{{ t('usage.timeRange') }}</label>
@@ -252,9 +307,10 @@
</div> </div>
</div> </div>
<!-- Usage Table --> <!-- Table Section -->
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <div class="overflow-auto">
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ <span class="font-medium text-gray-900 dark:text-white">{{
@@ -270,10 +326,26 @@
}}</span> }}</span>
</template> </template>
<template #cell-account="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{
row.account?.name || '-'
}}</span>
</template>
<template #cell-model="{ value }"> <template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
<template #cell-group="{ row }">
<span
v-if="row.group"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{ row.group.name }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-stream="{ row }"> <template #cell-stream="{ row }">
<span <span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
@@ -407,6 +479,27 @@
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800" class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
> >
<div class="space-y-1.5"> <div class="space-y-1.5">
<!-- Cost Breakdown -->
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">成本明细</div>
<div v-if="row.input_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ row.input_cost.toFixed(6) }}</span>
</div>
<div v-if="row.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ row.output_cost.toFixed(6) }}</span>
</div>
<div v-if="row.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
<span class="font-medium text-white">${{ row.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="row.cache_read_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
<span class="font-medium text-white">${{ row.cache_read_cost.toFixed(6) }}</span>
</div>
</div>
<!-- Rate and Summary -->
<div class="flex items-center justify-between gap-6"> <div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span> <span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400" <span class="font-semibold text-blue-400"
@@ -471,10 +564,17 @@
}}</span> }}</span>
</template> </template>
<template #cell-request_id="{ row }">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
row.request_id || '-'
}}</span>
</template>
<template #empty> <template #empty>
<EmptyState :message="t('usage.noRecords')" /> <EmptyState :message="t('usage.noRecords')" />
</template> </template>
</DataTable> </DataTable>
</div>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@@ -498,6 +598,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import { formatDateTime } from '@/utils/format'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue' import DateRangePicker from '@/components/common/DateRangePicker.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
@@ -532,17 +633,23 @@ const granularityOptions = computed(() => [
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false }, { key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false }, { key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false }, { key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false }, { key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false }, { key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true } { key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
]) ])
const usageLogs = ref<UsageLog[]>([]) const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<SimpleApiKey[]>([]) const apiKeys = ref<SimpleApiKey[]>([])
const models = ref<string[]>([])
const accounts = ref<any[]>([])
const groups = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
// User search state // User search state
@@ -564,6 +671,53 @@ const apiKeyOptions = computed(() => {
] ]
}) })
// Model options
const modelOptions = computed(() => {
return [
{ value: null, label: t('admin.usage.allModels') },
...models.value.map((model) => ({
value: model,
label: model
}))
]
})
// Account options
const accountOptions = computed(() => {
return [
{ value: null, label: t('admin.usage.allAccounts') },
...accounts.value.map((account) => ({
value: account.id,
label: account.name
}))
]
})
// Stream type options
const streamOptions = computed(() => [
{ value: null, label: t('admin.usage.allTypes') },
{ value: true, label: t('usage.stream') },
{ value: false, label: t('usage.sync') }
])
// Billing type options
const billingTypeOptions = computed(() => [
{ value: null, label: t('admin.usage.allBillingTypes') },
{ value: 0, label: t('usage.balance') },
{ value: 1, label: t('usage.subscription') }
])
// Group options
const groupOptions = computed(() => {
return [
{ value: null, label: t('admin.usage.allGroups') },
...groups.value.map((group) => ({
value: group.id,
label: group.name
}))
]
})
// Date range state // Date range state
const startDate = ref('') const startDate = ref('')
const endDate = ref('') const endDate = ref('')
@@ -571,6 +725,11 @@ const endDate = ref('')
const filters = ref<AdminUsageQueryParams>({ const filters = ref<AdminUsageQueryParams>({
user_id: undefined, user_id: undefined,
api_key_id: undefined, api_key_id: undefined,
account_id: undefined,
group_id: undefined,
model: undefined,
stream: undefined,
billing_type: undefined,
start_date: undefined, start_date: undefined,
end_date: undefined end_date: undefined
}) })
@@ -689,17 +848,6 @@ const formatCacheTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
const formatDateTime = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
loading.value = true loading.value = true
try { try {
@@ -713,6 +861,9 @@ const loadUsageLogs = async () => {
usageLogs.value = response.items usageLogs.value = response.items
pagination.value.total = response.total pagination.value.total = response.total
pagination.value.pages = response.pages pagination.value.pages = response.pages
// Extract models from loaded logs for filter options
extractModelsFromLogs()
} catch (error) { } catch (error) {
appStore.showError(t('usage.failedToLoad')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
@@ -775,6 +926,32 @@ const applyFilters = () => {
loadChartData() loadChartData()
} }
// Load filter options
const loadFilterOptions = async () => {
try {
// Load accounts
const accountsResponse = await adminAPI.accounts.list(1, 1000)
accounts.value = accountsResponse.items || []
// Load groups
const groupsResponse = await adminAPI.groups.list(1, 1000)
groups.value = groupsResponse.items || []
} catch (error) {
console.error('Failed to load filter options:', error)
}
}
// Extract unique models from usage logs
const extractModelsFromLogs = () => {
const uniqueModels = new Set<string>()
usageLogs.value.forEach(log => {
if (log.model) {
uniqueModels.add(log.model)
}
})
models.value = Array.from(uniqueModels).sort()
}
const resetFilters = () => { const resetFilters = () => {
selectedUser.value = null selectedUser.value = null
userSearchKeyword.value = '' userSearchKeyword.value = ''
@@ -783,6 +960,11 @@ const resetFilters = () => {
filters.value = { filters.value = {
user_id: undefined, user_id: undefined,
api_key_id: undefined, api_key_id: undefined,
account_id: undefined,
group_id: undefined,
model: undefined,
stream: undefined,
billing_type: undefined,
start_date: undefined, start_date: undefined,
end_date: undefined end_date: undefined
} }
@@ -858,6 +1040,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadFilterOptions()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()

View File

@@ -1,8 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <!-- Page Header Actions -->
<div class="flex justify-end gap-3"> <template #actions>
<div class="flex justify-end gap-3">
<button <button
@click="loadUsers" @click="loadUsers"
:disabled="loading" :disabled="loading"
@@ -36,8 +37,10 @@
{{ t('admin.users.createUser') }} {{ t('admin.users.createUser') }}
</button> </button>
</div> </div>
</template>
<!-- Search and Filters --> <!-- Search and Filters -->
<template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <div class="relative max-w-md flex-1">
<svg <svg
@@ -78,9 +81,10 @@
/> />
</div> </div>
</div> </div>
</template>
<!-- Users Table --> <!-- Users Table -->
<div class="card overflow-hidden"> <template #table>
<DataTable :columns="columns" :data="users" :loading="loading"> <DataTable :columns="columns" :data="users" :loading="loading">
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -135,7 +139,7 @@
:subscription-type="sub.group?.subscription_type" :subscription-type="sub.group?.subscription_type"
:rate-multiplier="sub.group?.rate_multiplier" :rate-multiplier="sub.group?.rate_multiplier"
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null" :days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''" :title="sub.expires_at ? formatDateTime(sub.expires_at) : ''"
/> />
</div> </div>
<span <span
@@ -191,27 +195,70 @@
</template> </template>
<template #cell-created_at="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row, expanded }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Toggle Status (hidden for admin users) --> <!-- 主要操作编辑和删除始终显示 -->
<button
@click="handleEdit(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('common.edit')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
<button <button
v-if="row.role !== 'admin'" v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)" @click="handleDelete(row)"
:class="[ class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
'rounded-lg p-2 transition-colors', :title="t('common.delete')"
row.status === 'active'
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
:title="
row.status === 'active'
? t('admin.users.disableUser')
: t('admin.users.enableUser')
"
> >
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
<!-- 次要操作展开时显示 -->
<template v-if="expanded">
<!-- Toggle Status (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)"
:class="[
'rounded-lg p-2 transition-colors',
row.status === 'active'
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
:title="
row.status === 'active'
? t('admin.users.disableUser')
: t('admin.users.enableUser')
"
>
<svg <svg
v-if="row.status === 'active'" v-if="row.status === 'active'"
class="h-4 w-4" class="h-4 w-4"
@@ -240,120 +287,80 @@
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
</button> </button>
<!-- Allowed Groups --> <!-- Allowed Groups -->
<button <button
@click="handleAllowedGroups(row)" @click="handleAllowedGroups(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
:title="t('admin.users.setAllowedGroups')" :title="t('admin.users.setAllowedGroups')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
> >
<path <svg
stroke-linecap="round" class="h-4 w-4"
stroke-linejoin="round" fill="none"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> stroke-width="1.5"
</button> >
<!-- View API Keys --> <path
<button stroke-linecap="round"
@click="handleViewApiKeys(row)" stroke-linejoin="round"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
:title="t('admin.users.viewApiKeys')" />
> </svg>
<svg </button>
class="h-4 w-4" <!-- View API Keys -->
fill="none" <button
stroke="currentColor" @click="handleViewApiKeys(row)"
viewBox="0 0 24 24" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
stroke-width="1.5" :title="t('admin.users.viewApiKeys')"
> >
<path <svg
stroke-linecap="round" class="h-4 w-4"
stroke-linejoin="round" fill="none"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> stroke-width="1.5"
</button> >
<!-- Deposit --> <path
<button stroke-linecap="round"
@click="handleDeposit(row)" stroke-linejoin="round"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1221.75 8.25z"
:title="t('admin.users.deposit')" />
> </svg>
<svg </button>
class="h-4 w-4" <!-- Deposit -->
fill="none" <button
stroke="currentColor" @click="handleDeposit(row)"
viewBox="0 0 24 24" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
stroke-width="1.5" :title="t('admin.users.deposit')"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <svg
</svg> class="h-4 w-4"
</button> fill="none"
<!-- Withdraw --> stroke="currentColor"
<button viewBox="0 0 24 24"
@click="handleWithdraw(row)" stroke-width="1.5"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" >
:title="t('admin.users.withdraw')" <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
> </svg>
<svg </button>
class="h-4 w-4" <!-- Withdraw -->
fill="none" <button
stroke="currentColor" @click="handleWithdraw(row)"
viewBox="0 0 24 24" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
stroke-width="1.5" :title="t('admin.users.withdraw')"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /> <svg
</svg> class="h-4 w-4"
</button> fill="none"
<!-- Edit --> stroke="currentColor"
<button viewBox="0 0 24 24"
@click="handleEdit(row)" stroke-width="1.5"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" >
:title="t('common.edit')" <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
> </svg>
<svg </button>
class="h-4 w-4" </template>
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
<!-- Delete (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@click="handleDelete(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
:title="t('common.delete')"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div> </div>
</template> </template>
@@ -366,9 +373,10 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <!-- Pagination -->
<template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
@@ -376,7 +384,8 @@
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create User Modal --> <!-- Create User Modal -->
<Modal <Modal
@@ -808,7 +817,7 @@
/> />
</svg> </svg>
<span <span
>{{ t('admin.users.columns.created') }}: {{ formatDate(key.created_at) }}</span >{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span
> >
</div> </div>
</div> </div>
@@ -1164,6 +1173,7 @@
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@@ -1171,6 +1181,7 @@ import type { User, ApiKey, Group } from '@/types'
import type { BatchUserUsageStats } from '@/api/admin/dashboard' import type { BatchUserUsageStats } from '@/api/admin/dashboard'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
@@ -1274,15 +1285,6 @@ const editForm = reactive({
}) })
const editPasswordCopied = ref(false) const editPasswordCopied = ref(false)
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
// 计算剩余天数 // 计算剩余天数
const getDaysRemaining = (expiresAt: string): number => { const getDaysRemaining = (expiresAt: string): number => {
const now = new Date() const now = new Date()
@@ -1291,12 +1293,6 @@ const getDaysRemaining = (expiresAt: string): number => {
return Math.ceil(diffMs / (1000 * 60 * 60 * 24)) return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
} }
// 格式化过期时间(用于 tooltip
const formatExpiresAt = (expiresAt: string): string => {
const date = new Date(expiresAt)
return date.toLocaleString()
}
const generateRandomPasswordStr = () => { const generateRandomPasswordStr = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*' const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let password = '' let password = ''

View File

@@ -3,7 +3,9 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Title --> <!-- Title -->
<div class="text-center"> <div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.verifyYourEmail') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400"> <p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
We'll send a verification code to We'll send a verification code to
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
@@ -32,8 +34,8 @@
</svg> </svg>
</div> </div>
<div class="text-sm text-amber-700 dark:text-amber-400"> <div class="text-sm text-amber-700 dark:text-amber-400">
<p class="font-medium">Session expired</p> <p class="font-medium">{{ t('auth.sessionExpired') }}</p>
<p class="mt-1">Please go back to the registration page and start again.</p> <p class="mt-1">{{ t('auth.sessionExpiredDesc') }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -42,7 +44,9 @@
<form v-else @submit.prevent="handleVerify" class="space-y-5"> <form v-else @submit.prevent="handleVerify" class="space-y-5">
<!-- Verification Code Input --> <!-- Verification Code Input -->
<div> <div>
<label for="code" class="input-label text-center"> Verification Code </label> <label for="code" class="input-label text-center">
{{ t('auth.verificationCode') }}
</label>
<input <input
id="code" id="code"
v-model="verifyCode" v-model="verifyCode"
@@ -59,7 +63,7 @@
<p v-if="errors.code" class="input-error-text text-center"> <p v-if="errors.code" class="input-error-text text-center">
{{ errors.code }} {{ errors.code }}
</p> </p>
<p v-else class="input-hint text-center">Enter the 6-digit code sent to your email</p> <p v-else class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
</div> </div>
<!-- Code Status --> <!-- Code Status -->
@@ -190,9 +194,11 @@
" "
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300" class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
> >
<span v-if="isSendingCode">Sending...</span> <span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span> <span v-else-if="turnstileEnabled && !showResendTurnstile">
<span v-else>Resend verification code</span> {{ t('auth.clickToResend') }}
</span>
<span v-else>{{ t('auth.resendCode') }}</span>
</button> </button>
</div> </div>
</form> </form>
@@ -226,11 +232,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout' import { AuthLayout } from '@/components/layout'
import TurnstileWidget from '@/components/TurnstileWidget.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth' import { getPublicSettings, sendVerifyCode } from '@/api/auth'
const { t } = useI18n()
// ==================== Router & Stores ==================== // ==================== Router & Stores ====================
const router = useRouter() const router = useRouter()

View File

@@ -10,7 +10,7 @@
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
<div> <div>
<label class="input-label">Code</label> <label class="input-label">{{ t('auth.oauth.code') }}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input class="input flex-1 font-mono text-sm" :value="code" readonly /> <input class="input flex-1 font-mono text-sm" :value="code" readonly />
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)"> <button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
@@ -20,7 +20,7 @@
</div> </div>
<div> <div>
<label class="input-label">State</label> <label class="input-label">{{ t('auth.oauth.state') }}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input class="input flex-1 font-mono text-sm" :value="state" readonly /> <input class="input flex-1 font-mono text-sm" :value="state" readonly />
<button <button
@@ -35,7 +35,7 @@
</div> </div>
<div> <div>
<label class="input-label">Full URL</label> <label class="input-label">{{ t('auth.oauth.fullUrl') }}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly /> <input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly />
<button <button
@@ -63,10 +63,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
const route = useRoute() const route = useRoute()
const { t } = useI18n()
const { copyToClipboard } = useClipboard() const { copyToClipboard } = useClipboard()
const code = computed(() => (route.query.code as string) || '') const code = computed(() => (route.query.code as string) || '')

View File

@@ -27,8 +27,8 @@
/> />
</svg> </svg>
</div> </div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sub2API Setup</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ t('setup.title') }}</h1>
<p class="mt-2 text-gray-500 dark:text-dark-400">Configure your Sub2API instance</p> <p class="mt-2 text-gray-500 dark:text-dark-400">{{ t('setup.description') }}</p>
</div> </div>
<!-- Progress Steps --> <!-- Progress Steps -->
@@ -84,7 +84,7 @@
<div v-if="currentStep === 0" class="space-y-6"> <div v-if="currentStep === 0" class="space-y-6">
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white"> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
Database Configuration {{ t('setup.database.title') }}
</h2> </h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> <p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Connect to your PostgreSQL database Connect to your PostgreSQL database
@@ -93,7 +93,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">Host</label> <label class="input-label">{{ t('setup.database.host') }}</label>
<input <input
v-model="formData.database.host" v-model="formData.database.host"
type="text" type="text"
@@ -102,7 +102,7 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">Port</label> <label class="input-label">{{ t('setup.database.port') }}</label>
<input <input
v-model.number="formData.database.port" v-model.number="formData.database.port"
type="number" type="number"
@@ -114,7 +114,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">Username</label> <label class="input-label">{{ t('setup.database.username') }}</label>
<input <input
v-model="formData.database.user" v-model="formData.database.user"
type="text" type="text"
@@ -123,7 +123,7 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">Password</label> <label class="input-label">{{ t('setup.database.password') }}</label>
<input <input
v-model="formData.database.password" v-model="formData.database.password"
type="password" type="password"
@@ -135,7 +135,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">Database Name</label> <label class="input-label">{{ t('setup.database.databaseName') }}</label>
<input <input
v-model="formData.database.dbname" v-model="formData.database.dbname"
type="text" type="text"
@@ -144,12 +144,12 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">SSL Mode</label> <label class="input-label">{{ t('setup.database.sslMode') }}</label>
<select v-model="formData.database.sslmode" class="input"> <select v-model="formData.database.sslmode" class="input">
<option value="disable">Disable</option> <option value="disable">{{ t('setup.database.ssl.disable') }}</option>
<option value="require">Require</option> <option value="require">{{ t('setup.database.ssl.require') }}</option>
<option value="verify-ca">Verify CA</option> <option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
<option value="verify-full">Verify Full</option> <option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -198,7 +198,9 @@
<!-- Step 2: Redis --> <!-- Step 2: Redis -->
<div v-if="currentStep === 1" class="space-y-6"> <div v-if="currentStep === 1" class="space-y-6">
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Redis Configuration</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('setup.redis.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> <p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Connect to your Redis server Connect to your Redis server
</p> </p>
@@ -206,7 +208,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">Host</label> <label class="input-label">{{ t('setup.redis.host') }}</label>
<input <input
v-model="formData.redis.host" v-model="formData.redis.host"
type="text" type="text"
@@ -215,7 +217,7 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">Port</label> <label class="input-label">{{ t('setup.redis.port') }}</label>
<input <input
v-model.number="formData.redis.port" v-model.number="formData.redis.port"
type="number" type="number"
@@ -227,7 +229,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="input-label">Password (optional)</label> <label class="input-label">{{ t('setup.redis.password') }}</label>
<input <input
v-model="formData.redis.password" v-model="formData.redis.password"
type="password" type="password"
@@ -236,7 +238,7 @@
/> />
</div> </div>
<div> <div>
<label class="input-label">Database</label> <label class="input-label">{{ t('setup.redis.database') }}</label>
<input <input
v-model.number="formData.redis.db" v-model.number="formData.redis.db"
type="number" type="number"
@@ -294,14 +296,16 @@
<!-- Step 3: Admin --> <!-- Step 3: Admin -->
<div v-if="currentStep === 2" class="space-y-6"> <div v-if="currentStep === 2" class="space-y-6">
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Admin Account</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('setup.admin.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> <p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Create your administrator account Create your administrator account
</p> </p>
</div> </div>
<div> <div>
<label class="input-label">Email</label> <label class="input-label">{{ t('setup.admin.email') }}</label>
<input <input
v-model="formData.admin.email" v-model="formData.admin.email"
type="email" type="email"
@@ -311,7 +315,7 @@
</div> </div>
<div> <div>
<label class="input-label">Password</label> <label class="input-label">{{ t('setup.admin.password') }}</label>
<input <input
v-model="formData.admin.password" v-model="formData.admin.password"
type="password" type="password"
@@ -321,7 +325,7 @@
</div> </div>
<div> <div>
<label class="input-label">Confirm Password</label> <label class="input-label">{{ t('setup.admin.confirmPassword') }}</label>
<input <input
v-model="confirmPassword" v-model="confirmPassword"
type="password" type="password"
@@ -340,7 +344,9 @@
<!-- Step 4: Complete --> <!-- Step 4: Complete -->
<div v-if="currentStep === 3" class="space-y-6"> <div v-if="currentStep === 3" class="space-y-6">
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Ready to Install</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('setup.ready.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400"> <p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
Review your configuration and complete setup Review your configuration and complete setup
</p> </p>
@@ -348,7 +354,9 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Database</h3> <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
{{ t('setup.ready.database') }}
</h3>
<p class="text-gray-900 dark:text-white"> <p class="text-gray-900 dark:text-white">
{{ formData.database.user }}@{{ formData.database.host }}:{{ {{ formData.database.user }}@{{ formData.database.host }}:{{
formData.database.port formData.database.port
@@ -357,14 +365,18 @@
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Redis</h3> <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
{{ t('setup.ready.redis') }}
</h3>
<p class="text-gray-900 dark:text-white"> <p class="text-gray-900 dark:text-white">
{{ formData.redis.host }}:{{ formData.redis.port }} {{ formData.redis.host }}:{{ formData.redis.port }}
</p> </p>
</div> </div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> <div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Admin Email</h3> <h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
{{ t('setup.ready.adminEmail') }}
</h3>
<p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p> <p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p>
</div> </div>
</div> </div>
@@ -526,8 +538,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup' import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
const { t } = useI18n()
const steps = [ const steps = [
{ id: 'database', title: 'Database' }, { id: 'database', title: 'Database' },
{ id: 'redis', title: 'Redis' }, { id: 'redis', title: 'Redis' },

View File

@@ -452,16 +452,16 @@
{{ log.model }} {{ log.model }}
</p> </p>
<p class="text-xs text-gray-500 dark:text-dark-400"> <p class="text-xs text-gray-500 dark:text-dark-400">
{{ formatDate(log.created_at) }} {{ formatDateTime(log.created_at) }}
</p> </p>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-sm font-semibold"> <p class="text-sm font-semibold">
<span class="text-green-600 dark:text-green-400" title="实际扣除" <span class="text-green-600 dark:text-green-400" :title="t('dashboard.actual')"
>${{ formatCost(log.actual_cost) }}</span >${{ formatCost(log.actual_cost) }}</span
> >
<span class="font-normal text-gray-400 dark:text-gray-500" title="标准计费"> <span class="font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')">
/ ${{ formatCost(log.total_cost) }}</span / ${{ formatCost(log.total_cost) }}</span
> >
</p> </p>
@@ -649,6 +649,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
import { usageAPI, type UserDashboardStats } from '@/api/usage' import { usageAPI, type UserDashboardStats } from '@/api/usage'
@@ -914,16 +915,6 @@ const formatDuration = (ms: number): string => {
return `${Math.round(ms)}ms` return `${Math.round(ms)}ms`
} }
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const navigateTo = (path: string) => { const navigateTo = (path: string) => {
router.push(path) router.push(path)
} }

View File

@@ -1,8 +1,8 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadApiKeys" @click="loadApiKeys"
:disabled="loading" :disabled="loading"
@@ -36,9 +36,9 @@
{{ t('keys.createKey') }} {{ t('keys.createKey') }}
</button> </button>
</div> </div>
</template>
<!-- API Keys Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="apiKeys" :loading="loading"> <DataTable :columns="columns" :data="apiKeys" :loading="loading">
<template #cell-key="{ value, row }"> <template #cell-key="{ value, row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -146,7 +146,7 @@
</template> </template>
<template #cell-created_at="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
@@ -235,7 +235,7 @@
<button <button
@click="editKey(row)" @click="editKey(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
title="Edit" :title="t('common.edit')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -255,7 +255,7 @@
<button <button
@click="confirmDelete(row)" @click="confirmDelete(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Delete" :title="t('common.delete')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -283,17 +283,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<Modal <Modal
@@ -496,6 +497,7 @@ import { useAppStore } from '@/stores/app'
const { t } = useI18n() const { t } = useI18n()
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api' import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
@@ -507,6 +509,7 @@ import GroupBadge from '@/components/common/GroupBadge.vue'
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types' import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage' import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
interface GroupOption { interface GroupOption {
value: number value: number
@@ -624,15 +627,6 @@ const copyToClipboard = async (text: string, keyId: number) => {
} }
} }
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const loadApiKeys = async () => { const loadApiKeys = async () => {
loading.value = true loading.value = true
try { try {

View File

@@ -17,7 +17,7 @@
/> />
<StatCard <StatCard
:title="t('profile.memberSince')" :title="t('profile.memberSince')"
:value="formatMemberSince(user?.created_at || '')" :value="formatDate(user?.created_at || '', 'YYYY-MM')"
:icon="CalendarIcon" :icon="CalendarIcon"
icon-variant="primary" icon-variant="primary"
/> />
@@ -267,6 +267,7 @@ import { ref, computed, h, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { formatDate } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
import { userAPI, authAPI } from '@/api' import { userAPI, authAPI } from '@/api'
@@ -358,15 +359,6 @@ const formatCurrency = (value: number): string => {
return `$${value.toFixed(2)}` return `$${value.toFixed(2)}`
} }
const formatMemberSince = (dateString: string): string => {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short'
})
}
const handleChangePassword = async () => { const handleChangePassword = async () => {
// Validate password match // Validate password match
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) { if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {

View File

@@ -377,7 +377,7 @@
{{ getHistoryItemTitle(item) }} {{ getHistoryItemTitle(item) }}
</p> </p>
<p class="text-xs text-gray-500 dark:text-dark-400"> <p class="text-xs text-gray-500 dark:text-dark-400">
{{ formatDate(item.used_at) }} {{ formatDateTime(item.used_at) }}
</p> </p>
</div> </div>
</div> </div>
@@ -447,6 +447,7 @@ import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api' import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -472,18 +473,6 @@ const history = ref<RedeemHistoryItem[]>([])
const loadingHistory = ref(false) const loadingHistory = ref(false)
const contactInfo = ref('') const contactInfo = ref('')
const formatDate = (dateString: string) => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// Helper functions for history display // Helper functions for history display
const isBalanceType = (type: string) => { const isBalanceType = (type: string) => {
return type === 'balance' || type === 'admin_balance' return type === 'balance' || type === 'admin_balance'

View File

@@ -257,6 +257,7 @@ import { useAppStore } from '@/stores/app'
import subscriptionsAPI from '@/api/subscriptions' import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types' import type { UserSubscription } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import { formatDateOnly } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
@@ -300,11 +301,7 @@ function formatExpirationDate(expiresAt: string): string {
return t('userSubscriptions.status.expired') return t('userSubscriptions.status.expired')
} }
const dateStr = expires.toLocaleDateString(undefined, { const dateStr = formatDateOnly(expires)
year: 'numeric',
month: 'short',
day: 'numeric'
})
if (days === 0) { if (days === 0) {
return `${dateStr} (Today)` return `${dateStr} (Today)`

View File

@@ -1,10 +1,10 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Summary Stats Cards --> <template #actions>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests --> <!-- Total Requests -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg <svg
@@ -131,11 +131,12 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Filters --> <template #filters>
<div class="card"> <div class="card">
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<!-- API Key Filter --> <!-- API Key Filter -->
<div class="min-w-[180px]"> <div class="min-w-[180px]">
@@ -169,11 +170,17 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Usage Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <DataTable :columns="columns" :data="usageLogs" :loading="loading">
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{
row.api_key?.name || '-'
}}</span>
</template>
<template #cell-model="{ value }"> <template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
@@ -379,17 +386,18 @@
<EmptyState :message="t('usage.noRecords')" /> <EmptyState :message="t('usage.noRecords')" />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
</AppLayout> </AppLayout>
</template> </template>
@@ -399,6 +407,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api' import { usageAPI, keysAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
@@ -406,6 +415,7 @@ import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue' import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types' import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
@@ -414,6 +424,7 @@ const appStore = useAppStore()
const usageStats = ref<UsageStatsResponse | null>(null) const usageStats = ref<UsageStatsResponse | null>(null)
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
@@ -505,17 +516,6 @@ const formatCacheTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
const formatDateTime = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
loading.value = true loading.value = true
try { try {