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:
@@ -385,7 +385,7 @@
|
||||
>
|
||||
<span class="text-xs font-bold text-white">C</span>
|
||||
</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
|
||||
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
|
||||
@@ -415,7 +415,7 @@
|
||||
>
|
||||
<span class="text-xs font-bold text-white">G</span>
|
||||
</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
|
||||
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
|
||||
@@ -430,7 +430,7 @@
|
||||
>
|
||||
<span class="text-xs font-bold text-white">+</span>
|
||||
</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
|
||||
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
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
|
||||
<!-- Text Content -->
|
||||
<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">
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
@@ -100,8 +102,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
function goBack(): void {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadAccounts"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
@@ -23,7 +23,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</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
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
@@ -50,11 +50,12 @@
|
||||
</svg>
|
||||
{{ t('admin.accounts.createAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
@@ -75,8 +76,8 @@
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformOptions"
|
||||
@@ -98,10 +99,12 @@
|
||||
class="w-36"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<template #table>
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div
|
||||
v-if="selectedAccountIds.length > 0"
|
||||
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
@@ -162,9 +165,7 @@
|
||||
</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 }">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -274,130 +275,9 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<template #cell-actions="{ row, expanded }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Reset Status button for error accounts -->
|
||||
<button
|
||||
v-if="row.status === 'error'"
|
||||
@click="handleResetStatus(row)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.accounts.resetStatus')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@click="handleClearRateLimit(row)"
|
||||
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
||||
:title="t('admin.accounts.clearRateLimit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Test Connection button -->
|
||||
<button
|
||||
@click="handleTest(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||
:title="t('admin.accounts.testConnection')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View Stats button -->
|
||||
<button
|
||||
@click="handleViewStats(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
||||
:title="t('admin.accounts.viewStats')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.accounts.reAuthorize')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleRefreshToken(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
:title="t('admin.accounts.refreshToken')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||
<button
|
||||
@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"
|
||||
@@ -436,6 +316,132 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 次要操作:展开时显示 -->
|
||||
<template v-if="expanded">
|
||||
<!-- Reset Status button for error accounts -->
|
||||
<button
|
||||
v-if="row.status === 'error'"
|
||||
@click="handleResetStatus(row)"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('admin.accounts.resetStatus')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@click="handleClearRateLimit(row)"
|
||||
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
||||
:title="t('admin.accounts.clearRateLimit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Test Connection button -->
|
||||
<button
|
||||
@click="handleTest(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||
:title="t('admin.accounts.testConnection')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View Stats button -->
|
||||
<button
|
||||
@click="handleViewStats(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
||||
:title="t('admin.accounts.viewStats')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.accounts.reAuthorize')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleRefreshToken(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
:title="t('admin.accounts.refreshToken')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -448,17 +454,18 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Account Modal -->
|
||||
<CreateAccountModal
|
||||
@@ -541,6 +548,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
@@ -1,69 +1,70 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadGroups"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadGroups"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
placeholder="All Platforms"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
placeholder="All Status"
|
||||
class="w-40"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.is_exclusive"
|
||||
:options="exclusiveOptions"
|
||||
placeholder="All Groups"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
:placeholder="t('admin.groups.allPlatforms')"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.groups.allStatus')"
|
||||
class="w-40"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.is_exclusive"
|
||||
:options="exclusiveOptions"
|
||||
:placeholder="t('admin.groups.allGroups')"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
@@ -213,17 +214,18 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<Modal
|
||||
@@ -541,6 +543,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
@@ -35,11 +35,12 @@
|
||||
</svg>
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
@@ -60,8 +61,8 @@
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
@@ -76,11 +77,11 @@
|
||||
class="w-36"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Proxies Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
@@ -199,17 +200,18 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Proxy Modal -->
|
||||
<Modal
|
||||
@@ -291,7 +293,7 @@
|
||||
v-model="createForm.host"
|
||||
type="text"
|
||||
required
|
||||
placeholder="proxy.example.com"
|
||||
:placeholder="t('admin.proxies.form.hostPlaceholder')"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
@@ -303,7 +305,7 @@
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="8080"
|
||||
:placeholder="t('admin.proxies.form.portPlaceholder')"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
@@ -577,6 +579,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, ProxyProtocol } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadCodes"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
@@ -26,11 +26,12 @@
|
||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters and Actions -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -38,8 +39,8 @@
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="filterTypeOptions"
|
||||
@@ -55,11 +56,11 @@
|
||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redeem Codes Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -151,7 +152,7 @@
|
||||
|
||||
<template #cell-used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{
|
||||
value ? formatDate(value) : '-'
|
||||
value ? formatDateTime(value) : '-'
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
@@ -176,24 +177,25 @@
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
|
||||
<!-- Batch Actions -->
|
||||
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
||||
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
|
||||
{{ t('admin.redeem.deleteAllUnused') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Batch Actions -->
|
||||
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
||||
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
|
||||
{{ t('admin.redeem.deleteAllUnused') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -417,9 +419,11 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
@@ -549,10 +553,6 @@ const generateForm = reactive({
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -326,7 +326,12 @@
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.site.siteName') }}
|
||||
</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">
|
||||
{{ t('admin.settings.site.siteNameHint') }}
|
||||
</p>
|
||||
@@ -339,7 +344,7 @@
|
||||
v-model="form.site_subtitle"
|
||||
type="text"
|
||||
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">
|
||||
{{ t('admin.settings.site.siteSubtitleHint') }}
|
||||
@@ -356,7 +361,7 @@
|
||||
v-model="form.api_base_url"
|
||||
type="text"
|
||||
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">
|
||||
{{ t('admin.settings.site.apiBaseUrlHint') }}
|
||||
@@ -388,7 +393,7 @@
|
||||
v-model="form.doc_url"
|
||||
type="url"
|
||||
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">
|
||||
{{ t('admin.settings.site.docUrlHint') }}
|
||||
@@ -537,7 +542,7 @@
|
||||
v-model="form.smtp_host"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="smtp.gmail.com"
|
||||
:placeholder="t('admin.settings.smtp.hostPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -550,7 +555,7 @@
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
placeholder="587"
|
||||
:placeholder="t('admin.settings.smtp.portPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -561,7 +566,7 @@
|
||||
v-model="form.smtp_username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="your-email@gmail.com"
|
||||
:placeholder="t('admin.settings.smtp.usernamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -572,7 +577,7 @@
|
||||
v-model="form.smtp_password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="********"
|
||||
:placeholder="t('admin.settings.smtp.passwordPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.smtp.passwordHint') }}
|
||||
@@ -586,7 +591,7 @@
|
||||
v-model="form.smtp_from_email"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="noreply@example.com"
|
||||
:placeholder="t('admin.settings.smtp.fromEmailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -597,7 +602,7 @@
|
||||
v-model="form.smtp_from_name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Sub2API"
|
||||
:placeholder="t('admin.settings.smtp.fromNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,7 +644,7 @@
|
||||
v-model="testEmailAddress"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="test@example.com"
|
||||
:placeholder="t('admin.settings.testEmail.recipientEmailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<TablePageLayout>
|
||||
<!-- Page Header Actions -->
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
@@ -36,8 +37,10 @@
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
@@ -54,9 +57,10 @@
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -222,7 +226,7 @@
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
"
|
||||
>
|
||||
{{ formatDate(value) }}
|
||||
{{ formatDateOnly(value) }}
|
||||
</span>
|
||||
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
||||
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
||||
@@ -302,9 +306,10 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
@@ -312,7 +317,8 @@
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Assign Subscription Modal -->
|
||||
<Modal
|
||||
@@ -401,7 +407,7 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{
|
||||
extendingSubscription.expires_at
|
||||
? formatDate(extendingSubscription.expires_at)
|
||||
? formatDateOnly(extendingSubscription.expires_at)
|
||||
: t('admin.subscriptions.noExpiration')
|
||||
}}
|
||||
</span>
|
||||
@@ -444,7 +450,9 @@ import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group, User } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
@@ -640,14 +648,6 @@ const confirmRevoke = async () => {
|
||||
}
|
||||
|
||||
// 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 now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Stats Cards -->
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<svg
|
||||
@@ -130,10 +130,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="space-y-4">
|
||||
<!-- Charts Section -->
|
||||
<div class="space-y-4">
|
||||
<!-- Chart Controls -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -157,9 +157,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<!-- Filters Section -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<!-- User Search -->
|
||||
<div class="min-w-[200px]">
|
||||
@@ -229,6 +229,61 @@
|
||||
/>
|
||||
</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 -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||
@@ -252,9 +307,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Table -->
|
||||
<!-- Table Section -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
@@ -270,10 +326,26 @@
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{
|
||||
row.account?.name || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</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 }">
|
||||
<span
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400"
|
||||
@@ -471,10 +564,17 @@
|
||||
}}</span>
|
||||
</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>
|
||||
<EmptyState :message="t('usage.noRecords')" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@@ -498,6 +598,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
@@ -532,17 +633,23 @@ const granularityOptions = computed(() => [
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), 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: 'group', label: t('admin.usage.group'), sortable: false },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), 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 apiKeys = ref<SimpleApiKey[]>([])
|
||||
const models = ref<string[]>([])
|
||||
const accounts = ref<any[]>([])
|
||||
const groups = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 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
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
@@ -571,6 +725,11 @@ const endDate = ref('')
|
||||
const filters = ref<AdminUsageQueryParams>({
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
account_id: undefined,
|
||||
group_id: undefined,
|
||||
model: undefined,
|
||||
stream: undefined,
|
||||
billing_type: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
})
|
||||
@@ -689,17 +848,6 @@ const formatCacheTokens = (value: number): string => {
|
||||
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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -713,6 +861,9 @@ const loadUsageLogs = async () => {
|
||||
usageLogs.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
|
||||
// Extract models from loaded logs for filter options
|
||||
extractModelsFromLogs()
|
||||
} catch (error) {
|
||||
appStore.showError(t('usage.failedToLoad'))
|
||||
} finally {
|
||||
@@ -775,6 +926,32 @@ const applyFilters = () => {
|
||||
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 = () => {
|
||||
selectedUser.value = null
|
||||
userSearchKeyword.value = ''
|
||||
@@ -783,6 +960,11 @@ const resetFilters = () => {
|
||||
filters.value = {
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
account_id: undefined,
|
||||
group_id: undefined,
|
||||
model: undefined,
|
||||
stream: undefined,
|
||||
billing_type: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
}
|
||||
@@ -858,6 +1040,7 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
onMounted(() => {
|
||||
initializeDateRange()
|
||||
loadFilterOptions()
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
loadChartData()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<TablePageLayout>
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadUsers"
|
||||
:disabled="loading"
|
||||
@@ -36,8 +37,10 @@
|
||||
{{ t('admin.users.createUser') }}
|
||||
</button>
|
||||
</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="relative max-w-md flex-1">
|
||||
<svg
|
||||
@@ -78,9 +81,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="users" :loading="loading">
|
||||
<template #cell-email="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -135,7 +139,7 @@
|
||||
:subscription-type="sub.group?.subscription_type"
|
||||
:rate-multiplier="sub.group?.rate_multiplier"
|
||||
: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>
|
||||
<span
|
||||
@@ -191,27 +195,70 @@
|
||||
</template>
|
||||
|
||||
<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 #cell-actions="{ row }">
|
||||
<template #cell-actions="{ row, expanded }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Toggle Status (hidden for admin users) -->
|
||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.role !== 'admin'"
|
||||
@click="handleToggleStatus(row)"
|
||||
:class="[
|
||||
'rounded-lg p-2 transition-colors',
|
||||
row.status === 'active'
|
||||
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||
]"
|
||||
:title="
|
||||
row.status === 'active'
|
||||
? t('admin.users.disableUser')
|
||||
: t('admin.users.enableUser')
|
||||
"
|
||||
@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) -->
|
||||
<button
|
||||
v-if="row.role !== 'admin'"
|
||||
@click="handleToggleStatus(row)"
|
||||
:class="[
|
||||
'rounded-lg p-2 transition-colors',
|
||||
row.status === 'active'
|
||||
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||
]"
|
||||
:title="
|
||||
row.status === 'active'
|
||||
? t('admin.users.disableUser')
|
||||
: t('admin.users.enableUser')
|
||||
"
|
||||
>
|
||||
<svg
|
||||
v-if="row.status === 'active'"
|
||||
class="h-4 w-4"
|
||||
@@ -240,120 +287,80 @@
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Allowed Groups -->
|
||||
<button
|
||||
@click="handleAllowedGroups(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.users.setAllowedGroups')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
</button>
|
||||
<!-- Allowed Groups -->
|
||||
<button
|
||||
@click="handleAllowedGroups(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.users.setAllowedGroups')"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View API Keys -->
|
||||
<button
|
||||
@click="handleViewApiKeys(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
:title="t('admin.users.viewApiKeys')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
<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="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View API Keys -->
|
||||
<button
|
||||
@click="handleViewApiKeys(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
:title="t('admin.users.viewApiKeys')"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Deposit -->
|
||||
<button
|
||||
@click="handleDeposit(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||
:title="t('admin.users.deposit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
<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="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>
|
||||
</button>
|
||||
<!-- Deposit -->
|
||||
<button
|
||||
@click="handleDeposit(row)"
|
||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||
:title="t('admin.users.deposit')"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Withdraw -->
|
||||
<button
|
||||
@click="handleWithdraw(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('admin.users.withdraw')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Withdraw -->
|
||||
<button
|
||||
@click="handleWithdraw(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('admin.users.withdraw')"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Edit -->
|
||||
<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>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -366,9 +373,10 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
@@ -376,7 +384,8 @@
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<Modal
|
||||
@@ -808,7 +817,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
>{{ t('admin.users.columns.created') }}: {{ formatDate(key.created_at) }}</span
|
||||
>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1164,6 +1173,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
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 { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
@@ -1274,15 +1285,6 @@ const editForm = reactive({
|
||||
})
|
||||
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 now = new Date()
|
||||
@@ -1291,12 +1293,6 @@ const getDaysRemaining = (expiresAt: string): number => {
|
||||
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 chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
let password = ''
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<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">
|
||||
We'll send a verification code to
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||
@@ -32,8 +34,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-sm text-amber-700 dark:text-amber-400">
|
||||
<p class="font-medium">Session expired</p>
|
||||
<p class="mt-1">Please go back to the registration page and start again.</p>
|
||||
<p class="font-medium">{{ t('auth.sessionExpired') }}</p>
|
||||
<p class="mt-1">{{ t('auth.sessionExpiredDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +44,9 @@
|
||||
<form v-else @submit.prevent="handleVerify" class="space-y-5">
|
||||
<!-- Verification Code Input -->
|
||||
<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
|
||||
id="code"
|
||||
v-model="verifyCode"
|
||||
@@ -59,7 +63,7 @@
|
||||
<p v-if="errors.code" class="input-error-text text-center">
|
||||
{{ errors.code }}
|
||||
</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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<span v-if="isSendingCode">Sending...</span>
|
||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
|
||||
<span v-else>Resend verification code</span>
|
||||
<span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
|
||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">
|
||||
{{ t('auth.clickToResend') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.resendCode') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -226,11 +232,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label class="input-label">Code</label>
|
||||
<label class="input-label">{{ t('auth.oauth.code') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
|
||||
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">State</label>
|
||||
<label class="input-label">{{ t('auth.oauth.state') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-sm" :value="state" readonly />
|
||||
<button
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Full URL</label>
|
||||
<label class="input-label">{{ t('auth.oauth.fullUrl') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly />
|
||||
<button
|
||||
@@ -63,10 +63,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const code = computed(() => (route.query.code as string) || '')
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sub2API Setup</h1>
|
||||
<p class="mt-2 text-gray-500 dark:text-dark-400">Configure your Sub2API instance</p>
|
||||
<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">{{ t('setup.description') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Steps -->
|
||||
@@ -84,7 +84,7 @@
|
||||
<div v-if="currentStep === 0" class="space-y-6">
|
||||
<div class="mb-6 text-center">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Database Configuration
|
||||
{{ t('setup.database.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
Connect to your PostgreSQL database
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Host</label>
|
||||
<label class="input-label">{{ t('setup.database.host') }}</label>
|
||||
<input
|
||||
v-model="formData.database.host"
|
||||
type="text"
|
||||
@@ -102,7 +102,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Port</label>
|
||||
<label class="input-label">{{ t('setup.database.port') }}</label>
|
||||
<input
|
||||
v-model.number="formData.database.port"
|
||||
type="number"
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Username</label>
|
||||
<label class="input-label">{{ t('setup.database.username') }}</label>
|
||||
<input
|
||||
v-model="formData.database.user"
|
||||
type="text"
|
||||
@@ -123,7 +123,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Password</label>
|
||||
<label class="input-label">{{ t('setup.database.password') }}</label>
|
||||
<input
|
||||
v-model="formData.database.password"
|
||||
type="password"
|
||||
@@ -135,7 +135,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Database Name</label>
|
||||
<label class="input-label">{{ t('setup.database.databaseName') }}</label>
|
||||
<input
|
||||
v-model="formData.database.dbname"
|
||||
type="text"
|
||||
@@ -144,12 +144,12 @@
|
||||
/>
|
||||
</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">
|
||||
<option value="disable">Disable</option>
|
||||
<option value="require">Require</option>
|
||||
<option value="verify-ca">Verify CA</option>
|
||||
<option value="verify-full">Verify Full</option>
|
||||
<option value="disable">{{ t('setup.database.ssl.disable') }}</option>
|
||||
<option value="require">{{ t('setup.database.ssl.require') }}</option>
|
||||
<option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
|
||||
<option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,9 @@
|
||||
<!-- Step 2: Redis -->
|
||||
<div v-if="currentStep === 1" class="space-y-6">
|
||||
<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">
|
||||
Connect to your Redis server
|
||||
</p>
|
||||
@@ -206,7 +208,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Host</label>
|
||||
<label class="input-label">{{ t('setup.redis.host') }}</label>
|
||||
<input
|
||||
v-model="formData.redis.host"
|
||||
type="text"
|
||||
@@ -215,7 +217,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Port</label>
|
||||
<label class="input-label">{{ t('setup.redis.port') }}</label>
|
||||
<input
|
||||
v-model.number="formData.redis.port"
|
||||
type="number"
|
||||
@@ -227,7 +229,7 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">Password (optional)</label>
|
||||
<label class="input-label">{{ t('setup.redis.password') }}</label>
|
||||
<input
|
||||
v-model="formData.redis.password"
|
||||
type="password"
|
||||
@@ -236,7 +238,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">Database</label>
|
||||
<label class="input-label">{{ t('setup.redis.database') }}</label>
|
||||
<input
|
||||
v-model.number="formData.redis.db"
|
||||
type="number"
|
||||
@@ -294,14 +296,16 @@
|
||||
<!-- Step 3: Admin -->
|
||||
<div v-if="currentStep === 2" class="space-y-6">
|
||||
<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">
|
||||
Create your administrator account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Email</label>
|
||||
<label class="input-label">{{ t('setup.admin.email') }}</label>
|
||||
<input
|
||||
v-model="formData.admin.email"
|
||||
type="email"
|
||||
@@ -311,7 +315,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Password</label>
|
||||
<label class="input-label">{{ t('setup.admin.password') }}</label>
|
||||
<input
|
||||
v-model="formData.admin.password"
|
||||
type="password"
|
||||
@@ -321,7 +325,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Confirm Password</label>
|
||||
<label class="input-label">{{ t('setup.admin.confirmPassword') }}</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
@@ -340,7 +344,9 @@
|
||||
<!-- Step 4: Complete -->
|
||||
<div v-if="currentStep === 3" class="space-y-6">
|
||||
<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">
|
||||
Review your configuration and complete setup
|
||||
</p>
|
||||
@@ -348,7 +354,9 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
{{ formData.database.user }}@{{ formData.database.host }}:{{
|
||||
formData.database.port
|
||||
@@ -357,14 +365,18 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ formData.redis.host }}:{{ formData.redis.port }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,8 +538,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const steps = [
|
||||
{ id: 'database', title: 'Database' },
|
||||
{ id: 'redis', title: 'Redis' },
|
||||
|
||||
@@ -452,16 +452,16 @@
|
||||
{{ log.model }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ formatDate(log.created_at) }}
|
||||
{{ formatDateTime(log.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
</p>
|
||||
@@ -649,6 +649,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { usageAPI, type UserDashboardStats } from '@/api/usage'
|
||||
@@ -914,16 +915,6 @@ const formatDuration = (ms: number): string => {
|
||||
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) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadApiKeys"
|
||||
:disabled="loading"
|
||||
@@ -36,9 +36,9 @@
|
||||
{{ t('keys.createKey') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
||||
<template #cell-key="{ value, row }">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -146,7 +146,7 @@
|
||||
</template>
|
||||
|
||||
<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 #cell-actions="{ row }">
|
||||
@@ -235,7 +235,7 @@
|
||||
<button
|
||||
@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"
|
||||
title="Edit"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -255,7 +255,7 @@
|
||||
<button
|
||||
@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"
|
||||
title="Delete"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -283,17 +283,18 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<Modal
|
||||
@@ -496,6 +497,7 @@ import { useAppStore } from '@/stores/app'
|
||||
const { t } = useI18n()
|
||||
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.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 { Column } from '@/components/common/types'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
interface GroupOption {
|
||||
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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.memberSince')"
|
||||
:value="formatMemberSince(user?.created_at || '')"
|
||||
:value="formatDate(user?.created_at || '', 'YYYY-MM')"
|
||||
:icon="CalendarIcon"
|
||||
icon-variant="primary"
|
||||
/>
|
||||
@@ -267,6 +267,7 @@ import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { userAPI, authAPI } from '@/api'
|
||||
@@ -358,15 +359,6 @@ const formatCurrency = (value: number): string => {
|
||||
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 () => {
|
||||
// Validate password match
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
{{ getHistoryItemTitle(item) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ formatDate(item.used_at) }}
|
||||
{{ formatDateTime(item.used_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -447,6 +447,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
@@ -472,18 +473,6 @@ const history = ref<RedeemHistoryItem[]>([])
|
||||
const loadingHistory = ref(false)
|
||||
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
|
||||
const isBalanceType = (type: string) => {
|
||||
return type === 'balance' || type === 'admin_balance'
|
||||
|
||||
@@ -257,6 +257,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import subscriptionsAPI from '@/api/subscriptions'
|
||||
import type { UserSubscription } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -300,11 +301,7 @@ function formatExpirationDate(expiresAt: string): string {
|
||||
return t('userSubscriptions.status.expired')
|
||||
}
|
||||
|
||||
const dateStr = expires.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
const dateStr = formatDateOnly(expires)
|
||||
|
||||
if (days === 0) {
|
||||
return `${dateStr} (Today)`
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<svg
|
||||
@@ -131,11 +131,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4">
|
||||
<template #filters>
|
||||
<div class="card">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<!-- API Key Filter -->
|
||||
<div class="min-w-[180px]">
|
||||
@@ -169,11 +170,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Usage Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<template #table>
|
||||
<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 }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -379,17 +386,18 @@
|
||||
<EmptyState :message="t('usage.noRecords')" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -399,6 +407,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { usageAPI, keysAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.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 type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -414,6 +424,7 @@ const appStore = useAppStore()
|
||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
@@ -505,17 +516,6 @@ const formatCacheTokens = (value: number): string => {
|
||||
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 () => {
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user