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:
@@ -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 != "" {
|
||||||
@@ -85,6 +126,11 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
filters := usagestats.UsageLogFilters{
|
filters := usagestats.UsageLogFilters{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ApiKeyID: apiKeyID,
|
ApiKeyID: apiKeyID,
|
||||||
|
AccountID: accountID,
|
||||||
|
GroupID: groupID,
|
||||||
|
Model: model,
|
||||||
|
Stream: stream,
|
||||||
|
BillingType: billingType,
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
EndTime: endTime,
|
EndTime: endTime,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ type UserDashboardStats struct {
|
|||||||
type UsageLogFilters struct {
|
type UsageLogFilters struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
ApiKeyID int64
|
ApiKeyID int64
|
||||||
|
AccountID int64
|
||||||
|
GroupID int64
|
||||||
|
Model string
|
||||||
|
Stream *bool
|
||||||
|
BillingType *int8
|
||||||
StartTime *time.Time
|
StartTime *time.Time
|
||||||
EndTime *time.Time
|
EndTime *time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -924,7 +924,10 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
logs := r.userLogs[filters.UserID]
|
||||||
|
total := int64(len(logs))
|
||||||
|
out := paginateLogs(logs, params)
|
||||||
|
return out, paginationResult(total, params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
|||||||
@@ -226,7 +226,9 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||||
|
t('admin.accounts.stats.tokens')
|
||||||
|
}}</span>
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||||
formatTokens(stats.summary.today?.tokens || 0)
|
formatTokens(stats.summary.today?.tokens || 0)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageSizeChange = (value: string | number | null) => {
|
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||||
if (value === null) return
|
if (value === null || typeof value === 'boolean') return
|
||||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||||
emit('update:pageSize', newPageSize)
|
emit('update:pageSize', newPageSize)
|
||||||
// Reset to first page when page size changes
|
// Reset to first page when page size changes
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<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 => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
114
frontend/src/components/layout/TablePageLayout.vue
Normal file
114
frontend/src/components/layout/TablePageLayout.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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: '请输入收件人邮箱地址'
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -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"
|
||||||
@@ -51,8 +51,9 @@
|
|||||||
{{ 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
|
||||||
@@ -100,7 +101,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #table>
|
||||||
<!-- Bulk Actions Bar -->
|
<!-- Bulk Actions Bar -->
|
||||||
<div
|
<div
|
||||||
v-if="selectedAccountIds.length > 0"
|
v-if="selectedAccountIds.length > 0"
|
||||||
@@ -162,8 +165,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accounts Table -->
|
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
<input
|
<input
|
||||||
@@ -274,8 +275,50 @@
|
|||||||
</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">
|
||||||
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
|
<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
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<!-- 次要操作:展开时显示 -->
|
||||||
|
<template v-if="expanded">
|
||||||
<!-- Reset Status button for error accounts -->
|
<!-- Reset Status button for error accounts -->
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'error'"
|
v-if="row.status === 'error'"
|
||||||
@@ -398,44 +441,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
</template>
|
||||||
@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
|
|
||||||
@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>
|
||||||
|
|
||||||
@@ -448,9 +454,9 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -458,7 +464,8 @@
|
|||||||
: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'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -36,34 +36,35 @@
|
|||||||
{{ t('admin.groups.createGroup') }}
|
{{ t('admin.groups.createGroup') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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,9 +214,9 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -223,7 +224,8 @@
|
|||||||
: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'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -36,8 +36,9 @@
|
|||||||
{{ 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
|
||||||
@@ -78,9 +79,9 @@
|
|||||||
/>
|
/>
|
||||||
</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,9 +200,9 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -209,7 +210,8 @@
|
|||||||
: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'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -27,8 +27,9 @@
|
|||||||
{{ 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
|
||||||
@@ -57,9 +58,9 @@
|
|||||||
</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,9 +177,9 @@
|
|||||||
</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"
|
||||||
@@ -193,7 +194,8 @@
|
|||||||
{{ 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
</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">
|
||||||
@@ -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,8 +307,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usage Table -->
|
<!-- Table Section -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
|
<div class="overflow-auto">
|
||||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -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,11 +564,18 @@
|
|||||||
}}</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 -->
|
||||||
<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()
|
||||||
|
|||||||
@@ -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="loadUsers"
|
@click="loadUsers"
|
||||||
@@ -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,11 +195,54 @@
|
|||||||
</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">
|
||||||
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 次要操作:展开时显示 -->
|
||||||
|
<template v-if="expanded">
|
||||||
<!-- Toggle Status (hidden for admin users) -->
|
<!-- Toggle Status (hidden for admin users) -->
|
||||||
<button
|
<button
|
||||||
v-if="row.role !== 'admin'"
|
v-if="row.role !== 'admin'"
|
||||||
@@ -277,7 +324,7 @@
|
|||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -313,47 +360,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- Edit -->
|
</template>
|
||||||
<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>
|
|
||||||
<!-- 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 = ''
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) || '')
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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"
|
||||||
@@ -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,9 +283,9 @@
|
|||||||
/>
|
/>
|
||||||
</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"
|
||||||
@@ -293,7 +293,8 @@
|
|||||||
: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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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">
|
||||||
@@ -132,8 +132,9 @@
|
|||||||
</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">
|
||||||
@@ -170,10 +171,16 @@
|
|||||||
</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,9 +386,9 @@
|
|||||||
<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"
|
||||||
@@ -389,7 +396,8 @@
|
|||||||
: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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user