Unverified Commit 254f1254 authored by IanShaw's avatar IanShaw Committed by GitHub
Browse files

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 方法调用
parent cf8a6452
...@@ -27,13 +27,56 @@ export default { ...@@ -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 { ...@@ -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 { ...@@ -373,6 +429,12 @@ export default {
noData: '暂无数据' noData: '暂无数据'
}, },
// Table
table: {
expandActions: '展开更多操作',
collapseActions: '收起操作'
},
// Pagination // Pagination
pagination: { pagination: {
showing: '显示', showing: '显示',
...@@ -689,6 +751,7 @@ export default { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 { ...@@ -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 @@ ...@@ -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 { ...@@ -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 { ...@@ -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( ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 {
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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'
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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'
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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'
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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({ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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 () => { ...@@ -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)
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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(() => [ ...@@ -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(() => { ...@@ -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('') ...@@ -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 => { ...@@ -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 () => { ...@@ -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 = () => { ...@@ -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 = () => { ...@@ -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) => { ...@@ -858,6 +1040,7 @@ const handleClickOutside = (event: MouseEvent) => {
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadFilterOptions()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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({ ...@@ -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 => { ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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 => { ...@@ -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)
} }
......
<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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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 @@ ...@@ -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' ...@@ -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' ...@@ -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) => { ...@@ -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 @@ ...@@ -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' ...@@ -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 => { ...@@ -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) {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment