Compare commits

...

39 Commits

Author SHA1 Message Date
shaw
fbdff4f34f fix: 防止订阅过期时间超出 JSON 序列化范围
问题:当分配订阅天数过大时,expires_at 年份可能超过 9999,
导致 time.Time JSON 序列化失败(RFC 3339 要求年份 <= 9999),
使后台无法显示和删除异常数据。

修复:
- handler 层添加 validity_days 最大值验证(max=36500,即100年)
- service 层添加 MaxValidityDays 和 MaxExpiresAt 双重保护
- 启动时自动修复已存在的异常数据(expires_at > 2099年)
2025-12-28 11:45:41 +08:00
shaw
0aa480283f Merge branch 'feat/deferred-batch-update' 2025-12-28 11:28:06 +08:00
shaw
cd9d31f5f2 fix: 修复NeedsRefresh bug导致刷新失败的问题 2025-12-28 11:23:52 +08:00
noreply
cbfce49aa1 feat: Schedule batch update for account last_used_at
Implement deferred batch update mechanism to reduce database load:

- Add DeferredService for batching account last_used_at updates
- Add TimingWheelService for efficient recurring task scheduling
- Integrate with GatewayService and OpenAIGatewayService
- Implement BatchUpdateLastUsed repository method using CASE...WHEN SQL
- Fix golangci-lint error: Replace interface{} with any

Benefits:
- Reduces database writes by batching updates (10-second intervals)
- Improves request throughput by deferring non-critical updates
- Maintains accurate account usage tracking for scheduling
2025-12-28 09:49:54 +08:00
程序猿MT
d3e73f1260 feat: 增加caddy 安全配置示例 (#57)
feat 增加 caddy 示例安全反向代理
2025-12-27 21:36:26 +08:00
shaw
b5ca6a654c Merge branch 'main' of github.com:Wei-Shaw/sub2api
# Conflicts:
2025-12-27 21:16:29 +08:00
shaw
94749b12ac chore: 调整deploy说明以及取消postgres端口暴露 2025-12-27 21:14:08 +08:00
IanShaw
523fa9f71e fix(frontend): 修复用户仪表板日期格式错误导致请求失败 (#55)
修复 loadRecentUsage 函数中日期格式问题,将 ISO 完整格式改为 YYYY-MM-DD 格式,与后端 API 期望一致。
2025-12-27 21:09:43 +08:00
shaw
54636781ea Merge branch 'feature/datatable-enhancements' 2025-12-27 21:00:37 +08:00
shaw
5187db5ee5 fix(frontend): 修复DataTable无限循环和i18n邮箱解析错误
- 修复DataTable组件watch监听actionsExpanded导致的无限循环卡死问题
- 为AccountsView和UsersView添加actionsCount属性启用操作列展开功能
- 修复i18n翻译中邮箱地址的@符号未转义导致的编译错误
2025-12-27 21:00:26 +08:00
shaw
0b9c4ae69e fix: 修复claude setup token授权效期短的问题 2025-12-27 20:42:00 +08:00
shaw
0d5a8a95c8 fix: 修复claude token刷新失效的问题 2025-12-27 20:13:39 +08:00
IanShaw027
9cd97c9e1d fix(frontend): 统一账号编辑弹窗宽度与新增弹窗保持一致
问题:
- 编辑账号弹窗使用size='lg'
- 新增账号弹窗使用size='xl'
- 两者宽度不一致,体验不统一

修复:
- 将EditAccountModal的size从lg改为xl
- 与CreateAccountModal保持一致
2025-12-27 20:13:29 +08:00
IanShaw027
d521191e87 fix(frontend): 修复i18n翻译中的Invalid linked format错误
问题:
- admin/settings页面无法访问,报错'Invalid linked format'
- vue-i18n解析器将{'@'}误认为链接格式语法

修复:
- 将zh.ts和en.ts中的{'@'}替换为直接的@字符
- 影响范围:代理配置相关的翻译字符串
2025-12-27 20:11:13 +08:00
IanShaw027
fd78993b91 feat(frontend): DataTable组件增强 - 操作列宽度自适应和列数自适应padding
新增功能:

1. 操作列宽度自适应
   - checkActionsColumnWidth 方法:智能检测操作按钮是否超出列宽
     - 临时展开所有按钮测量实际宽度
     - 计算包含gap的总宽度
     - 与可用宽度对比,自动显示/隐藏"展开"按钮
   - 新增 actionsCount prop:
     - 用于快速判断是否需要展开功能
     - 避免DOM查询带来的性能开销

2. 列数自适应padding
   - getAdaptivePaddingClass 方法:根据列数动态调整内边距
     - ≥10列 → px-2 (8px)
     - ≥7列  → px-3 (12px)
     - ≥5列  → px-4 (16px)
     - <5列  → px-6 (24px,原始值)
   - 让表格在列数较多时更紧凑,提升空间利用率
2025-12-27 20:02:10 +08:00
shaw
80cce858cb fix(frontend): 修复添加账号弹窗宽度和tooltip被截断问题
- 将弹窗尺寸从 lg 改为 xl,增加内容显示空间
- 修复 AI Studio tooltip 被弹窗边界截断的问题
  - 调整定位从 left-0 改为 right-0
  - 减小宽度从 w-[28rem] 改为 w-80
  - 提高 z-index 确保正确显示
2025-12-27 17:04:38 +08:00
shaw
0743652d92 Merge branch 'feature/ui-and-backend-improvements' 2025-12-27 16:33:57 +08:00
IanShaw027
96bec5c9b1 fix(test): 实现GetUserStatsAggregated方法以支持新的统计查询
- 在stubUsageLogRepo中实现GetUserStatsAggregated方法
- 根据userLogs计算统计数据而不是返回错误
- 修复类型转换问题(int转int64)
2025-12-27 16:20:59 +08:00
IanShaw027
cfeb6b8b14 fix(test): 添加缺失的GetUserStatsAggregated方法 2025-12-27 16:15:32 +08:00
IanShaw027
481310dea0 fix(test): 修复CI测试失败
- 修复gofmt格式问题
- 为stubUsageLogRepo添加缺失的GetApiKeyStatsAggregated方法
2025-12-27 16:12:06 +08:00
IanShaw027
ea2821d11d refactor(frontend): 优化用户视图和设置向导
- 改进API密钥管理界面
- 优化用户使用统计视图
- 完善初始化设置向导
2025-12-27 16:05:36 +08:00
IanShaw027
7a0de1765f refactor(frontend): 优化管理后台视图
- 改进账户管理视图
- 优化分组管理界面
- 完善代理管理功能
- 增强兑换码管理
- 改进订阅管理视图
- 优化使用统计展示
- 完善用户管理界面
2025-12-27 16:05:16 +08:00
IanShaw027
35b1bc3753 refactor(frontend): 优化格式化工具函数
- 改进数据格式化逻辑
- 增强工具函数可读性
2025-12-27 16:04:56 +08:00
IanShaw027
8d38788672 feat(frontend): 更新国际化翻译
- 新增英文翻译条目
- 完善中文翻译内容
- 改进多语言支持
2025-12-27 16:04:35 +08:00
IanShaw027
c615a4264d refactor(frontend): 优化通用组件
- 改进ConfirmDialog对话框组件
- 增强DataTable表格组件功能和响应式布局
- 优化EmptyState空状态组件
- 完善SubscriptionProgressMini订阅进度组件
2025-12-27 16:04:16 +08:00
IanShaw027
227d506c53 feat(backend): 增强使用统计和API密钥功能
- 优化使用统计处理逻辑
- 增强API密钥仓储层功能
- 改进账户使用服务
- 完善API契约测试覆盖
2025-12-27 16:03:57 +08:00
IanShaw027
36a86e9ab4 perf(backend): 优化数据库查询性能
- 合并多个独立查询为单个SQL查询
- 减少数据库往返次数
- 提升仪表板统计数据获取效率
2025-12-27 16:03:37 +08:00
shaw
f133b051dc fix: 修复TG通知workflow语法错误
- 移除if条件中对secrets的直接引用(GitHub Actions不支持)
- 改用shell脚本内部检查环境变量是否存在
2025-12-27 16:03:13 +08:00
shaw
7af1bdbf4c chore: workflow增加TG频道更新通知 2025-12-27 15:55:09 +08:00
shaw
016d7ef645 feat: 增强前端clipboard功能 2025-12-27 15:16:52 +08:00
shaw
f1e47291cd fix: 修复账号更新时分组绑定操作顺序导致的数据不一致问题
原逻辑先执行 Update 再验证 GroupIDs,如果验证失败会导致账号已更新但返回错误。
现改为先验证分组是否存在,再执行 Update 和 BindGroups。
2025-12-27 14:57:43 +08:00
shaw
d7e9ae38e4 Merge PR #49: feat: cc/codex/gemini 增加账号重试功能 2025-12-27 13:59:00 +08:00
程序猿MT
88be981afc feat: (#47)
golang 1.24-> 1.25
node 20 -> node 24
具体提升请查看官方文档

Co-authored-by: yangjianbo <yangjianbo@leagsoft.com>
2025-12-27 13:56:14 +08:00
IanShaw
3f92a43170 test: 完善 UsageLogRepo 测试 stub 的过滤逻辑 (#50) 2025-12-27 13:53:47 +08:00
shaw
2101f1d1c8 fix: 修复claude OAuth账户刷新token失败的bug 2025-12-27 13:50:35 +08:00
daodao97
f0f920e49f feat: cc/codex/gemini 增加账号重试 2025-12-27 12:27:47 +08:00
daodao97
95583fce83 feat: cc/codex support account retry 2025-12-27 12:05:38 +08:00
IanShaw
254f12543c 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 方法调用
2025-12-27 10:50:25 +08:00
IanShaw
cf8a64528c fix: 修复 Gemini API 认证和 /responses 端点路由问题 (#45)
* fix(middleware): 修复 Gemini API Key 认证中间件用户上下文类型错误

修复了 ApiKeyAuthWithSubscriptionGoogle 中间件中设置用户上下文时的类型错误。

**问题:**
- 中间件直接设置 `apiKey.User` 对象到上下文
- 导致 handler 中获取 `AuthSubject` 时类型断言失败
- 所有 Gemini v1beta 端点返回 500 "User context not found"

**修复:**
- 改为设置 `AuthSubject` 结构体,与 `api_key_auth.go` 保持一致
- 添加 `ContextKeyUserRole` 设置以完整支持角色检查

**影响范围:**
- Gemini v1beta API 端点 (generateContent, streamGenerateContent)
- 使用 Google API Key 认证的所有请求

**测试:**
- 验证 Gemini CLI 调用成功返回 200
- 确认用户上下文正确传递到 handler

* fix(web): 修复 /responses 端点被前端中间件拦截的问题

- 将 /responses 路径添加到 API 白名单,防止其被当作前端路由处理
- 修复 /responses 端点返回 HTML 而非 API 响应的 BUG
- 解决 codex CLI stream 在远程服务器上断开连接的问题

根本原因:
在 6c469b4 提交中添加了 /responses 路由,但未同步更新前端嵌入中间件
的 API 白名单,导致该路由被拦截并返回 index.html 而非 API 响应。
2025-12-27 10:50:15 +08:00
90 changed files with 3819 additions and 1196 deletions

View File

@@ -143,3 +143,61 @@ jobs:
repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api
short-description: "Sub2API - AI API Gateway Platform" short-description: "Sub2API - AI API Gateway Platform"
readme-filepath: ./deploy/DOCKER.md readme-filepath: ./deploy/DOCKER.md
# Send Telegram notification
- name: Send Telegram Notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
continue-on-error: true
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "Telegram credentials not configured, skipping notification"
exit 0
fi
TAG_NAME=${GITHUB_REF#refs/tags/}
VERSION=${TAG_NAME#v}
REPO="${{ github.repository }}"
DOCKER_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/sub2api"
# 获取 tag message 内容
TAG_MESSAGE='${{ steps.tag_message.outputs.message }}'
# 限制消息长度Telegram 消息限制 4096 字符,预留空间给头尾固定内容)
if [ ${#TAG_MESSAGE} -gt 3500 ]; then
TAG_MESSAGE="${TAG_MESSAGE:0:3500}..."
fi
# 构建消息内容
MESSAGE="🚀 *Sub2API 新版本发布!*"$'\n'$'\n'
MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n'
# 添加更新内容
if [ -n "$TAG_MESSAGE" ]; then
MESSAGE+="${TAG_MESSAGE}"$'\n'$'\n'
fi
MESSAGE+="🐳 *Docker 部署:*"$'\n'
MESSAGE+="\`\`\`bash"$'\n'
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG_NAME}"$'\n'
MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n'
MESSAGE+="\`\`\`"$'\n'$'\n'
MESSAGE+="🔗 *相关链接:*"$'\n'
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG_NAME})"$'\n'
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE})"$'\n'$'\n'
MESSAGE+="#Sub2API #Release #${TAG_NAME//./_}"
# 发送消息
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "${TELEGRAM_CHAT_ID}" \
--arg text "${MESSAGE}" \
'{
chat_id: $chat_id,
text: $text,
parse_mode: "Markdown",
disable_web_page_preview: true
}')"

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ frontend/node_modules/
frontend/dist/ frontend/dist/
*.local *.local
*.tsbuildinfo *.tsbuildinfo
vite.config.d.ts
# 日志 # 日志
npm-debug.log* npm-debug.log*

View File

@@ -9,7 +9,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Stage 1: Frontend Builder # Stage 1: Frontend Builder
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
FROM node:20-alpine AS frontend-builder FROM node:24-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -24,7 +24,7 @@ RUN npm run build
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Stage 2: Backend Builder # Stage 2: Backend Builder
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
FROM golang:1.24-alpine AS backend-builder FROM golang:1.25-alpine AS backend-builder
# Build arguments for version info (set by CI) # Build arguments for version info (set by CI)
ARG VERSION=docker ARG VERSION=docker

View File

@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
billingService := service.NewBillingService(configConfig, pricingService) billingService := service.NewBillingService(configConfig, pricingService)
identityCache := repository.NewIdentityCache(client) identityCache := repository.NewIdentityCache(client)
identityService := service.NewIdentityService(identityCache) identityService := service.NewIdentityService(identityCache)
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream) timingWheelService := service.ProvideTimingWheelService()
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService) gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler) handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)

View File

@@ -11,13 +11,14 @@ require (
github.com/google/wire v0.7.0 github.com/google/wire v0.7.0
github.com/imroc/req/v3 v3.56.0 github.com/imroc/req/v3 v3.56.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.7.3 github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0 github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/zeromicro/go-zero v1.9.4
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.44.0
golang.org/x/net v0.47.0 golang.org/x/net v0.47.0
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
@@ -49,6 +50,7 @@ require (
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
@@ -59,7 +61,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.9.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/subcommands v1.2.0 // indirect github.com/google/subcommands v1.2.0 // indirect
@@ -67,9 +69,9 @@ require (
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
@@ -78,7 +80,8 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -93,7 +96,7 @@ require (
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
@@ -105,6 +108,7 @@ require (
github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
@@ -123,7 +127,8 @@ require (
go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect

View File

@@ -52,6 +52,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -80,8 +82,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
@@ -113,12 +115,12 @@ github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo=
github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk= github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -142,8 +144,11 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -179,8 +184,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -188,12 +193,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY= github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c= github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -208,6 +215,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
@@ -228,6 +237,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -259,26 +269,30 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -301,6 +315,7 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -30,8 +30,8 @@ type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"` Count int `json:"count" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription"` Type string `json:"type" binding:"required,oneof=balance concurrency subscription"`
Value float64 `json:"value" binding:"min=0"` Value float64 `json:"value" binding:"min=0"`
GroupID *int64 `json:"group_id"` // 订阅类型必填 GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用默认30天 ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用默认30天最大100年
} }
// List handles listing all redeem codes with pagination // List handles listing all redeem codes with pagination

View File

@@ -41,7 +41,7 @@ func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *S
type AssignSubscriptionRequest struct { type AssignSubscriptionRequest struct {
UserID int64 `json:"user_id" binding:"required"` UserID int64 `json:"user_id" binding:"required"`
GroupID int64 `json:"group_id" binding:"required"` GroupID int64 `json:"group_id" binding:"required"`
ValidityDays int `json:"validity_days"` ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
Notes string `json:"notes"` Notes string `json:"notes"`
} }
@@ -49,13 +49,13 @@ type AssignSubscriptionRequest struct {
type BulkAssignSubscriptionRequest struct { type BulkAssignSubscriptionRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"` UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
GroupID int64 `json:"group_id" binding:"required"` GroupID int64 `json:"group_id" binding:"required"`
ValidityDays int `json:"validity_days"` ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
Notes string `json:"notes"` Notes string `json:"notes"`
} }
// ExtendSubscriptionRequest represents extend subscription request // ExtendSubscriptionRequest represents extend subscription request
type ExtendSubscriptionRequest struct { type ExtendSubscriptionRequest struct {
Days int `json:"days" binding:"required,min=1"` Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years
} }
// List handles listing all subscriptions with pagination and filters // List handles listing all subscriptions with pagination and filters

View File

@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c) page, pageSize := response.ParsePagination(c)
// Parse filters // Parse filters
var userID, apiKeyID int64 var userID, apiKeyID, accountID, groupID int64
if userIDStr := c.Query("user_id"); userIDStr != "" { if userIDStr := c.Query("user_id"); userIDStr != "" {
id, err := strconv.ParseInt(userIDStr, 10, 64) id, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil { if err != nil {
@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID = id apiKeyID = id
} }
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
id, err := strconv.ParseInt(accountIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account_id")
return
}
accountID = id
}
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
id, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group_id")
return
}
groupID = id
}
model := c.Query("model")
var stream *bool
if streamStr := c.Query("stream"); streamStr != "" {
val, err := strconv.ParseBool(streamStr)
if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
}
bt := int8(val)
billingType = &bt
}
// Parse date range // Parse date range
var startTime, endTime *time.Time var startTime, endTime *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" { if startDateStr := c.Query("start_date"); startDateStr != "" {
@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize}
filters := usagestats.UsageLogFilters{ filters := usagestats.UsageLogFilters{
UserID: userID, UserID: userID,
ApiKeyID: apiKeyID, ApiKeyID: apiKeyID,
StartTime: startTime, AccountID: accountID,
EndTime: endTime, GroupID: groupID,
Model: model,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
} }
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters) records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -127,66 +128,158 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
platform = apiKey.Group.Platform platform = apiKey.Group.Platform
} }
// 选择支持该模型的账号
var account *service.Account
if platform == service.PlatformGemini { if platform == service.PlatformGemini {
account, err = h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model) const maxAccountSwitches = 3
} else { switchCount := 0
account, err = h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model) failedAccountIDs := make(map[int64]struct{})
} lastFailoverStatus := 0
if err != nil {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
return
}
// 检查预热请求拦截(在账号选择后、转发前检查) for {
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) { account, err := h.geminiCompatService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, failedAccountIDs)
if req.Stream { if err != nil {
sendMockWarmupStream(c, req.Model) if len(failedAccountIDs) == 0 {
} else { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
sendMockWarmupResponse(c, req.Model) return
}
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return
}
// 检查预热请求拦截(在账号选择后、转发前检查)
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
if req.Stream {
sendMockWarmupStream(c, req.Model)
} else {
sendMockWarmupResponse(c, req.Model)
}
return
}
// 3. 获取账号并发槽位
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted)
if err != nil {
log.Printf("Account concurrency acquire failed: %v", err)
h.handleConcurrencyError(c, err, "account", streamStarted)
return
}
// 转发请求
result, err := h.geminiCompatService.Forward(c.Request.Context(), c, account, body)
if accountReleaseFunc != nil {
accountReleaseFunc()
}
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return
}
lastFailoverStatus = failoverErr.StatusCode
switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue
}
// 错误响应已在Forward中处理这里只记录日志
log.Printf("Forward request failed: %v", err)
return
}
// 异步记录使用量subscription已在函数开头获取
go func(result *service.ForwardResult, usedAccount *service.Account) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
return
} }
return
} }
// 3. 获取账号并发槽位 const maxAccountSwitches = 3
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted) switchCount := 0
if err != nil { failedAccountIDs := make(map[int64]struct{})
log.Printf("Account concurrency acquire failed: %v", err) lastFailoverStatus := 0
h.handleConcurrencyError(c, err, "account", streamStarted)
return
}
if accountReleaseFunc != nil {
defer accountReleaseFunc()
}
// 转发请求 for {
var result *service.ForwardResult // 选择支持该模型的账号
if platform == service.PlatformGemini { account, err := h.gatewayService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, failedAccountIDs)
result, err = h.geminiCompatService.Forward(c.Request.Context(), c, account, body) if err != nil {
} else { if len(failedAccountIDs) == 0 {
result, err = h.gatewayService.Forward(c.Request.Context(), c, account, body) h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
} return
if err != nil { }
// 错误响应已在Forward中处理这里只记录日志 h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
log.Printf("Forward request failed: %v", err) return
return
}
// 异步记录使用量subscription已在函数开头获取
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
} }
}()
// 检查预热请求拦截(在账号选择后、转发前检查)
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
if req.Stream {
sendMockWarmupStream(c, req.Model)
} else {
sendMockWarmupResponse(c, req.Model)
}
return
}
// 3. 获取账号并发槽位
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted)
if err != nil {
log.Printf("Account concurrency acquire failed: %v", err)
h.handleConcurrencyError(c, err, "account", streamStarted)
return
}
// 转发请求
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
if accountReleaseFunc != nil {
accountReleaseFunc()
}
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return
}
lastFailoverStatus = failoverErr.StatusCode
switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue
}
// 错误响应已在Forward中处理这里只记录日志
log.Printf("Forward request failed: %v", err)
return
}
// 异步记录使用量subscription已在函数开头获取
go func(result *service.ForwardResult, usedAccount *service.Account) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
return
}
} }
// Models handles listing available models // Models handles listing available models
@@ -314,6 +407,28 @@ func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotT
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted) fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
} }
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int, streamStarted bool) {
status, errType, errMsg := h.mapUpstreamError(statusCode)
h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted)
}
func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
switch statusCode {
case 401:
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
case 403:
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
case 429:
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
case 529:
return http.StatusServiceUnavailable, "overloaded_error", "Upstream service overloaded, please retry later"
case 500, 502, 503, 504:
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
default:
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
}
}
// handleStreamingAwareError handles errors that may occur after streaming has started // handleStreamingAwareError handles errors that may occur after streaming has started
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
if streamStarted { if streamStarted {

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"context" "context"
"errors"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -158,44 +159,69 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 3) select account (sticky session based on request body) // 3) select account (sticky session based on request body)
sessionHash := h.gatewayService.GenerateSessionHash(body) sessionHash := h.gatewayService.GenerateSessionHash(body)
account, err := h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, modelName) const maxAccountSwitches = 3
if err != nil { switchCount := 0
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) failedAccountIDs := make(map[int64]struct{})
return lastFailoverStatus := 0
}
// 4) account concurrency slot for {
accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted) account, err := h.geminiCompatService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, modelName, failedAccountIDs)
if err != nil { if err != nil {
googleError(c, http.StatusTooManyRequests, err.Error()) if len(failedAccountIDs) == 0 {
return googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
} return
if accountReleaseFunc != nil { }
defer accountReleaseFunc() handleGeminiFailoverExhausted(c, lastFailoverStatus)
} return
// 5) forward (writes response to client)
result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body)
if err != nil {
// ForwardNative already wrote the response
log.Printf("Gemini native forward failed: %v", err)
return
}
// 6) record usage async
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
} }
}()
// 4) account concurrency slot
accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted)
if err != nil {
googleError(c, http.StatusTooManyRequests, err.Error())
return
}
// 5) forward (writes response to client)
result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body)
if accountReleaseFunc != nil {
accountReleaseFunc()
}
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
handleGeminiFailoverExhausted(c, lastFailoverStatus)
return
}
lastFailoverStatus = failoverErr.StatusCode
switchCount++
log.Printf("Gemini account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue
}
// ForwardNative already wrote the response
log.Printf("Gemini native forward failed: %v", err)
return
}
// 6) record usage async
go func(result *service.ForwardResult, usedAccount *service.Account) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
return
}
} }
func parseGeminiModelAction(rest string) (model string, action string, err error) { func parseGeminiModelAction(rest string) (model string, action string, err error) {
@@ -217,6 +243,28 @@ func parseGeminiModelAction(rest string) (model string, action string, err error
return "", "", &pathParseError{"invalid model action path"} return "", "", &pathParseError{"invalid model action path"}
} }
func handleGeminiFailoverExhausted(c *gin.Context, statusCode int) {
status, message := mapGeminiUpstreamError(statusCode)
googleError(c, status, message)
}
func mapGeminiUpstreamError(statusCode int) (int, string) {
switch statusCode {
case 401:
return http.StatusBadGateway, "Upstream authentication failed, please contact administrator"
case 403:
return http.StatusBadGateway, "Upstream access forbidden, please contact administrator"
case 429:
return http.StatusTooManyRequests, "Upstream rate limit exceeded, please retry later"
case 529:
return http.StatusServiceUnavailable, "Upstream service overloaded, please retry later"
case 500, 502, 503, 504:
return http.StatusBadGateway, "Upstream service temporarily unavailable"
default:
return http.StatusBadGateway, "Upstream request failed"
}
}
type pathParseError struct{ msg string } type pathParseError struct{ msg string }
func (e *pathParseError) Error() string { return e.msg } func (e *pathParseError) Error() string { return e.msg }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -127,49 +128,74 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// Generate session hash (from header for OpenAI) // Generate session hash (from header for OpenAI)
sessionHash := h.gatewayService.GenerateSessionHash(c) sessionHash := h.gatewayService.GenerateSessionHash(c)
// Select account supporting the requested model const maxAccountSwitches = 3
log.Printf("[OpenAI Handler] Selecting account: groupID=%v model=%s", apiKey.GroupID, reqModel) switchCount := 0
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel) failedAccountIDs := make(map[int64]struct{})
if err != nil { lastFailoverStatus := 0
log.Printf("[OpenAI Handler] SelectAccount failed: %v", err)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
return
}
log.Printf("[OpenAI Handler] Selected account: id=%d name=%s", account.ID, account.Name)
// 3. Acquire account concurrency slot for {
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, reqStream, &streamStarted) // Select account supporting the requested model
if err != nil { log.Printf("[OpenAI Handler] Selecting account: groupID=%v model=%s", apiKey.GroupID, reqModel)
log.Printf("Account concurrency acquire failed: %v", err) account, err := h.gatewayService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, failedAccountIDs)
h.handleConcurrencyError(c, err, "account", streamStarted) if err != nil {
return log.Printf("[OpenAI Handler] SelectAccount failed: %v", err)
} if len(failedAccountIDs) == 0 {
if accountReleaseFunc != nil { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
defer accountReleaseFunc() return
} }
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
// Forward request return
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
if err != nil {
// Error response already handled in Forward, just log
log.Printf("Forward request failed: %v", err)
return
}
// Async record usage
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
} }
}() log.Printf("[OpenAI Handler] Selected account: id=%d name=%s", account.ID, account.Name)
// 3. Acquire account concurrency slot
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, reqStream, &streamStarted)
if err != nil {
log.Printf("Account concurrency acquire failed: %v", err)
h.handleConcurrencyError(c, err, "account", streamStarted)
return
}
// Forward request
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
if accountReleaseFunc != nil {
accountReleaseFunc()
}
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
failedAccountIDs[account.ID] = struct{}{}
if switchCount >= maxAccountSwitches {
lastFailoverStatus = failoverErr.StatusCode
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
return
}
lastFailoverStatus = failoverErr.StatusCode
switchCount++
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
continue
}
// Error response already handled in Forward, just log
log.Printf("Forward request failed: %v", err)
return
}
// Async record usage
go func(result *service.OpenAIForwardResult, usedAccount *service.Account) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
ApiKey: apiKey,
User: apiKey.User,
Account: usedAccount,
Subscription: subscription,
}); err != nil {
log.Printf("Record usage failed: %v", err)
}
}(result, account)
return
}
} }
// handleConcurrencyError handles concurrency-related errors with proper 429 response // handleConcurrencyError handles concurrency-related errors with proper 429 response
@@ -178,6 +204,28 @@ func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error,
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted) fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
} }
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int, streamStarted bool) {
status, errType, errMsg := h.mapUpstreamError(statusCode)
h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted)
}
func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
switch statusCode {
case 401:
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
case 403:
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
case 429:
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
case 529:
return http.StatusServiceUnavailable, "upstream_error", "Upstream service overloaded, please retry later"
case 500, 502, 503, 504:
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
default:
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
}
}
// handleStreamingAwareError handles errors that may occur after streaming has started // handleStreamingAwareError handles errors that may occur after streaming has started
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
if streamStarted { if streamStarted {

View File

@@ -8,6 +8,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) {
apiKeyID = id apiKeyID = id
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize} // Parse additional filters
var records []service.UsageLog model := c.Query("model")
var result *pagination.PaginationResult
var err error
if apiKeyID > 0 { var stream *bool
records, result, err = h.usageService.ListByApiKey(c.Request.Context(), apiKeyID, params) if streamStr := c.Query("stream"); streamStr != "" {
} else { val, err := strconv.ParseBool(streamStr)
records, result, err = h.usageService.ListByUser(c.Request.Context(), subject.UserID, params) if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
}
stream = &val
} }
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
}
bt := int8(val)
billingType = &bt
}
// Parse date range
var startTime, endTime *time.Time
if startDateStr := c.Query("start_date"); startDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
startTime = &t
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// Set end time to end of day
t = t.Add(24*time.Hour - time.Nanosecond)
endTime = &t
}
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
filters := usagestats.UsageLogFilters{
UserID: subject.UserID, // Always filter by current user for security
ApiKeyID: apiKeyID,
Model: model,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -322,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
return return
} }
// Verify ownership of all requested API keys // Limit the number of API key IDs to prevent SQL parameter overflow
userApiKeys, _, err := h.apiKeyService.List(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: 1, PageSize: 1000}) if len(req.ApiKeyIDs) > 100 {
if err != nil { response.BadRequest(c, "Too many API key IDs (maximum 100 allowed)")
response.ErrorFrom(c, err)
return return
} }
userApiKeyIDs := make(map[int64]bool) validApiKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.ApiKeyIDs)
for _, key := range userApiKeys { if err != nil {
userApiKeyIDs[key.ID] = true response.ErrorFrom(c, err)
} return
// Filter to only include user's own API keys
validApiKeyIDs := make([]int64, 0)
for _, id := range req.ApiKeyIDs {
if userApiKeyIDs[id] {
validApiKeyIDs = append(validApiKeyIDs, id)
}
} }
if len(validApiKeyIDs) == 0 { if len(validApiKeyIDs) == 0 {

View File

@@ -127,10 +127,15 @@ type UserDashboardStats struct {
// UsageLogFilters represents filters for usage log queries // UsageLogFilters represents filters for usage log queries
type UsageLogFilters struct { type UsageLogFilters struct {
UserID int64 UserID int64
ApiKeyID int64 ApiKeyID int64
StartTime *time.Time AccountID int64
EndTime *time.Time GroupID int64
Model string
Stream *bool
BillingType *int8
StartTime *time.Time
EndTime *time.Time
} }
// UsageStats represents usage statistics // UsageStats represents usage statistics

View File

@@ -171,6 +171,27 @@ func (r *accountRepository) UpdateLastUsed(ctx context.Context, id int64) error
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("last_used_at", now).Error return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("last_used_at", now).Error
} }
func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
if len(updates) == 0 {
return nil
}
var caseSql = "UPDATE accounts SET last_used_at = CASE id"
var args []any
var ids []int64
for id, ts := range updates {
caseSql += " WHEN ? THEN CAST(? AS TIMESTAMP)"
args = append(args, id, ts)
ids = append(ids, id)
}
caseSql += " END WHERE id IN ?"
args = append(args, ids)
return r.db.WithContext(ctx).Exec(caseSql, args...).Error
}
func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg string) error { func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg string) error {
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id). return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{

View File

@@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
return outKeys, paginationResultFromTotal(total, params), nil return outKeys, paginationResultFromTotal(total, params), nil
} }
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
}
ids := make([]int64, 0, len(apiKeyIDs))
err := r.db.WithContext(ctx).
Model(&apiKeyModel{}).
Where("user_id = ? AND id IN ?", userID, apiKeyIDs).
Pluck("id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) { func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
var count int64 var count int64
err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error

View File

@@ -1,11 +1,20 @@
package repository package repository
import "gorm.io/gorm" import (
"log"
"time"
"gorm.io/gorm"
)
// MaxExpiresAt is the maximum allowed expiration date for subscriptions (year 2099)
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
var maxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
// AutoMigrate runs schema migrations for all repository persistence models. // AutoMigrate runs schema migrations for all repository persistence models.
// Persistence models are defined within individual `*_repo.go` files. // Persistence models are defined within individual `*_repo.go` files.
func AutoMigrate(db *gorm.DB) error { func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate( err := db.AutoMigrate(
&userModel{}, &userModel{},
&apiKeyModel{}, &apiKeyModel{},
&groupModel{}, &groupModel{},
@@ -17,4 +26,24 @@ func AutoMigrate(db *gorm.DB) error {
&settingModel{}, &settingModel{},
&userSubscriptionModel{}, &userSubscriptionModel{},
) )
if err != nil {
return err
}
// 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败)
return fixInvalidExpiresAt(db)
}
// fixInvalidExpiresAt 修复 user_subscriptions 表中无效的过期时间
func fixInvalidExpiresAt(db *gorm.DB) error {
result := db.Model(&userSubscriptionModel{}).
Where("expires_at > ?", maxExpiresAt).
Update("expires_at", maxExpiresAt)
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
log.Printf("[AutoMigrate] Fixed %d subscriptions with invalid expires_at (year > 2099)", result.RowsAffected)
}
return nil
} }

View File

@@ -145,7 +145,7 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
return fullCode, nil return fullCode, nil
} }
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) { func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
client := s.clientFactory(proxyURL) client := s.clientFactory(proxyURL)
// Parse code which may contain state in format "authCode#state" // Parse code which may contain state in format "authCode#state"
@@ -168,6 +168,11 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
reqBody["state"] = codeState reqBody["state"] = codeState
} }
// Setup token requires longer expiration (1 year)
if isSetupToken {
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
}
reqBodyJSON, _ := json.Marshal(reqBody) reqBodyJSON, _ := json.Marshal(reqBody)
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL) log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON)) log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
@@ -199,16 +204,20 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) { func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
client := s.clientFactory(proxyURL) client := s.clientFactory(proxyURL)
formData := url.Values{} // 使用 JSON 格式(与 ExchangeCodeForToken 保持一致)
formData.Set("grant_type", "refresh_token") // Anthropic OAuth API 期望 JSON 格式的请求体
formData.Set("refresh_token", refreshToken) reqBody := map[string]any{
formData.Set("client_id", oauth.ClientID) "grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": oauth.ClientID,
}
var tokenResp oauth.TokenResponse var tokenResp oauth.TokenResponse
resp, err := client.R(). resp, err := client.R().
SetContext(ctx). SetContext(ctx).
SetFormDataFromValues(formData). SetHeader("Content-Type", "application/json").
SetBody(reqBody).
SetSuccessResult(&tokenResp). SetSuccessResult(&tokenResp).
Post(s.tokenURL) Post(s.tokenURL)

View File

@@ -6,7 +6,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -34,7 +33,6 @@ type requestCapture struct {
method string method string
cookies []*http.Cookie cookies []*http.Cookie
body []byte body []byte
formValues url.Values
bodyJSON map[string]any bodyJSON map[string]any
contentType string contentType string
} }
@@ -193,12 +191,13 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() { func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
tests := []struct { tests := []struct {
name string name string
handler http.HandlerFunc handler http.HandlerFunc
code string code string
wantErr bool isSetupToken bool
wantResp *oauth.TokenResponse wantErr bool
validate func(captured requestCapture) wantResp *oauth.TokenResponse
validate func(captured requestCapture)
}{ }{
{ {
name: "sends_state_when_embedded", name: "sends_state_when_embedded",
@@ -212,7 +211,8 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
Scope: "s", Scope: "s",
}) })
}, },
code: "AUTH#STATE2", code: "AUTH#STATE2",
isSetupToken: false,
wantResp: &oauth.TokenResponse{ wantResp: &oauth.TokenResponse{
AccessToken: "at", AccessToken: "at",
RefreshToken: "rt", RefreshToken: "rt",
@@ -225,6 +225,29 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"]) require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"]) require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"]) require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
// Regular OAuth should not include expires_in
require.Nil(s.T(), captured.bodyJSON["expires_in"], "regular OAuth should not include expires_in")
},
},
{
name: "setup_token_includes_expires_in",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
AccessToken: "at",
TokenType: "bearer",
ExpiresIn: 31536000,
})
},
code: "AUTH",
isSetupToken: true,
wantResp: &oauth.TokenResponse{
AccessToken: "at",
},
validate: func(captured requestCapture) {
// Setup token should include expires_in with 1 year value
require.Equal(s.T(), float64(31536000), captured.bodyJSON["expires_in"],
"setup token should include expires_in: 31536000")
}, },
}, },
{ {
@@ -233,8 +256,9 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("bad request")) _, _ = w.Write([]byte("bad request"))
}, },
code: "AUTH", code: "AUTH",
wantErr: true, isSetupToken: false,
wantErr: true,
}, },
} }
@@ -256,7 +280,7 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
s.client = client s.client = client
s.client.tokenURL = s.srv.URL s.client.tokenURL = s.srv.URL
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "") resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "", tt.isSetupToken)
if tt.wantErr { if tt.wantErr {
require.Error(s.T(), err) require.Error(s.T(), err)
@@ -282,24 +306,53 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
validate func(captured requestCapture) validate func(captured requestCapture)
}{ }{
{ {
name: "sends_form", name: "sends_json_format",
handler: func(w http.ResponseWriter, r *http.Request) { handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{AccessToken: "at2", TokenType: "bearer", ExpiresIn: 3600}) _ = json.NewEncoder(w).Encode(oauth.TokenResponse{
AccessToken: "new_access_token",
TokenType: "bearer",
ExpiresIn: 28800,
RefreshToken: "new_refresh_token",
Scope: "user:profile user:inference",
})
},
wantResp: &oauth.TokenResponse{
AccessToken: "new_access_token",
RefreshToken: "new_refresh_token",
}, },
wantResp: &oauth.TokenResponse{AccessToken: "at2"},
validate: func(captured requestCapture) { validate: func(captured requestCapture) {
require.Equal(s.T(), http.MethodPost, captured.method, "expected POST") require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
require.Equal(s.T(), "refresh_token", captured.formValues.Get("grant_type")) // 验证使用 JSON 格式(不是 form 格式)
require.Equal(s.T(), "rt", captured.formValues.Get("refresh_token")) require.True(s.T(), strings.HasPrefix(captured.contentType, "application/json"),
require.Equal(s.T(), oauth.ClientID, captured.formValues.Get("client_id")) "expected JSON content-type, got: %s", captured.contentType)
// 验证 JSON body 内容
require.Equal(s.T(), "refresh_token", captured.bodyJSON["grant_type"])
require.Equal(s.T(), "rt", captured.bodyJSON["refresh_token"])
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
},
},
{
name: "returns_new_refresh_token",
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
AccessToken: "at",
TokenType: "bearer",
ExpiresIn: 28800,
RefreshToken: "rotated_rt", // Anthropic rotates refresh tokens
})
},
wantResp: &oauth.TokenResponse{
AccessToken: "at",
RefreshToken: "rotated_rt",
}, },
}, },
{ {
name: "non_200_returns_error", name: "non_200_returns_error",
handler: func(w http.ResponseWriter, r *http.Request) { handler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("unauthorized")) _, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
}, },
wantErr: true, wantErr: true,
}, },
@@ -311,8 +364,9 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured.method = r.Method captured.method = r.Method
captured.contentType = r.Header.Get("Content-Type")
captured.body, _ = io.ReadAll(r.Body) captured.body, _ = io.ReadAll(r.Body)
captured.formValues, _ = url.ParseQuery(string(captured.body)) _ = json.Unmarshal(captured.body, &captured.bodyJSON)
tt.handler(w, r) tt.handler(w, r)
})) }))
defer s.srv.Close() defer s.srv.Close()
@@ -331,6 +385,7 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
require.NoError(s.T(), err) require.NoError(s.T(), err)
require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken) require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken)
require.Equal(s.T(), tt.wantResp.RefreshToken, resp.RefreshToken)
if tt.validate != nil { if tt.validate != nil {
tt.validate(captured) tt.validate(captured)
} }

View File

@@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
var stats DashboardStats var stats DashboardStats
today := timezone.Today() today := timezone.Today()
now := time.Now()
// 总用户数 // 合并用户统计查询
r.db.WithContext(ctx).Model(&userModel{}).Count(&stats.TotalUsers) var userStats struct {
TotalUsers int64 `gorm:"column:total_users"`
TodayNewUsers int64 `gorm:"column:today_new_users"`
ActiveUsers int64 `gorm:"column:active_users"`
}
if err := r.db.WithContext(ctx).Raw(`
SELECT
COUNT(*) as total_users,
COUNT(CASE WHEN created_at >= ? THEN 1 END) as today_new_users,
(SELECT COUNT(DISTINCT user_id) FROM usage_logs WHERE created_at >= ?) as active_users
FROM users
`, today, today).Scan(&userStats).Error; err != nil {
return nil, err
}
stats.TotalUsers = userStats.TotalUsers
stats.TodayNewUsers = userStats.TodayNewUsers
stats.ActiveUsers = userStats.ActiveUsers
// 今日新增用户数 // 合并API Key统计查询
r.db.WithContext(ctx).Model(&userModel{}). var apiKeyStats struct {
Where("created_at >= ?", today). TotalApiKeys int64 `gorm:"column:total_api_keys"`
Count(&stats.TodayNewUsers) ActiveApiKeys int64 `gorm:"column:active_api_keys"`
}
if err := r.db.WithContext(ctx).Raw(`
SELECT
COUNT(*) as total_api_keys,
COUNT(CASE WHEN status = ? THEN 1 END) as active_api_keys
FROM api_keys
`, service.StatusActive).Scan(&apiKeyStats).Error; err != nil {
return nil, err
}
stats.TotalApiKeys = apiKeyStats.TotalApiKeys
stats.ActiveApiKeys = apiKeyStats.ActiveApiKeys
// 今日活跃用户数 (今日有请求的用户) // 合并账户统计查询
r.db.WithContext(ctx).Model(&usageLogModel{}). var accountStats struct {
Distinct("user_id"). TotalAccounts int64 `gorm:"column:total_accounts"`
Where("created_at >= ?", today). NormalAccounts int64 `gorm:"column:normal_accounts"`
Count(&stats.ActiveUsers) ErrorAccounts int64 `gorm:"column:error_accounts"`
RateLimitAccounts int64 `gorm:"column:ratelimit_accounts"`
// 总 API Key 数 OverloadAccounts int64 `gorm:"column:overload_accounts"`
r.db.WithContext(ctx).Model(&apiKeyModel{}).Count(&stats.TotalApiKeys) }
if err := r.db.WithContext(ctx).Raw(`
// 活跃 API Key 数 SELECT
r.db.WithContext(ctx).Model(&apiKeyModel{}). COUNT(*) as total_accounts,
Where("status = ?", service.StatusActive). COUNT(CASE WHEN status = ? AND schedulable = true THEN 1 END) as normal_accounts,
Count(&stats.ActiveApiKeys) COUNT(CASE WHEN status = ? THEN 1 END) as error_accounts,
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
// 总账户数 COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
r.db.WithContext(ctx).Model(&accountModel{}).Count(&stats.TotalAccounts) FROM accounts
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
// 正常账户数 (schedulable=true, status=active) return nil, err
r.db.WithContext(ctx).Model(&accountModel{}). }
Where("status = ? AND schedulable = ?", service.StatusActive, true). stats.TotalAccounts = accountStats.TotalAccounts
Count(&stats.NormalAccounts) stats.NormalAccounts = accountStats.NormalAccounts
stats.ErrorAccounts = accountStats.ErrorAccounts
// 异常账户数 (status=error) stats.RateLimitAccounts = accountStats.RateLimitAccounts
r.db.WithContext(ctx).Model(&accountModel{}). stats.OverloadAccounts = accountStats.OverloadAccounts
Where("status = ?", service.StatusError).
Count(&stats.ErrorAccounts)
// 限流账户数
r.db.WithContext(ctx).Model(&accountModel{}).
Where("rate_limited_at IS NOT NULL AND rate_limit_reset_at > ?", time.Now()).
Count(&stats.RateLimitAccounts)
// 过载账户数
r.db.WithContext(ctx).Model(&accountModel{}).
Where("overload_until IS NOT NULL AND overload_until > ?", time.Now()).
Count(&stats.OverloadAccounts)
// 累计 Token 统计 // 累计 Token 统计
var totalStats struct { var totalStats struct {
@@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID
return usageLogModelsToService(logs), nil, err return usageLogModelsToService(logs), nil, err
} }
// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation
func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
var stats struct {
TotalRequests int64 `gorm:"column:total_requests"`
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
TotalCost float64 `gorm:"column:total_cost"`
TotalActualCost float64 `gorm:"column:total_actual_cost"`
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
}
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
Select(`
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
`).
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, startTime, endTime).
Scan(&stats).Error
if err != nil {
return nil, err
}
return &usagestats.UsageStats{
TotalRequests: stats.TotalRequests,
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheTokens: stats.TotalCacheTokens,
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
TotalCost: stats.TotalCost,
TotalActualCost: stats.TotalActualCost,
AverageDurationMs: stats.AverageDurationMs,
}, nil
}
// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation
func (r *usageLogRepository) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
var stats struct {
TotalRequests int64 `gorm:"column:total_requests"`
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
TotalCost float64 `gorm:"column:total_cost"`
TotalActualCost float64 `gorm:"column:total_actual_cost"`
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
}
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
Select(`
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
`).
Where("api_key_id = ? AND created_at >= ? AND created_at < ?", apiKeyID, startTime, endTime).
Scan(&stats).Error
if err != nil {
return nil, err
}
return &usagestats.UsageStats{
TotalRequests: stats.TotalRequests,
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheTokens: stats.TotalCacheTokens,
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
TotalCost: stats.TotalCost,
TotalActualCost: stats.TotalActualCost,
AverageDurationMs: stats.AverageDurationMs,
}, nil
}
func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) { func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
var logs []usageLogModel var logs []usageLogModel
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
@@ -631,6 +729,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
if filters.ApiKeyID > 0 { if filters.ApiKeyID > 0 {
db = db.Where("api_key_id = ?", filters.ApiKeyID) db = db.Where("api_key_id = ?", filters.ApiKeyID)
} }
if filters.AccountID > 0 {
db = db.Where("account_id = ?", filters.AccountID)
}
if filters.GroupID > 0 {
db = db.Where("group_id = ?", filters.GroupID)
}
if filters.Model != "" {
db = db.Where("model = ?", filters.Model)
}
if filters.Stream != nil {
db = db.Where("stream = ?", *filters.Stream)
}
if filters.BillingType != nil {
db = db.Where("billing_type = ?", *filters.BillingType)
}
if filters.StartTime != nil { if filters.StartTime != nil {
db = db.Where("created_at >= ?", *filters.StartTime) db = db.Where("created_at >= ?", *filters.StartTime)
} }
@@ -642,8 +755,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
return nil, nil, err return nil, nil, err
} }
// Preload user and api_key for display // Preload user, api_key, account, and group for display
if err := db.Preload("User").Preload("ApiKey"). if err := db.Preload("User").Preload("ApiKey").Preload("Account").Preload("Group").
Offset(params.Offset()).Limit(params.Limit()). Offset(params.Offset()).Limit(params.Limit()).
Order("id DESC").Find(&logs).Error; err != nil { Order("id DESC").Find(&logs).Error; err != nil {
return nil, nil, err return nil, nil, err

View File

@@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params
}, nil }, nil
} }
func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
}
seen := make(map[int64]struct{}, len(apiKeyIDs))
out := make([]int64, 0, len(apiKeyIDs))
for _, id := range apiKeyIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
key, ok := r.byID[id]
if ok && key.UserID == userID {
out = append(out, id)
}
}
return out, nil
}
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
var count int64 var count int64
for _, key := range r.byID { for _, key := range r.byID {
@@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
logs := r.userLogs[userID]
if len(logs) == 0 {
return &usagestats.UsageStats{}, nil
}
var totalRequests int64
var totalInputTokens int64
var totalOutputTokens int64
var totalCacheTokens int64
var totalCost float64
var totalActualCost float64
var totalDuration int64
var durationCount int64
for _, log := range logs {
totalRequests++
totalInputTokens += int64(log.InputTokens)
totalOutputTokens += int64(log.OutputTokens)
totalCacheTokens += int64(log.CacheCreationTokens + log.CacheReadTokens)
totalCost += log.TotalCost
totalActualCost += log.ActualCost
if log.DurationMs != nil {
totalDuration += int64(*log.DurationMs)
durationCount++
}
}
var avgDuration float64
if durationCount > 0 {
avgDuration = float64(totalDuration) / float64(durationCount)
}
return &usagestats.UsageStats{
TotalRequests: totalRequests,
TotalInputTokens: totalInputTokens,
TotalOutputTokens: totalOutputTokens,
TotalCacheTokens: totalCacheTokens,
TotalTokens: totalInputTokens + totalOutputTokens + totalCacheTokens,
TotalCost: totalCost,
TotalActualCost: totalActualCost,
AverageDurationMs: avgDuration,
}, nil
}
func (r *stubUsageLogRepo) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
return nil, errors.New("not implemented")
}
func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) { func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
@@ -924,7 +992,40 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
} }
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented") logs := r.userLogs[filters.UserID]
// Apply filters
var filtered []service.UsageLog
for _, log := range logs {
// Apply ApiKeyID filter
if filters.ApiKeyID > 0 && log.ApiKeyID != filters.ApiKeyID {
continue
}
// Apply Model filter
if filters.Model != "" && log.Model != filters.Model {
continue
}
// Apply Stream filter
if filters.Stream != nil && log.Stream != *filters.Stream {
continue
}
// Apply BillingType filter
if filters.BillingType != nil && log.BillingType != *filters.BillingType {
continue
}
// Apply time range filters
if filters.StartTime != nil && log.CreatedAt.Before(*filters.StartTime) {
continue
}
if filters.EndTime != nil && log.CreatedAt.After(*filters.EndTime) {
continue
}
filtered = append(filtered, log)
}
total := int64(len(filtered))
out := paginateLogs(filtered, params)
return out, paginationResult(total, params), nil
} }
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) { func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {

View File

@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
} }
c.Set(string(ContextKeyApiKey), apiKey) c.Set(string(ContextKeyApiKey), apiKey)
c.Set(string(ContextKeyUser), apiKey.User) c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
c.Next() c.Next()
} }
} }

View File

@@ -1,6 +1,10 @@
package service package service
import "time" import (
"encoding/json"
"strconv"
"time"
)
type Account struct { type Account struct {
ID int64 ID int64
@@ -82,12 +86,28 @@ func (a *Account) GetCredential(key string) string {
if a.Credentials == nil { if a.Credentials == nil {
return "" return ""
} }
if v, ok := a.Credentials[key]; ok { v, ok := a.Credentials[key]
if s, ok := v.(string); ok { if !ok || v == nil {
return s return ""
} }
// 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
switch val := v.(type) {
case string:
return val
case json.Number:
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
return val.String()
case float64:
// JSON 解析后数字默认为 float64
return strconv.FormatInt(int64(val), 10)
case int64:
return strconv.FormatInt(val, 10)
case int:
return strconv.Itoa(val)
default:
return ""
} }
return ""
} }
func (a *Account) GetModelMapping() map[string]string { func (a *Account) GetModelMapping() map[string]string {

View File

@@ -29,6 +29,7 @@ type AccountRepository interface {
ListByPlatform(ctx context.Context, platform string) ([]Account, error) ListByPlatform(ctx context.Context, platform string) ([]Account, error)
UpdateLastUsed(ctx context.Context, id int64) error UpdateLastUsed(ctx context.Context, id int64) error
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
SetError(ctx context.Context, id int64, errorMsg string) error SetError(ctx context.Context, id int64, errorMsg string) error
SetSchedulable(ctx context.Context, id int64, schedulable bool) error SetSchedulable(ctx context.Context, id int64, schedulable bool) error
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
@@ -208,20 +209,23 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
account.Status = *req.Status account.Status = *req.Status
} }
if err := s.accountRepo.Update(ctx, account); err != nil { // 先验证分组是否存在(在任何写操作之前)
return nil, fmt.Errorf("update account: %w", err)
}
// 更新分组绑定
if req.GroupIDs != nil { if req.GroupIDs != nil {
// 验证分组是否存在
for _, groupID := range *req.GroupIDs { for _, groupID := range *req.GroupIDs {
_, err := s.groupRepo.GetByID(ctx, groupID) _, err := s.groupRepo.GetByID(ctx, groupID)
if err != nil { if err != nil {
return nil, fmt.Errorf("get group: %w", err) return nil, fmt.Errorf("get group: %w", err)
} }
} }
}
// 执行更新
if err := s.accountRepo.Update(ctx, account); err != nil {
return nil, fmt.Errorf("update account: %w", err)
}
// 绑定分组
if req.GroupIDs != nil {
if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil { if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil {
return nil, fmt.Errorf("bind groups: %w", err) return nil, fmt.Errorf("bind groups: %w", err)
} }

View File

@@ -48,6 +48,10 @@ type UsageLogRepository interface {
// Account stats // Account stats
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
// Aggregated stats (optimized)
GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
} }
// usageCache 用于缓存usage数据 // usageCache 用于缓存usage数据

View File

@@ -652,11 +652,20 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
account.Status = input.Status account.Status = input.Status
} }
// 先验证分组是否存在(在任何写操作之前)
if input.GroupIDs != nil {
for _, groupID := range *input.GroupIDs {
if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil {
return nil, fmt.Errorf("get group: %w", err)
}
}
}
if err := s.accountRepo.Update(ctx, account); err != nil { if err := s.accountRepo.Update(ctx, account); err != nil {
return nil, err return nil, err
} }
// 更新分组绑定 // 绑定分组
if input.GroupIDs != nil { if input.GroupIDs != nil {
if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil {
return nil, err return nil, err

View File

@@ -34,6 +34,7 @@ type ApiKeyRepository interface {
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
CountByUserID(ctx context.Context, userID int64) (int64, error) CountByUserID(ctx context.Context, userID int64) (int64, error)
ExistsByKey(ctx context.Context, key string) (bool, error) ExistsByKey(ctx context.Context, key string) (bool, error)
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
@@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio
return keys, pagination, nil return keys, pagination, nil
} }
func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
}
validIDs, err := s.apiKeyRepo.VerifyOwnership(ctx, userID, apiKeyIDs)
if err != nil {
return nil, fmt.Errorf("verify api key ownership: %w", err)
}
return validIDs, nil
}
// GetByID 根据ID获取API Key // GetByID 根据ID获取API Key
func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) { func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) {
apiKey, err := s.apiKeyRepo.GetByID(ctx, id) apiKey, err := s.apiKeyRepo.GetByID(ctx, id)

View File

@@ -0,0 +1,76 @@
package service
import (
"context"
"log"
"sync"
"time"
)
// DeferredService provides deferred batch update functionality
type DeferredService struct {
accountRepo AccountRepository
timingWheel *TimingWheelService
interval time.Duration
lastUsedUpdates sync.Map
}
// NewDeferredService creates a new DeferredService instance
func NewDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService, interval time.Duration) *DeferredService {
return &DeferredService{
accountRepo: accountRepo,
timingWheel: timingWheel,
interval: interval,
}
}
// Start starts the deferred service
func (s *DeferredService) Start() {
s.timingWheel.ScheduleRecurring("deferred:last_used", s.interval, s.flushLastUsed)
log.Printf("[DeferredService] Started (interval: %v)", s.interval)
}
// Stop stops the deferred service
func (s *DeferredService) Stop() {
s.timingWheel.Cancel("deferred:last_used")
s.flushLastUsed()
log.Printf("[DeferredService] Service stopped")
}
func (s *DeferredService) ScheduleLastUsedUpdate(accountID int64) {
s.lastUsedUpdates.Store(accountID, time.Now())
}
func (s *DeferredService) flushLastUsed() {
updates := make(map[int64]time.Time)
s.lastUsedUpdates.Range(func(key, value any) bool {
id, ok := key.(int64)
if !ok {
return true
}
ts, ok := value.(time.Time)
if !ok {
return true
}
updates[id] = ts
s.lastUsedUpdates.Delete(key)
return true
})
if len(updates) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.accountRepo.BatchUpdateLastUsed(ctx, updates); err != nil {
log.Printf("[DeferredService] BatchUpdateLastUsed failed (%d accounts): %v", len(updates), err)
for id, ts := range updates {
s.lastUsedUpdates.Store(id, ts)
}
} else {
log.Printf("[DeferredService] BatchUpdateLastUsed flushed %d accounts", len(updates))
}
}

View File

@@ -81,6 +81,15 @@ type ForwardResult struct {
FirstTokenMs *int // 首字时间(流式请求) FirstTokenMs *int // 首字时间(流式请求)
} }
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
type UpstreamFailoverError struct {
StatusCode int
}
func (e *UpstreamFailoverError) Error() string {
return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode)
}
// GatewayService handles API gateway operations // GatewayService handles API gateway operations
type GatewayService struct { type GatewayService struct {
accountRepo AccountRepository accountRepo AccountRepository
@@ -94,6 +103,7 @@ type GatewayService struct {
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
identityService *IdentityService identityService *IdentityService
httpUpstream HTTPUpstream httpUpstream HTTPUpstream
deferredService *DeferredService
} }
// NewGatewayService creates a new GatewayService // NewGatewayService creates a new GatewayService
@@ -109,6 +119,7 @@ func NewGatewayService(
billingCacheService *BillingCacheService, billingCacheService *BillingCacheService,
identityService *IdentityService, identityService *IdentityService,
httpUpstream HTTPUpstream, httpUpstream HTTPUpstream,
deferredService *DeferredService,
) *GatewayService { ) *GatewayService {
return &GatewayService{ return &GatewayService{
accountRepo: accountRepo, accountRepo: accountRepo,
@@ -122,6 +133,7 @@ func NewGatewayService(
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
identityService: identityService, identityService: identityService,
httpUpstream: httpUpstream, httpUpstream: httpUpstream,
deferredService: deferredService,
} }
} }
@@ -274,19 +286,26 @@ func (s *GatewayService) SelectAccount(ctx context.Context, groupID *int64, sess
// SelectAccountForModel 选择支持指定模型的账号(粘性会话+优先级+模型映射) // SelectAccountForModel 选择支持指定模型的账号(粘性会话+优先级+模型映射)
func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) { func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
}
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
// 1. 查询粘性会话 // 1. 查询粘性会话
if sessionHash != "" { if sessionHash != "" {
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash) accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
if err == nil && accountID > 0 { if err == nil && accountID > 0 {
account, err := s.accountRepo.GetByID(ctx, accountID) if _, excluded := excludedIDs[accountID]; !excluded {
// 使用IsSchedulable代替IsActive确保限流/过载账号不会被选中 account, err := s.accountRepo.GetByID(ctx, accountID)
// 同时检查模型支持 // 使用IsSchedulable代替IsActive确保限流/过载账号不会被选中
if err == nil && account.IsSchedulable() && (requestedModel == "" || account.IsModelSupported(requestedModel)) { // 同时检查模型支持
// 续期粘性会话 if err == nil && account.IsSchedulable() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { // 续期粘性会话
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
}
return account, nil
} }
return account, nil
} }
} }
} }
@@ -307,6 +326,9 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
var selected *Account var selected *Account
for i := range accounts { for i := range accounts {
acc := &accounts[i] acc := &accounts[i]
if _, excluded := excludedIDs[acc.ID]; excluded {
continue
}
// 检查模型支持 // 检查模型支持
if requestedModel != "" && !acc.IsModelSupported(requestedModel) { if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
continue continue
@@ -394,6 +416,16 @@ func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode i
return !account.ShouldHandleErrorCode(statusCode) return !account.ShouldHandleErrorCode(statusCode)
} }
// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover.
func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
switch statusCode {
case 401, 403, 429, 529:
return true
default:
return statusCode >= 500
}
}
// Forward 转发请求到Claude API // Forward 转发请求到Claude API
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
startTime := time.Now() startTime := time.Now()
@@ -478,9 +510,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 处理重试耗尽的情况 // 处理重试耗尽的情况
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) { if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
if s.shouldFailoverUpstreamError(resp.StatusCode) {
s.handleRetryExhaustedSideEffects(ctx, resp, account)
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return s.handleRetryExhaustedError(ctx, resp, c, account) return s.handleRetryExhaustedError(ctx, resp, c, account)
} }
// 处理可切换账号的错误
if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
s.handleFailoverSideEffects(ctx, resp, account)
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
// 处理错误响应(不可重试的错误) // 处理错误响应(不可重试的错误)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return s.handleErrorResponse(ctx, resp, c, account) return s.handleErrorResponse(ctx, resp, c, account)
@@ -692,10 +734,7 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
} }
// handleRetryExhaustedError 处理重试耗尽后的错误 func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) {
// OAuth 403标记账号异常
// API Key 未配置错误码:仅返回错误,不标记账号
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
statusCode := resp.StatusCode statusCode := resp.StatusCode
@@ -707,6 +746,18 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
// API Key 未配置错误码:不标记账号状态 // API Key 未配置错误码:不标记账号状态
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries) log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries)
} }
}
func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
body, _ := io.ReadAll(resp.Body)
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
}
// handleRetryExhaustedError 处理重试耗尽后的错误
// OAuth 403标记账号异常
// API Key 未配置错误码:仅返回错误,不标记账号
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
s.handleRetryExhaustedSideEffects(ctx, resp, account)
// 返回统一的重试耗尽错误响应 // 返回统一的重试耗尽错误响应
c.JSON(http.StatusBadGateway, gin.H{ c.JSON(http.StatusBadGateway, gin.H{
@@ -717,7 +768,7 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
}, },
}) })
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", statusCode) return nil, fmt.Errorf("upstream error: %d (retries exhausted)", resp.StatusCode)
} }
// streamingResult 流式响应结果 // streamingResult 流式响应结果
@@ -1047,10 +1098,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
} }
} }
// 更新账号最后使用时间 // Schedule batch update for account last_used_at
if err := s.accountRepo.UpdateLastUsed(ctx, account.ID); err != nil { s.deferredService.ScheduleLastUsedUpdate(account.ID)
log.Printf("Update last used failed: %v", err)
}
return nil return nil
} }

View File

@@ -62,14 +62,20 @@ func (s *GeminiMessagesCompatService) GetTokenProvider() *GeminiTokenProvider {
} }
func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) { func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
}
func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
cacheKey := "gemini:" + sessionHash cacheKey := "gemini:" + sessionHash
if sessionHash != "" { if sessionHash != "" {
accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey) accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey)
if err == nil && accountID > 0 { if err == nil && accountID > 0 {
account, err := s.accountRepo.GetByID(ctx, accountID) if _, excluded := excludedIDs[accountID]; !excluded {
if err == nil && account.IsSchedulable() && account.Platform == PlatformGemini && (requestedModel == "" || account.IsModelSupported(requestedModel)) { account, err := s.accountRepo.GetByID(ctx, accountID)
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL) if err == nil && account.IsSchedulable() && account.Platform == PlatformGemini && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
return account, nil _ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
return account, nil
}
} }
} }
} }
@@ -88,6 +94,9 @@ func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context,
var selected *Account var selected *Account
for i := range accounts { for i := range accounts {
acc := &accounts[i] acc := &accounts[i]
if _, excluded := excludedIDs[acc.ID]; excluded {
continue
}
if requestedModel != "" && !acc.IsModelSupported(requestedModel) { if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
continue continue
} }
@@ -425,6 +434,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return nil, s.writeGeminiMappedError(c, resp.StatusCode, respBody) return nil, s.writeGeminiMappedError(c, resp.StatusCode, respBody)
} }
@@ -724,6 +736,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}, nil }, nil
} }
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
respBody = unwrapIfNeeded(isOAuth, respBody) respBody = unwrapIfNeeded(isOAuth, respBody)
contentType := resp.Header.Get("Content-Type") contentType := resp.Header.Get("Content-Type")
if contentType == "" { if contentType == "" {
@@ -795,6 +811,15 @@ func (s *GeminiMessagesCompatService) shouldRetryGeminiUpstreamError(account *Ac
} }
} }
func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCode int) bool {
switch statusCode {
case 401, 403, 429, 529:
return true
default:
return statusCode >= 500
}
}
func sleepGeminiBackoff(attempt int) { func sleepGeminiBackoff(attempt int) {
delay := geminiRetryBaseDelay * time.Duration(1<<uint(attempt-1)) delay := geminiRetryBaseDelay * time.Duration(1<<uint(attempt-1))
if delay > geminiRetryMaxDelay { if delay > geminiRetryMaxDelay {

View File

@@ -20,7 +20,7 @@ type OpenAIOAuthClient interface {
type ClaudeOAuthClient interface { type ClaudeOAuthClient interface {
GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
} }
@@ -142,8 +142,11 @@ func (s *OAuthService) ExchangeCode(ctx context.Context, input *ExchangeCodeInpu
} }
} }
// Determine if this is a setup token (scope is inference only)
isSetupToken := session.Scope == oauth.ScopeInference
// Exchange code for token // Exchange code for token
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL) tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -172,10 +175,12 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
} }
} }
// Determine scope // Determine scope and if this is a setup token
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference) scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
isSetupToken := false
if input.Scope == "inference" { if input.Scope == "inference" {
scope = oauth.ScopeInference scope = oauth.ScopeInference
isSetupToken = true
} }
// Step 1: Get organization info using sessionKey // Step 1: Get organization info using sessionKey
@@ -203,7 +208,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
} }
// Step 4: Exchange code for token // Step 4: Exchange code for token
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL) tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err) return nil, fmt.Errorf("failed to exchange code: %w", err)
} }
@@ -228,8 +233,8 @@ func (s *OAuthService) getAuthorizationCode(ctx context.Context, sessionKey, org
} }
// exchangeCodeForToken exchanges authorization code for tokens // exchangeCodeForToken exchanges authorization code for tokens
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*TokenInfo, error) { func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*TokenInfo, error) {
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL) tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -83,6 +83,7 @@ type OpenAIGatewayService struct {
rateLimitService *RateLimitService rateLimitService *RateLimitService
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
httpUpstream HTTPUpstream httpUpstream HTTPUpstream
deferredService *DeferredService
} }
// NewOpenAIGatewayService creates a new OpenAIGatewayService // NewOpenAIGatewayService creates a new OpenAIGatewayService
@@ -97,6 +98,7 @@ func NewOpenAIGatewayService(
rateLimitService *RateLimitService, rateLimitService *RateLimitService,
billingCacheService *BillingCacheService, billingCacheService *BillingCacheService,
httpUpstream HTTPUpstream, httpUpstream HTTPUpstream,
deferredService *DeferredService,
) *OpenAIGatewayService { ) *OpenAIGatewayService {
return &OpenAIGatewayService{ return &OpenAIGatewayService{
accountRepo: accountRepo, accountRepo: accountRepo,
@@ -109,6 +111,7 @@ func NewOpenAIGatewayService(
rateLimitService: rateLimitService, rateLimitService: rateLimitService,
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
httpUpstream: httpUpstream, httpUpstream: httpUpstream,
deferredService: deferredService,
} }
} }
@@ -129,15 +132,22 @@ func (s *OpenAIGatewayService) SelectAccount(ctx context.Context, groupID *int64
// SelectAccountForModel selects an account supporting the requested model // SelectAccountForModel selects an account supporting the requested model
func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) { func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
}
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
// 1. Check sticky session // 1. Check sticky session
if sessionHash != "" { if sessionHash != "" {
accountID, err := s.cache.GetSessionAccountID(ctx, "openai:"+sessionHash) accountID, err := s.cache.GetSessionAccountID(ctx, "openai:"+sessionHash)
if err == nil && accountID > 0 { if err == nil && accountID > 0 {
account, err := s.accountRepo.GetByID(ctx, accountID) if _, excluded := excludedIDs[accountID]; !excluded {
if err == nil && account.IsSchedulable() && account.IsOpenAI() && (requestedModel == "" || account.IsModelSupported(requestedModel)) { account, err := s.accountRepo.GetByID(ctx, accountID)
// Refresh sticky session TTL if err == nil && account.IsSchedulable() && account.IsOpenAI() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
_ = s.cache.RefreshSessionTTL(ctx, "openai:"+sessionHash, openaiStickySessionTTL) // Refresh sticky session TTL
return account, nil _ = s.cache.RefreshSessionTTL(ctx, "openai:"+sessionHash, openaiStickySessionTTL)
return account, nil
}
} }
} }
} }
@@ -158,6 +168,9 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI
var selected *Account var selected *Account
for i := range accounts { for i := range accounts {
acc := &accounts[i] acc := &accounts[i]
if _, excluded := excludedIDs[acc.ID]; excluded {
continue
}
// Check model support // Check model support
if requestedModel != "" && !acc.IsModelSupported(requestedModel) { if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
continue continue
@@ -221,6 +234,20 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco
} }
} }
func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
switch statusCode {
case 401, 403, 429, 529:
return true
default:
return statusCode >= 500
}
}
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
body, _ := io.ReadAll(resp.Body)
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
}
// Forward forwards request to OpenAI API // Forward forwards request to OpenAI API
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
startTime := time.Now() startTime := time.Now()
@@ -288,6 +315,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
// Handle error response // Handle error response
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
if s.shouldFailoverUpstreamError(resp.StatusCode) {
s.handleFailoverSideEffects(ctx, resp, account)
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
}
return s.handleErrorResponse(ctx, resp, c, account) return s.handleErrorResponse(ctx, resp, c, account)
} }
@@ -744,8 +775,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
} }
} }
// Update account last used // Schedule batch update for account last_used_at
_ = s.accountRepo.UpdateLastUsed(ctx, account.ID) s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil return nil
} }

View File

@@ -10,6 +10,13 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
// MaxExpiresAt is the maximum allowed expiration date (year 2099)
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
// MaxValidityDays is the maximum allowed validity days for subscriptions (100 years)
const MaxValidityDays = 36500
var ( var (
ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found") ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired") ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
@@ -111,6 +118,9 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
if validityDays <= 0 { if validityDays <= 0 {
validityDays = 30 validityDays = 30
} }
if validityDays > MaxValidityDays {
validityDays = MaxValidityDays
}
// 已有订阅,执行续期 // 已有订阅,执行续期
if existingSub != nil { if existingSub != nil {
@@ -125,6 +135,11 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
newExpiresAt = now.AddDate(0, 0, validityDays) newExpiresAt = now.AddDate(0, 0, validityDays)
} }
// 确保不超过最大过期时间
if newExpiresAt.After(MaxExpiresAt) {
newExpiresAt = MaxExpiresAt
}
// 更新过期时间 // 更新过期时间
if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil { if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil {
return nil, false, fmt.Errorf("extend subscription: %w", err) return nil, false, fmt.Errorf("extend subscription: %w", err)
@@ -189,13 +204,21 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
if validityDays <= 0 { if validityDays <= 0 {
validityDays = 30 validityDays = 30
} }
if validityDays > MaxValidityDays {
validityDays = MaxValidityDays
}
now := time.Now() now := time.Now()
expiresAt := now.AddDate(0, 0, validityDays)
if expiresAt.After(MaxExpiresAt) {
expiresAt = MaxExpiresAt
}
sub := &UserSubscription{ sub := &UserSubscription{
UserID: input.UserID, UserID: input.UserID,
GroupID: input.GroupID, GroupID: input.GroupID,
StartsAt: now, StartsAt: now,
ExpiresAt: now.AddDate(0, 0, validityDays), ExpiresAt: expiresAt,
Status: SubscriptionStatusActive, Status: SubscriptionStatusActive,
AssignedAt: now, AssignedAt: now,
Notes: input.Notes, Notes: input.Notes,
@@ -291,8 +314,17 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
return nil, ErrSubscriptionNotFound return nil, ErrSubscriptionNotFound
} }
// 限制延长天数
if days > MaxValidityDays {
days = MaxValidityDays
}
// 计算新的过期时间 // 计算新的过期时间
newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days) newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days)
if newExpiresAt.After(MaxExpiresAt) {
newExpiresAt = MaxExpiresAt
}
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil { if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,63 @@
package service
import (
"log"
"sync"
"time"
"github.com/zeromicro/go-zero/core/collection"
)
// TimingWheelService wraps go-zero's TimingWheel for task scheduling
type TimingWheelService struct {
tw *collection.TimingWheel
stopOnce sync.Once
}
// NewTimingWheelService creates a new TimingWheelService instance
func NewTimingWheelService() *TimingWheelService {
// 1 second tick, 3600 slots = supports up to 1 hour delay
// execute function: runs func() type tasks
tw, err := collection.NewTimingWheel(1*time.Second, 3600, func(key, value any) {
if fn, ok := value.(func()); ok {
fn()
}
})
if err != nil {
panic(err)
}
return &TimingWheelService{tw: tw}
}
// Start starts the timing wheel
func (s *TimingWheelService) Start() {
log.Println("[TimingWheel] Started (auto-start by go-zero)")
}
// Stop stops the timing wheel
func (s *TimingWheelService) Stop() {
s.stopOnce.Do(func() {
s.tw.Stop()
log.Println("[TimingWheel] Stopped")
})
}
// Schedule schedules a one-time task
func (s *TimingWheelService) Schedule(name string, delay time.Duration, fn func()) {
_ = s.tw.SetTimer(name, fn, delay)
}
// ScheduleRecurring schedules a recurring task
func (s *TimingWheelService) ScheduleRecurring(name string, interval time.Duration, fn func()) {
var schedule func()
schedule = func() {
fn()
_ = s.tw.SetTimer(name, schedule, interval)
}
_ = s.tw.SetTimer(name, schedule, interval)
}
// Cancel cancels a scheduled task
func (s *TimingWheelService) Cancel(name string) {
_ = s.tw.RemoveTimer(name)
}

View File

@@ -106,6 +106,9 @@ func (s *TokenRefreshService) processRefresh() {
return return
} }
totalAccounts := len(accounts)
oauthAccounts := 0 // 可刷新的OAuth账号数
needsRefresh := 0 // 需要刷新的账号数
refreshed, failed := 0, 0 refreshed, failed := 0, 0
for i := range accounts { for i := range accounts {
@@ -117,11 +120,15 @@ func (s *TokenRefreshService) processRefresh() {
continue continue
} }
oauthAccounts++
// 检查是否需要刷新 // 检查是否需要刷新
if !refresher.NeedsRefresh(account, refreshWindow) { if !refresher.NeedsRefresh(account, refreshWindow) {
continue break // 不需要刷新,跳过
} }
needsRefresh++
// 执行刷新 // 执行刷新
if err := s.refreshWithRetry(ctx, account, refresher); err != nil { if err := s.refreshWithRetry(ctx, account, refresher); err != nil {
log.Printf("[TokenRefresh] Account %d (%s) failed: %v", account.ID, account.Name, err) log.Printf("[TokenRefresh] Account %d (%s) failed: %v", account.ID, account.Name, err)
@@ -136,9 +143,9 @@ func (s *TokenRefreshService) processRefresh() {
} }
} }
if refreshed > 0 || failed > 0 { // 始终打印周期日志,便于跟踪服务运行状态
log.Printf("[TokenRefresh] Cycle complete: %d refreshed, %d failed", refreshed, failed) log.Printf("[TokenRefresh] Cycle complete: total=%d, oauth=%d, needs_refresh=%d, refreshed=%d, failed=%d",
} totalAccounts, oauthAccounts, needsRefresh, refreshed, failed)
} }
// listActiveAccounts 获取所有active状态的账号 // listActiveAccounts 获取所有active状态的账号

View File

@@ -43,18 +43,17 @@ func (r *ClaudeTokenRefresher) CanRefresh(account *Account) bool {
// NeedsRefresh 检查token是否需要刷新 // NeedsRefresh 检查token是否需要刷新
// 基于 expires_at 字段判断是否在刷新窗口内 // 基于 expires_at 字段判断是否在刷新窗口内
func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
expiresAtStr := account.GetCredential("expires_at") s := account.GetCredential("expires_at")
if expiresAtStr == "" { if s == "" {
return false return false
} }
expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64) expiresAt, err := strconv.ParseInt(s, 10, 64)
if err != nil { if err != nil {
return false return false
} }
expiryTime := time.Unix(expiresAt, 0) return time.Until(time.Unix(expiresAt, 0)) < refreshWindow
return time.Until(expiryTime) < refreshWindow
} }
// Refresh 执行token刷新 // Refresh 执行token刷新

View File

@@ -0,0 +1,214 @@
//go:build unit
package service
import (
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestClaudeTokenRefresher_NeedsRefresh(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
tests := []struct {
name string
credentials map[string]any
wantRefresh bool
}{
{
name: "expires_at as string - expired",
credentials: map[string]any{
"expires_at": "1000", // 1970-01-01 00:16:40 UTC, 已过期
},
wantRefresh: true,
},
{
name: "expires_at as float64 - expired",
credentials: map[string]any{
"expires_at": float64(1000), // 数字类型,已过期
},
wantRefresh: true,
},
{
name: "expires_at as string - far future",
credentials: map[string]any{
"expires_at": "9999999999", // 远未来
},
wantRefresh: false,
},
{
name: "expires_at as float64 - far future",
credentials: map[string]any{
"expires_at": float64(9999999999), // 远未来,数字类型
},
wantRefresh: false,
},
{
name: "expires_at missing",
credentials: map[string]any{},
wantRefresh: false,
},
{
name: "expires_at is nil",
credentials: map[string]any{
"expires_at": nil,
},
wantRefresh: false,
},
{
name: "expires_at is invalid string",
credentials: map[string]any{
"expires_at": "invalid",
},
wantRefresh: false,
},
{
name: "credentials is nil",
credentials: nil,
wantRefresh: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.Equal(t, tt.wantRefresh, got)
})
}
}
func TestClaudeTokenRefresher_NeedsRefresh_WithinWindow(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
// 设置一个在刷新窗口内的时间(当前时间 + 15分钟
expiresAt := time.Now().Add(15 * time.Minute).Unix()
tests := []struct {
name string
credentials map[string]any
}{
{
name: "string type - within refresh window",
credentials: map[string]any{
"expires_at": strconv.FormatInt(expiresAt, 10),
},
},
{
name: "float64 type - within refresh window",
credentials: map[string]any{
"expires_at": float64(expiresAt),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.True(t, got, "should need refresh when within window")
})
}
}
func TestClaudeTokenRefresher_NeedsRefresh_OutsideWindow(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
refreshWindow := 30 * time.Minute
// 设置一个在刷新窗口外的时间(当前时间 + 1小时
expiresAt := time.Now().Add(1 * time.Hour).Unix()
tests := []struct {
name string
credentials map[string]any
}{
{
name: "string type - outside refresh window",
credentials: map[string]any{
"expires_at": strconv.FormatInt(expiresAt, 10),
},
},
{
name: "float64 type - outside refresh window",
credentials: map[string]any{
"expires_at": float64(expiresAt),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Credentials: tt.credentials,
}
got := refresher.NeedsRefresh(account, refreshWindow)
require.False(t, got, "should not need refresh when outside window")
})
}
}
func TestClaudeTokenRefresher_CanRefresh(t *testing.T) {
refresher := &ClaudeTokenRefresher{}
tests := []struct {
name string
platform string
accType string
want bool
}{
{
name: "anthropic oauth - can refresh",
platform: PlatformAnthropic,
accType: AccountTypeOAuth,
want: true,
},
{
name: "anthropic api-key - cannot refresh",
platform: PlatformAnthropic,
accType: AccountTypeApiKey,
want: false,
},
{
name: "openai oauth - cannot refresh",
platform: PlatformOpenAI,
accType: AccountTypeOAuth,
want: false,
},
{
name: "gemini oauth - cannot refresh",
platform: PlatformGemini,
accType: AccountTypeOAuth,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &Account{
Platform: tt.platform,
Type: tt.accType,
}
got := refresher.CanRefresh(account)
require.Equal(t, tt.want, got)
})
}
}

View File

@@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param
// GetStatsByUser 获取用户的使用统计 // GetStatsByUser 获取用户的使用统计
func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) { func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) {
logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, startTime, endTime) stats, err := s.usageRepo.GetUserStatsAggregated(ctx, userID, startTime, endTime)
if err != nil { if err != nil {
return nil, fmt.Errorf("list usage logs: %w", err) return nil, fmt.Errorf("get user stats: %w", err)
} }
return s.calculateStats(logs), nil return &UsageStats{
TotalRequests: stats.TotalRequests,
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheTokens: stats.TotalCacheTokens,
TotalTokens: stats.TotalTokens,
TotalCost: stats.TotalCost,
TotalActualCost: stats.TotalActualCost,
AverageDurationMs: stats.AverageDurationMs,
}, nil
} }
// GetStatsByApiKey 获取API Key的使用统计 // GetStatsByApiKey 获取API Key的使用统计
func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) { func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) {
logs, _, err := s.usageRepo.ListByApiKeyAndTimeRange(ctx, apiKeyID, startTime, endTime) stats, err := s.usageRepo.GetApiKeyStatsAggregated(ctx, apiKeyID, startTime, endTime)
if err != nil { if err != nil {
return nil, fmt.Errorf("list usage logs: %w", err) return nil, fmt.Errorf("get api key stats: %w", err)
} }
return s.calculateStats(logs), nil return &UsageStats{
TotalRequests: stats.TotalRequests,
TotalInputTokens: stats.TotalInputTokens,
TotalOutputTokens: stats.TotalOutputTokens,
TotalCacheTokens: stats.TotalCacheTokens,
TotalTokens: stats.TotalTokens,
TotalCost: stats.TotalCost,
TotalActualCost: stats.TotalActualCost,
AverageDurationMs: stats.AverageDurationMs,
}, nil
} }
// GetStatsByAccount 获取账号的使用统计 // GetStatsByAccount 获取账号的使用统计

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/google/wire" "github.com/google/wire"
) )
@@ -44,6 +46,20 @@ func ProvideTokenRefreshService(
return svc return svc
} }
// ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() *TimingWheelService {
svc := NewTimingWheelService()
svc.Start()
return svc
}
// ProvideDeferredService creates and starts DeferredService
func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService {
svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second)
svc.Start()
return svc
}
// ProviderSet is the Wire provider set for all services // ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet( var ProviderSet = wire.NewSet(
// Core services // Core services
@@ -80,4 +96,6 @@ var ProviderSet = wire.NewSet(
NewCRSSyncService, NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
ProvideTimingWheelService,
ProvideDeferredService,
) )

View File

@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") || strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/setup/") || strings.HasPrefix(path, "/setup/") ||
path == "/health" { path == "/health" ||
path == "/responses" {
c.Next() c.Next()
return return
} }

184
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,184 @@
# =============================================================================
# Sub2API Caddy Reverse Proxy Configuration (宿主机部署)
# =============================================================================
# 使用方法:
# 1. 安装 Caddy: https://caddyserver.com/docs/install
# 2. 修改下方 example.com 为你的域名
# 3. 确保域名 DNS 已指向服务器
# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile
# 5. 重载配置: sudo systemctl reload caddy
#
# Caddy 会自动申请和续期 Let's Encrypt SSL 证书
# =============================================================================
# 全局配置
{
# Let's Encrypt 邮箱通知
email admin@example.com
# 服务器配置
servers {
# 启用 HTTP/2 和 HTTP/3
protocols h1 h2 h3
# 超时配置
timeouts {
read_body 30s
read_header 10s
write 60s
idle 120s
}
}
}
# 修改为你的域名
example.com {
# =========================================================================
# TLS 安全配置
# =========================================================================
tls {
# 仅使用 TLS 1.2 和 1.3
protocols tls1.2 tls1.3
# 优先使用的加密套件
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
}
# =========================================================================
# 反向代理配置
# =========================================================================
reverse_proxy localhost:8080 {
# 健康检查
health_uri /health
health_interval 30s
health_timeout 10s
health_status 200
# 负载均衡策略(单节点可忽略,多节点时有用)
lb_policy round_robin
lb_try_duration 5s
lb_try_interval 250ms
# 传递真实客户端信息
# 兼容 Cloudflare 和直连:后端应优先读取 CF-Connecting-IP其次 X-Real-IP
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
# 保留 Cloudflare 原始头(如果存在)
# 后端获取 IP 的优先级建议: CF-Connecting-IP → X-Real-IP → X-Forwarded-For
header_up CF-Connecting-IP {http.request.header.CF-Connecting-IP}
# 连接池优化
transport http {
keepalive 120s
keepalive_idle_conns 256
read_buffer 16KB
write_buffer 16KB
compression off
}
# 故障转移
fail_duration 30s
max_fails 3
unhealthy_status 500 502 503 504
}
# =========================================================================
# 压缩配置
# =========================================================================
encode {
zstd
gzip 6
minimum_length 256
match {
header Content-Type text/*
header Content-Type application/json*
header Content-Type application/javascript*
header Content-Type application/xml*
header Content-Type application/rss+xml*
header Content-Type image/svg+xml*
}
}
# =========================================================================
# 速率限制 (需要 caddy-ratelimit 插件)
# 如未安装插件,请注释掉此段
# =========================================================================
# rate_limit {
# zone api {
# key {remote_host}
# events 100
# window 1m
# }
# }
# =========================================================================
# 安全响应头
# =========================================================================
header {
# 防止点击劫持
X-Frame-Options "SAMEORIGIN"
# XSS 保护
X-XSS-Protection "1; mode=block"
# 防止 MIME 类型嗅探
X-Content-Type-Options "nosniff"
# 引用策略
Referrer-Policy "strict-origin-when-cross-origin"
# HSTS - 强制 HTTPS (max-age=1年)
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# 内容安全策略 (根据需要调整)
# Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;"
# 权限策略
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
# 跨域资源策略
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
Cross-Origin-Resource-Policy "same-origin"
# 移除敏感头
-Server
-X-Powered-By
}
# =========================================================================
# 请求大小限制 (防止大文件攻击)
# =========================================================================
request_body {
max_size 100MB
}
# =========================================================================
# 日志配置
# =========================================================================
log {
output file /var/log/caddy/sub2api.log {
roll_size 50mb
roll_keep 10
roll_keep_for 720h
}
format json
level INFO
}
# =========================================================================
# 错误处理
# =========================================================================
handle_errors {
respond "{err.status_code} {err.status_text}"
}
}
# =============================================================================
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
# =============================================================================
; http://example.com {
; redir https://{host}{uri} permanent
; }

View File

@@ -281,6 +281,30 @@ To change after installation:
sudo systemctl restart sub2api sudo systemctl restart sub2api
``` ```
#### Gemini OAuth Configuration
If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client credentials to the systemd service file:
1. Edit the service file:
```bash
sudo nano /etc/systemd/system/sub2api.service
```
2. Add your OAuth credentials in the `[Service]` section (after the existing `Environment=` lines):
```ini
Environment=GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
```
3. Reload and restart:
```bash
sudo systemctl daemon-reload
sudo systemctl restart sub2api
```
> **Note:** Code Assist OAuth does not require any configuration - it uses the built-in Gemini CLI client.
> See the [Gemini OAuth Configuration](#gemini-oauth-configuration) section above for detailed setup instructions.
#### Application Configuration #### Application Configuration
The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard). The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard).

View File

@@ -121,8 +121,8 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
ports: # 注意:不暴露端口到宿主机,应用通过内部网络连接
- 5433:5432 # 如需调试可临时添加ports: ["127.0.0.1:5433:5432"]
# =========================================================================== # ===========================================================================
# Redis Cache # Redis Cache

View File

@@ -226,7 +226,9 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.tokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0) formatTokens(stats.summary.today?.tokens || 0)
}}</span> }}</span>

View File

@@ -89,6 +89,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Account } from '@/types' import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
const props = defineProps<{ const props = defineProps<{
account: Account account: Account
@@ -139,13 +140,4 @@ const statusText = computed(() => {
return props.account.status return props.account.status
}) })
// Format time helper
const formatTime = (dateStr: string | null | undefined) => {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
}
</script> </script>

View File

@@ -280,10 +280,12 @@
import { ref, watch, nextTick } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types' import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine { interface OutputLine {
text: string text: string
@@ -501,6 +503,6 @@ const handleEvent = (event: {
const copyOutput = () => { const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n') const text = outputLines.value.map((l) => l.text).join('\n')
navigator.clipboard.writeText(text) copyToClipboard(text, t('admin.accounts.outputCopied'))
} }
</script> </script>

View File

@@ -16,21 +16,27 @@
<div v-else-if="stats" class="space-y-0.5 text-xs"> <div v-else-if="stats" class="space-y-0.5 text-xs">
<!-- Requests --> <!-- Requests -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.requests') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(stats.requests) formatNumber(stats.requests)
}}</span> }}</span>
</div> </div>
<!-- Tokens --> <!-- Tokens -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.tokens') }}:</span
>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ <span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(stats.tokens) formatTokens(stats.tokens)
}}</span> }}</span>
</div> </div>
<!-- Cost --> <!-- Cost -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span> <span class="text-gray-500 dark:text-gray-400"
>{{ t('admin.accounts.stats.cost') }}:</span
>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ <span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost) formatCurrency(stats.cost)
}}</span> }}</span>
@@ -44,6 +50,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, WindowStats } from '@/types' import type { Account, WindowStats } from '@/types'
import { formatNumber, formatCurrency } from '@/utils/format' import { formatNumber, formatCurrency } from '@/utils/format'
@@ -52,6 +59,8 @@ const props = defineProps<{
account: Account account: Account
}>() }>()
const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const stats = ref<WindowStats | null>(null) const stats = ref<WindowStats | null>(null)

View File

@@ -105,6 +105,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo } from '@/types' import type { Account, AccountUsageInfo } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
@@ -113,6 +114,8 @@ const props = defineProps<{
account: Account account: Account
}>() }>()
const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null) const usageInfo = ref<AccountUsageInfo | null>(null)
@@ -282,7 +285,7 @@ const loadUsage = async () => {
try { try {
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id) usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
} catch (e: any) { } catch (e: any) {
error.value = 'Failed' error.value = t('common.error')
console.error('Failed to load usage:', e) console.error('Failed to load usage:', e)
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -1,5 +1,5 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="lg" @close="handleClose"> <Modal :show="show" :title="t('admin.accounts.createAccount')" size="xl" @close="handleClose">
<!-- Step Indicator for OAuth accounts --> <!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center"> <div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -256,7 +256,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
</div> </div>
</button> </button>
@@ -294,7 +294,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Responses API</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
</div> </div>
</button> </button>
</div> </div>
@@ -338,7 +338,7 @@
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Google OAuth</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
</div> </div>
</button> </button>
@@ -408,7 +408,7 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span> <span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span> <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
</div> </div>
@@ -468,7 +468,7 @@
<div <div
v-if="!geminiAIStudioOAuthEnabled" v-if="!geminiAIStudioOAuthEnabled"
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200" class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
> >
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }} {{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div> </div>
@@ -488,7 +488,7 @@
value="oauth" value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500" class="mr-2 text-primary-600 focus:ring-primary-500"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.types.oauth') }}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input

View File

@@ -1,5 +1,5 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose"> <Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose">
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> <form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('common.name') }}</label> <label class="input-label">{{ t('common.name') }}</label>

View File

@@ -63,7 +63,9 @@
value="oauth" value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500" class="mr-2 text-primary-600 focus:ring-primary-500"
/> />
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.types.oauth')
}}</span>
</label> </label>
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -116,7 +118,9 @@
</svg> </svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.types.codeAssist')
}}</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ <span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
t('admin.accounts.oauth.gemini.needsProjectId') t('admin.accounts.oauth.gemini.needsProjectId')
}}</span> }}</span>

View File

@@ -4,7 +4,7 @@
<div <div
v-if="windowStats" v-if="windowStats"
class="mb-0.5 flex items-center justify-between" class="mb-0.5 flex items-center justify-between"
:title="`5h 窗口用量统计`" :title="t('admin.accounts.usageWindow.statsTitle')"
> >
<div <div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400" class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
@@ -51,6 +51,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types' import type { WindowStats } from '@/types'
const props = defineProps<{ const props = defineProps<{
@@ -61,6 +62,8 @@ const props = defineProps<{
windowStats?: WindowStats | null windowStats?: WindowStats | null
}>() }>()
const { t } = useI18n()
// Label background colors // Label background colors
const labelClass = computed(() => { const labelClass = computed(() => {
const colors = { const colors = {

View File

@@ -31,8 +31,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue' import Modal from './Modal.vue'
const { t } = useI18n()
interface Props { interface Props {
show: boolean show: boolean
title: string title: string
@@ -47,12 +51,13 @@ interface Emits {
(e: 'cancel'): void (e: 'cancel'): void
} }
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
confirmText: 'Confirm',
cancelText: 'Cancel',
danger: false danger: false
}) })
const confirmText = computed(() => props.confirmText || t('common.confirm'))
const cancelText = computed(() => props.cancelText || t('common.cancel'))
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const handleConfirm = () => { const handleConfirm = () => {

View File

@@ -1,18 +1,60 @@
<template> <template>
<div class="overflow-x-auto"> <div
ref="tableWrapperRef"
class="table-wrapper"
:class="{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
}"
>
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-800"> <thead class="table-header bg-gray-50 dark:bg-dark-800">
<tr> <tr>
<th <th
v-for="column in columns" v-for="(column, index) in columns"
:key="column.key" :key="column.key"
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400" :class="[
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }" 'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
getAdaptivePaddingClass(),
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
getStickyColumnClass(column, index)
]"
@click="column.sortable && handleSort(column.key)" @click="column.sortable && handleSort(column.key)"
> >
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span>{{ column.label }}</span> <span>{{ column.label }}</span>
<!-- 操作列展开/折叠按钮 -->
<button
v-if="column.key === 'actions' && hasExpandableActions"
type="button"
@click.stop="toggleActionsExpanded"
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
>
<!-- 展开状态收起图标 -->
<svg
v-if="actionsExpanded"
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
</svg>
<!-- 折叠状态展开图标 -->
<svg
v-else
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
</svg>
</button>
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500"> <span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg <svg
v-if="sortKey === column.key" v-if="sortKey === column.key"
@@ -37,10 +79,10 @@
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900"> <tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton --> <!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i"> <tr v-if="loading" v-for="i in 5" :key="i">
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4"> <td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
<div class="animate-pulse"> <div class="animate-pulse">
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div> <div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
</div> </div>
@@ -51,7 +93,7 @@
<tr v-else-if="!data || data.length === 0"> <tr v-else-if="!data || data.length === 0">
<td <td
:colspan="columns.length" :colspan="columns.length"
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400" :class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
> >
<slot name="empty"> <slot name="empty">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@@ -84,11 +126,15 @@
class="hover:bg-gray-50 dark:hover:bg-dark-800" class="hover:bg-gray-50 dark:hover:bg-dark-800"
> >
<td <td
v-for="column in columns" v-for="(column, colIndex) in columns"
:key="column.key" :key="column.key"
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100" :class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
]"
> >
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]"> <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }} {{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot> </slot>
</td> </td>
@@ -99,24 +145,135 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Column } from './types' import type { Column } from './types'
const { t } = useI18n() const { t } = useI18n()
// 表格容器引用
const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false)
const actionsColumnNeedsExpanding = ref(false)
// 检查是否可滚动
const checkScrollable = () => {
if (tableWrapperRef.value) {
isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth
}
}
// 检查操作列是否需要展开
const checkActionsColumnWidth = () => {
if (!tableWrapperRef.value) return
// 查找第一行的操作列单元格
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
if (!firstActionCell) return
// 查找操作列内容的容器div
const actionsContainer = firstActionCell.querySelector('div')
if (!actionsContainer) return
// 临时展开以测量完整宽度
const wasExpanded = actionsExpanded.value
actionsExpanded.value = true
// 等待DOM更新
nextTick(() => {
// 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button')
if (buttons.length <= 2) {
actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded
return
}
// 计算所有按钮的总宽度包括gap
let totalWidth = 0
buttons.forEach((btn, index) => {
totalWidth += (btn as HTMLElement).offsetWidth
if (index < buttons.length - 1) {
totalWidth += 4 // gap-1 = 4px
}
})
// 获取单元格可用宽度减去padding
const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 // 减去左右padding
// 如果总宽度超过可用宽度,需要展开功能
actionsColumnNeedsExpanding.value = totalWidth > cellWidth
// 恢复原来的展开状态
actionsExpanded.value = wasExpanded
})
}
// 监听尺寸变化
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkScrollable()
checkActionsColumnWidth()
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
checkScrollable()
checkActionsColumnWidth()
})
resizeObserver.observe(tableWrapperRef.value)
} else {
// 降级方案:不支持 ResizeObserver 时使用 window resize
const handleResize = () => {
checkScrollable()
checkActionsColumnWidth()
}
window.addEventListener('resize', handleResize)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable)
})
interface Props { interface Props {
columns: Column[] columns: Column[]
data: any[] data: any[]
loading?: boolean loading?: boolean
stickyFirstColumn?: boolean
stickyActionsColumn?: boolean
expandableActions?: boolean
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false loading: false,
stickyFirstColumn: true,
stickyActionsColumn: true,
expandableActions: true
}) })
const sortKey = ref<string>('') const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false)
// 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch(
[() => props.data.length, () => props.columns],
async () => {
await nextTick()
checkScrollable()
checkActionsColumnWidth()
},
{ flush: 'post' }
)
// 单独监听展开状态变化,只更新滚动状态
watch(actionsExpanded, async () => {
await nextTick()
checkScrollable()
})
const handleSort = (key: string) => { const handleSort = (key: string) => {
if (sortKey.value === key) { if (sortKey.value === key) {
@@ -140,4 +297,212 @@ const sortedData = computed(() => {
return sortOrder.value === 'asc' ? comparison : -comparison return sortOrder.value === 'asc' ? comparison : -comparison
}) })
}) })
// 检查是否有可展开的操作列
const hasExpandableActions = computed(() => {
// 如果明确指定了actionsCount使用它来判断
if (props.actionsCount !== undefined) {
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
}
// 否则使用原来的检测逻辑
return (
props.expandableActions &&
props.columns.some((col) => col.key === 'actions') &&
actionsColumnNeedsExpanding.value
)
})
// 切换操作列展开/折叠状态
const toggleActionsExpanded = () => {
actionsExpanded.value = !actionsExpanded.value
}
// 检查第一列是否为勾选列
const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select'
})
// 生成固定列的 CSS 类
const getStickyColumnClass = (column: Column, index: number) => {
const classes: string[] = []
if (props.stickyFirstColumn) {
// 如果第一列是勾选列,固定前两列(勾选+名称)
if (hasSelectColumn.value) {
if (index === 0) {
classes.push('sticky-col sticky-col-left-first')
} else if (index === 1) {
classes.push('sticky-col sticky-col-left-second')
}
} else {
// 否则只固定第一列
if (index === 0) {
classes.push('sticky-col sticky-col-left')
}
}
}
// 操作列固定(最后一列)
if (props.stickyActionsColumn && column.key === 'actions') {
classes.push('sticky-col sticky-col-right')
}
return classes.join(' ')
}
// 根据列数自适应调整内边距
const getAdaptivePaddingClass = () => {
const columnCount = props.columns.length
// 列数越多,内边距越小
if (columnCount >= 10) {
return 'px-2' // 8px
} else if (columnCount >= 7) {
return 'px-3' // 12px
} else if (columnCount >= 5) {
return 'px-4' // 16px
} else {
return 'px-6' // 24px (原始值)
}
}
</script> </script>
<style scoped>
/* 表格横向滚动 */
.table-wrapper {
--select-col-width: 52px; /* 勾选列宽度px-6 (24px*2) + checkbox (16px) */
position: relative;
overflow-x: auto;
isolation: isolate;
}
/* 表头容器,确保在滚动时覆盖表体内容 */
.table-wrapper .table-header {
position: sticky;
top: 0;
z-index: 200;
background-color: rgb(249 250 251);
}
.dark .table-wrapper .table-header {
background-color: rgb(31 41 55);
}
/* 表体保持在表头下方 */
.table-body {
position: relative;
z-index: 0;
}
/* 所有表头单元格固定在顶部 */
.sticky-header-cell {
position: sticky;
top: 0;
z-index: 210; /* 必须高于所有表体内容 */
background-color: rgb(249 250 251);
}
.dark .sticky-header-cell {
background-color: rgb(31 41 55);
}
/* Sticky 列基础样式 */
.sticky-col {
position: sticky;
z-index: 20; /* 表体固定列 */
}
/* 单列固定(无勾选列时) */
.sticky-col-left {
left: 0;
}
/* 双列固定(有勾选列时):第一列(勾选) */
.sticky-col-left-first {
left: 0;
}
/* 双列固定(有勾选列时):第二列(名称) */
.sticky-col-left-second {
left: var(--select-col-width);
}
/* 操作列固定 */
.sticky-col-right {
right: 0;
}
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
.sticky-header-cell.sticky-col {
z-index: 220; /* 高于普通表头单元格和表体固定列 */
}
/* 表体 sticky 列背景 */
tbody .sticky-col {
background-color: white;
}
.dark tbody .sticky-col {
background-color: rgb(17 24 39);
}
/* hover 状态保持 */
tbody tr:hover .sticky-col {
background-color: rgb(249 250 251);
}
.dark tbody tr:hover .sticky-col {
background-color: rgb(31 41 55);
}
/* 阴影只在可滚动时显示 */
/* 单列固定右侧阴影 */
.is-scrollable .sticky-col-left::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
transform: translateX(100%);
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 双列固定:只在第二列显示阴影 */
.is-scrollable .sticky-col-left-second::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 10px;
transform: translateX(100%);
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 操作列左侧阴影 */
.is-scrollable .sticky-col-right::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 10px;
transform: translateX(-100%);
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent);
pointer-events: none;
}
/* 暗色模式阴影 */
.dark .is-scrollable .sticky-col-left::after,
.dark .is-scrollable .sticky-col-left-second::after {
background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent);
}
.dark .is-scrollable .sticky-col-right::before {
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
}
</style>

View File

@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
const localEndDate = ref(props.endDate) const localEndDate = ref(props.endDate)
const activePreset = ref<string | null>('7days') const activePreset = ref<string | null>('7days')
const today = computed(() => new Date().toISOString().split('T')[0]) const today = computed(() => {
// Use local timezone to avoid UTC timezone issues
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
})
// Helper function to format date to YYYY-MM-DD using local timezone
const formatDateToString = (date: Date): string => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const presets: DatePreset[] = [ const presets: DatePreset[] = [
{ {
@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
getRange: () => { getRange: () => {
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 1) d.setDate(d.getDate() - 1)
const yesterday = d.toISOString().split('T')[0] const yesterday = formatDateToString(d)
return { start: yesterday, end: yesterday } return { start: yesterday, end: yesterday }
} }
}, },
@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 6) d.setDate(d.getDate() - 6)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 13) d.setDate(d.getDate() - 13)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
const end = today.value const end = today.value
const d = new Date() const d = new Date()
d.setDate(d.getDate() - 29) d.setDate(d.getDate() - 29)
const start = d.toISOString().split('T')[0] const start = formatDateToString(d)
return { start, end } return { start, end }
} }
}, },
@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
value: 'thisMonth', value: 'thisMonth',
getRange: () => { getRange: () => {
const now = new Date() const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0] const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
return { start, end: today.value } return { start, end: today.value }
} }
}, },
@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
value: 'lastMonth', value: 'lastMonth',
getRange: () => { getRange: () => {
const now = new Date() const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0] const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0] const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
return { start, end } return { start, end }
} }
} }

View File

@@ -25,7 +25,7 @@
<!-- Title --> <!-- Title -->
<h3 class="empty-state-title"> <h3 class="empty-state-title">
{{ title }} {{ displayTitle }}
</h3> </h3>
<!-- Description --> <!-- Description -->
@@ -61,8 +61,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Component } from 'vue' import type { Component } from 'vue'
const { t } = useI18n()
interface Props { interface Props {
icon?: Component | string icon?: Component | string
title?: string title?: string
@@ -73,11 +77,12 @@ interface Props {
message?: string message?: string
} }
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: 'No data found',
description: '', description: '',
actionIcon: true actionIcon: true
}) })
const displayTitle = computed(() => props.title || t('common.noData'))
defineEmits(['action']) defineEmits(['action'])
</script> </script>

View File

@@ -11,7 +11,7 @@
v-for="group in filteredGroups" v-for="group in filteredGroups"
:key="group.id" :key="group.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`" :title="t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })"
> >
<input <input
type="checkbox" type="checkbox"
@@ -40,9 +40,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue' import GroupBadge from './GroupBadge.vue'
import type { Group, GroupPlatform } from '@/types' import type { Group, GroupPlatform } from '@/types'
const { t } = useI18n()
interface Props { interface Props {
modelValue: number[] modelValue: number[]
groups: Group[] groups: Group[]

View File

@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
} }
} }
const handlePageSizeChange = (value: string | number | null) => { const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null) return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
// Reset to first page when page size changes // Reset to first page when page size changes

View File

@@ -60,7 +60,7 @@
<div class="select-options"> <div class="select-options">
<div <div
v-for="option in filteredOptions" v-for="option in filteredOptions"
:key="getOptionValue(option) ?? undefined" :key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click="selectOption(option)" @click="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']" :class="['select-option', isSelected(option) && 'select-option-selected']"
> >
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
export interface SelectOption { export interface SelectOption {
value: string | number | null value: string | number | boolean | null
label: string label: string
disabled?: boolean disabled?: boolean
[key: string]: unknown [key: string]: unknown
} }
interface Props { interface Props {
modelValue: string | number | null | undefined modelValue: string | number | boolean | null | undefined
options: SelectOption[] | Array<Record<string, unknown>> options: SelectOption[] | Array<Record<string, unknown>>
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
@@ -116,8 +116,8 @@ interface Props {
} }
interface Emits { interface Emits {
(e: 'update:modelValue', value: string | number | null): void (e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | null, option: SelectOption | null): void (e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
const getOptionValue = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
): string | number | null | undefined => { ): string | number | boolean | null | undefined => {
if (typeof option === 'object' && option !== null) { if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | null | undefined return option[props.valueKey] as string | number | boolean | null | undefined
} }
return option as string | number | null return option as string | number | boolean | null
} }
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => { const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {

View File

@@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string {
const diff = expires.getTime() - now.getTime() const diff = expires.getTime() - now.getTime()
if (diff < 0) return t('subscriptionProgress.expired') if (diff < 0) return t('subscriptionProgress.expired')
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days === 0) return t('subscriptionProgress.expirestoday') if (days === 0) return t('subscriptionProgress.expiresToday')
if (days === 1) return t('subscriptionProgress.expiresTomorrow') if (days === 1) return t('subscriptionProgress.expiresTomorrow')
return t('subscriptionProgress.daysRemaining', { days }) return t('subscriptionProgress.daysRemaining', { days })
} }

View File

@@ -10,7 +10,7 @@
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50' ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
]" ]"
:title="hasUpdate ? 'New version available' : 'Up to date'" :title="hasUpdate ? t('version.updateAvailable') : t('version.upToDate')"
> >
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span> <span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
<span <span

View File

@@ -119,7 +119,7 @@
import { ref, computed, h, watch, type Component } from 'vue' import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
import { useAppStore } from '@/stores/app' import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types' import type { GroupPlatform } from '@/types'
interface Props { interface Props {
@@ -150,7 +150,7 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const { copyToClipboard: clipboardCopy } = useClipboard()
const copiedIndex = ref<number | null>(null) const copiedIndex = ref<number | null>(null)
const activeTab = ref<string>('unix') const activeTab = ref<string>('unix')
@@ -340,14 +340,12 @@ ${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
} }
const copyContent = async (content: string, index: number) => { const copyContent = async (content: string, index: number) => {
try { const success = await clipboardCopy(content, t('keys.copied'))
await navigator.clipboard.writeText(content) if (success) {
copiedIndex.value = index copiedIndex.value = index
setTimeout(() => { setTimeout(() => {
copiedIndex.value = null copiedIndex.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }
</script> </script>

View File

@@ -0,0 +1,114 @@
<template>
<div class="table-page-layout" :class="{ 'mobile-mode': isMobile }">
<!-- 固定区域操作按钮 -->
<div v-if="$slots.actions" class="layout-section-fixed">
<slot name="actions" />
</div>
<!-- 固定区域搜索和过滤器 -->
<div v-if="$slots.filters" class="layout-section-fixed">
<slot name="filters" />
</div>
<!-- 滚动区域表格 -->
<div class="layout-section-scrollable">
<div class="card table-scroll-container">
<slot name="table" />
</div>
</div>
<!-- 固定区域分页器 -->
<div v-if="$slots.pagination" class="layout-section-fixed">
<slot name="pagination" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
/* 桌面端Flexbox 布局 */
.table-page-layout {
@apply flex flex-col gap-6;
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
}
.layout-section-fixed {
@apply flex-shrink-0;
}
.layout-section-scrollable {
@apply flex-1 min-h-0 flex flex-col;
}
/* 表格滚动容器 - 增强版表体滚动方案 */
.table-scroll-container {
@apply flex flex-col overflow-hidden h-full bg-white dark:bg-dark-800 rounded-2xl border border-gray-200 dark:border-dark-700 shadow-sm;
}
.table-scroll-container :deep(.table-wrapper) {
@apply flex-1 overflow-x-auto overflow-y-auto;
/* 确保横向滚动条显示在最底部 */
scrollbar-gutter: stable;
}
.table-scroll-container :deep(table) {
@apply w-full;
min-width: max-content; /* 关键:确保表格宽度根据内容撑开,从而触发横向滚动 */
display: table; /* 使用标准 table 布局以支持 sticky 列 */
}
.table-scroll-container :deep(thead) {
@apply bg-gray-50/80 dark:bg-dark-800/80 backdrop-blur-sm;
}
.table-scroll-container :deep(tbody) {
/* 保持默认 table-row-group 显示,不使用 block */
}
.table-scroll-container :deep(th) {
/* 表头高度和文字加粗优化 */
@apply px-5 py-4 text-left text-sm font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-dark-700;
@apply uppercase tracking-wider; /* 让表头更有设计感 */
}
.table-scroll-container :deep(td) {
@apply px-5 py-4 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-dark-800;
}
/* 移动端:恢复正常滚动 */
.table-page-layout.mobile-mode .table-scroll-container {
@apply h-auto overflow-visible border-none shadow-none bg-transparent;
}
.table-page-layout.mobile-mode .layout-section-scrollable {
@apply flex-none min-h-fit;
}
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
@apply overflow-visible;
}
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
@apply flex-none;
display: table;
min-width: 100%;
}
</style>

View File

@@ -1,40 +1,65 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
/**
* 检测是否支持 Clipboard API需要安全上下文HTTPS/localhost
*/
function isClipboardSupported(): boolean {
return !!(navigator.clipboard && window.isSecureContext)
}
/**
* 降级方案:使用 textarea + execCommand
* 使用 textarea 而非 input以正确处理多行文本
*/
function fallbackCopy(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}
export function useClipboard() { export function useClipboard() {
const appStore = useAppStore() const appStore = useAppStore()
const copied = ref(false) const copied = ref(false)
const copyToClipboard = async (text: string, successMessage = 'Copied to clipboard') => { const copyToClipboard = async (
text: string,
successMessage = 'Copied to clipboard'
): Promise<boolean> => {
if (!text) return false if (!text) return false
try { let success = false
await navigator.clipboard.writeText(text)
copied.value = true if (isClipboardSupported()) {
appStore.showSuccess(successMessage) try {
setTimeout(() => { await navigator.clipboard.writeText(text)
copied.value = false success = true
}, 2000) } catch {
return true success = fallbackCopy(text)
} catch { }
// Fallback for older browsers } else {
const input = document.createElement('input') success = fallbackCopy(text)
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
return true
} }
if (success) {
copied.value = true
appStore.showSuccess(successMessage)
setTimeout(() => {
copied.value = false
}, 2000)
} else {
appStore.showError('Copy failed')
}
return success
} }
return { return { copied, copyToClipboard }
copied,
copyToClipboard
}
} }

View File

@@ -30,13 +30,61 @@ export default {
title: 'Supported Providers', title: 'Supported Providers',
description: 'Unified API interface for AI services', description: 'Unified API interface for AI services',
supported: 'Supported', supported: 'Supported',
soon: 'Soon' soon: 'Soon',
claude: 'Claude',
gemini: 'Gemini',
more: 'More'
}, },
footer: { footer: {
allRightsReserved: 'All rights reserved.' allRightsReserved: 'All rights reserved.'
} }
}, },
// Setup Wizard
setup: {
title: 'Sub2API Setup',
description: 'Configure your Sub2API instance',
database: {
title: 'Database Configuration',
host: 'Host',
port: 'Port',
username: 'Username',
password: 'Password',
databaseName: 'Database Name',
sslMode: 'SSL Mode',
passwordPlaceholder: 'Password',
ssl: {
disable: 'Disable',
require: 'Require',
verifyCa: 'Verify CA',
verifyFull: 'Verify Full'
}
},
redis: {
title: 'Redis Configuration',
host: 'Host',
port: 'Port',
password: 'Password (optional)',
database: 'Database',
passwordPlaceholder: 'Password'
},
admin: {
title: 'Admin Account',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
passwordPlaceholder: 'Min 6 characters',
confirmPasswordPlaceholder: 'Confirm password',
passwordMismatch: 'Passwords do not match'
},
ready: {
title: 'Ready to Install',
database: 'Database',
redis: 'Redis',
adminEmail: 'Admin Email'
}
},
// Common // Common
common: { common: {
loading: 'Loading...', loading: 'Loading...',
@@ -84,7 +132,14 @@ export default {
searchPlaceholder: 'Search...', searchPlaceholder: 'Search...',
noOptionsFound: 'No options found', noOptionsFound: 'No options found',
saving: 'Saving...', saving: 'Saving...',
refresh: 'Refresh' refresh: 'Refresh',
time: {
never: 'Never',
justNow: 'Just now',
minutesAgo: '{n}m ago',
hoursAgo: '{n}h ago',
daysAgo: '{n}d ago'
}
}, },
// Navigation // Navigation
@@ -142,7 +197,20 @@ export default {
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.', accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
turnstileExpired: 'Verification expired, please try again', turnstileExpired: 'Verification expired, please try again',
turnstileFailed: 'Verification failed, please try again', turnstileFailed: 'Verification failed, please try again',
completeVerification: 'Please complete the verification' completeVerification: 'Please complete the verification',
verifyYourEmail: 'Verify Your Email',
sessionExpired: 'Session expired',
sessionExpiredDesc: 'Please go back to the registration page and start again.',
verificationCode: 'Verification Code',
verificationCodeHint: 'Enter the 6-digit code sent to your email',
sendingCode: 'Sending...',
clickToResend: 'Click to resend code',
resendCode: 'Resend verification code',
oauth: {
code: 'Code',
state: 'State',
fullUrl: 'Full URL'
}
}, },
// Dashboard // Dashboard
@@ -207,7 +275,7 @@ export default {
created: 'Created', created: 'Created',
copyToClipboard: 'Copy to clipboard', copyToClipboard: 'Copy to clipboard',
copied: 'Copied!', copied: 'Copied!',
importToCcSwitch: 'Import to CC Switch', importToCcSwitch: 'Import to CCS',
enable: 'Enable', enable: 'Enable',
disable: 'Disable', disable: 'Disable',
nameLabel: 'Name', nameLabel: 'Name',
@@ -377,6 +445,12 @@ export default {
noData: 'No data found' noData: 'No data found'
}, },
// Table
table: {
expandActions: 'Expand More Actions',
collapseActions: 'Collapse Actions'
},
// Pagination // Pagination
pagination: { pagination: {
showing: 'Showing', showing: 'Showing',
@@ -455,6 +529,7 @@ export default {
actual: 'Actual', actual: 'Actual',
standard: 'Standard', standard: 'Standard',
noDataAvailable: 'No data available', noDataAvailable: 'No data available',
recentUsage: 'Recent Usage',
failedToLoad: 'Failed to load dashboard statistics' failedToLoad: 'Failed to load dashboard statistics'
}, },
@@ -507,9 +582,13 @@ export default {
noSubscription: 'No subscription', noSubscription: 'No subscription',
daysRemaining: '{days}d', daysRemaining: '{days}d',
expired: 'Expired', expired: 'Expired',
disable: 'Disable',
enable: 'Enable',
disableUser: 'Disable User', disableUser: 'Disable User',
enableUser: 'Enable User', enableUser: 'Enable User',
viewApiKeys: 'View API Keys', viewApiKeys: 'View API Keys',
groups: 'Groups',
apiKeys: 'API Keys',
userApiKeys: 'User API Keys', userApiKeys: 'User API Keys',
noApiKeys: 'This user has no API keys', noApiKeys: 'This user has no API keys',
group: 'Group', group: 'Group',
@@ -584,6 +663,7 @@ export default {
actions: 'Actions', actions: 'Actions',
billingType: 'Billing Type' billingType: 'Billing Type'
}, },
rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts', accountsCount: '{count} accounts',
form: { form: {
name: 'Name', name: 'Name',
@@ -742,6 +822,13 @@ export default {
openai: 'OpenAI', openai: 'OpenAI',
gemini: 'Gemini' gemini: 'Gemini'
}, },
types: {
oauth: 'OAuth',
chatgptOauth: 'ChatGPT OAuth',
responsesApi: 'Responses API',
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist'
},
columns: { columns: {
name: 'Name', name: 'Name',
platformType: 'Platform/Type', platformType: 'Platform/Type',
@@ -1022,6 +1109,7 @@ export default {
todayOverview: 'Today Overview', todayOverview: 'Today Overview',
cost: 'Cost', cost: 'Cost',
requests: 'Requests', requests: 'Requests',
tokens: 'Tokens',
highestCostDay: 'Highest Cost Day', highestCostDay: 'Highest Cost Day',
highestRequestDay: 'Highest Request Day', highestRequestDay: 'Highest Request Day',
date: 'Date', date: 'Date',
@@ -1037,6 +1125,9 @@ export default {
todayCost: 'Today Cost', todayCost: 'Today Cost',
usageTrend: '30-Day Cost & Request Trend', usageTrend: '30-Day Cost & Request Trend',
noData: 'No usage data available for this account' noData: 'No usage data available for this account'
},
usageWindow: {
statsTitle: '5-Hour Window Usage Statistics'
} }
}, },
@@ -1070,6 +1161,10 @@ export default {
enterProxyName: 'Enter proxy name', enterProxyName: 'Enter proxy name',
leaveEmptyToKeep: 'Leave empty to keep current', leaveEmptyToKeep: 'Leave empty to keep current',
optionalAuth: 'Optional authentication', optionalAuth: 'Optional authentication',
form: {
hostPlaceholder: 'proxy.example.com',
portPlaceholder: '8080'
},
noProxiesYet: 'No proxies yet', noProxiesYet: 'No proxies yet',
createFirstProxy: 'Create your first proxy to route traffic through it.', createFirstProxy: 'Create your first proxy to route traffic through it.',
// Batch import // Batch import
@@ -1077,9 +1172,9 @@ export default {
batchAdd: 'Quick Add', batchAdd: 'Quick Add',
batchInput: 'Proxy List', batchInput: 'Proxy List',
batchInputPlaceholder: batchInputPlaceholder:
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", "Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
batchInputHint: batchInputHint:
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port", "Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
parsedCount: '{count} valid', parsedCount: '{count} valid',
invalidCount: '{count} invalid', invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate', duplicateCount: '{count} duplicate',
@@ -1174,6 +1269,18 @@ export default {
searchUserPlaceholder: 'Search user by email...', searchUserPlaceholder: 'Search user by email...',
selectedUser: 'Selected', selectedUser: 'Selected',
user: 'User', user: 'User',
account: 'Account',
group: 'Group',
requestId: 'Request ID',
allModels: 'All Models',
allAccounts: 'All Accounts',
allGroups: 'All Groups',
allTypes: 'All Types',
allBillingTypes: 'All Billing',
inputCost: 'Input Cost',
outputCost: 'Output Cost',
cacheCreationCost: 'Cache Creation Cost',
cacheReadCost: 'Cache Read Cost',
failedToLoad: 'Failed to load usage records' failedToLoad: 'Failed to load usage records'
}, },
@@ -1211,16 +1318,20 @@ export default {
title: 'Site Settings', title: 'Site Settings',
description: 'Customize site branding', description: 'Customize site branding',
siteName: 'Site Name', siteName: 'Site Name',
siteNamePlaceholder: 'Sub2API',
siteNameHint: 'Displayed in emails and page titles', siteNameHint: 'Displayed in emails and page titles',
siteSubtitle: 'Site Subtitle', siteSubtitle: 'Site Subtitle',
siteSubtitlePlaceholder: 'Subscription to API Conversion Platform',
siteSubtitleHint: 'Displayed on login and register pages', siteSubtitleHint: 'Displayed on login and register pages',
apiBaseUrl: 'API Base URL', apiBaseUrl: 'API Base URL',
apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
contactInfo: 'Contact Info', contactInfo: 'Contact Info',
contactInfoPlaceholder: 'e.g., QQ: 123456789', contactInfoPlaceholder: 'e.g., QQ: 123456789',
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.', contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
docUrl: 'Documentation URL', docUrl: 'Documentation URL',
docUrlPlaceholder: 'https://docs.example.com',
docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.', docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.',
siteLogo: 'Site Logo', siteLogo: 'Site Logo',
uploadImage: 'Upload Image', uploadImage: 'Upload Image',
@@ -1236,12 +1347,18 @@ export default {
testConnection: 'Test Connection', testConnection: 'Test Connection',
testing: 'Testing...', testing: 'Testing...',
host: 'SMTP Host', host: 'SMTP Host',
hostPlaceholder: 'smtp.gmail.com',
port: 'SMTP Port', port: 'SMTP Port',
portPlaceholder: '587',
username: 'SMTP Username', username: 'SMTP Username',
usernamePlaceholder: "your-email{'@'}gmail.com",
password: 'SMTP Password', password: 'SMTP Password',
passwordPlaceholder: '********',
passwordHint: 'Leave empty to keep existing password', passwordHint: 'Leave empty to keep existing password',
fromEmail: 'From Email', fromEmail: 'From Email',
fromEmailPlaceholder: "noreply{'@'}example.com",
fromName: 'From Name', fromName: 'From Name',
fromNamePlaceholder: 'Sub2API',
useTls: 'Use TLS', useTls: 'Use TLS',
useTlsHint: 'Enable TLS encryption for SMTP connection' useTlsHint: 'Enable TLS encryption for SMTP connection'
}, },
@@ -1249,6 +1366,7 @@ export default {
title: 'Send Test Email', title: 'Send Test Email',
description: 'Send a test email to verify your SMTP configuration', description: 'Send a test email to verify your SMTP configuration',
recipientEmail: 'Recipient Email', recipientEmail: 'Recipient Email',
recipientEmailPlaceholder: "test{'@'}example.com",
sendTestEmail: 'Send Test Email', sendTestEmail: 'Send Test Email',
sending: 'Sending...', sending: 'Sending...',
enterRecipientHint: 'Please enter a recipient email address' enterRecipientHint: 'Please enter a recipient email address'

View File

@@ -27,13 +27,61 @@ 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 模式',
passwordPlaceholder: '密码',
ssl: {
disable: '禁用',
require: '要求',
verifyCa: '验证 CA',
verifyFull: '完全验证'
}
},
redis: {
title: 'Redis 配置',
host: '主机',
port: '端口',
password: '密码(可选)',
database: '数据库',
passwordPlaceholder: '密码'
},
admin: {
title: '管理员账户',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
passwordPlaceholder: '至少 6 个字符',
confirmPasswordPlaceholder: '确认密码',
passwordMismatch: '密码不匹配'
},
ready: {
title: '准备安装',
database: '数据库',
redis: 'Redis',
adminEmail: '管理员邮箱'
}
},
// Common // Common
common: { common: {
loading: '加载中...', loading: '加载中...',
@@ -81,7 +129,14 @@ export default {
searchPlaceholder: '搜索...', searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项', noOptionsFound: '无匹配选项',
saving: '保存中...', saving: '保存中...',
refresh: '刷新' refresh: '刷新',
time: {
never: '从未',
justNow: '刚刚',
minutesAgo: '{n}分钟前',
hoursAgo: '{n}小时前',
daysAgo: '{n}天前'
}
}, },
// Navigation // Navigation
@@ -139,7 +194,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
@@ -204,7 +272,7 @@ export default {
created: '创建时间', created: '创建时间',
copyToClipboard: '复制到剪贴板', copyToClipboard: '复制到剪贴板',
copied: '已复制!', copied: '已复制!',
importToCcSwitch: '导入到 CC Switch', importToCcSwitch: '导入到 CCS',
enable: '启用', enable: '启用',
disable: '禁用', disable: '禁用',
nameLabel: '名称', nameLabel: '名称',
@@ -373,6 +441,12 @@ export default {
noData: '暂无数据' noData: '暂无数据'
}, },
// Table
table: {
expandActions: '展开更多操作',
collapseActions: '收起操作'
},
// Pagination // Pagination
pagination: { pagination: {
showing: '显示', showing: '显示',
@@ -527,9 +601,13 @@ export default {
noSubscription: '暂无订阅', noSubscription: '暂无订阅',
daysRemaining: '{days}天', daysRemaining: '{days}天',
expired: '已过期', expired: '已过期',
disable: '禁用',
enable: '启用',
disableUser: '禁用用户', disableUser: '禁用用户',
enableUser: '启用用户', enableUser: '启用用户',
viewApiKeys: '查看 API 密钥', viewApiKeys: '查看 API 密钥',
groups: '分组',
apiKeys: 'API密钥',
userApiKeys: '用户 API 密钥', userApiKeys: '用户 API 密钥',
noApiKeys: '此用户暂无 API 密钥', noApiKeys: '此用户暂无 API 密钥',
group: '分组', group: '分组',
@@ -665,10 +743,13 @@ export default {
priorityHint: '数值越高优先级越高,用于账号调度', priorityHint: '数值越高优先级越高,用于账号调度',
statusLabel: '状态' statusLabel: '状态'
}, },
exclusive: { exclusiveObj: {
yes: '是', yes: '是',
no: '否' no: '否'
}, },
exclusive: '独占',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
platforms: { platforms: {
all: '全部平台', all: '全部平台',
claude: 'Claude', claude: 'Claude',
@@ -689,6 +770,7 @@ export default {
exclusiveFilter: '独占', exclusiveFilter: '独占',
nonExclusive: '非独占', nonExclusive: '非独占',
public: '公开', public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号', accountsCount: '{count} 个账号',
enterGroupName: '请输入分组名称', enterGroupName: '请输入分组名称',
optionalDescription: '可选描述', optionalDescription: '可选描述',
@@ -813,6 +895,7 @@ export default {
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
refreshCookie: '刷新 Cookie', refreshCookie: '刷新 Cookie',
testAccount: '测试账号', testAccount: '测试账号',
searchAccounts: '搜索账号...',
// Filter options // Filter options
allPlatforms: '全部平台', allPlatforms: '全部平台',
allTypes: '全部类型', allTypes: '全部类型',
@@ -840,6 +923,19 @@ export default {
lastUsed: '最近使用', lastUsed: '最近使用',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制',
testConnection: '测试连接',
reAuthorize: '重新授权',
refreshToken: '刷新令牌',
noAccountsYet: '暂无账号',
createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。',
tokenRefreshed: 'Token 刷新成功',
accountDeleted: '账号删除成功',
rateLimitCleared: '速率限制已清除',
setupToken: 'Setup Token',
apiKey: 'API Key',
deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。",
failedToClearRateLimit: '清除速率限制失败',
platforms: { platforms: {
claude: 'Claude', claude: 'Claude',
openai: 'OpenAI', openai: 'OpenAI',
@@ -848,6 +944,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 +957,9 @@ export default {
error: '错误', error: '错误',
cooldown: '冷却中' cooldown: '冷却中'
}, },
usageWindow: {
statsTitle: '5小时窗口用量统计'
},
form: { form: {
nameLabel: '账号名称', nameLabel: '账号名称',
namePlaceholder: '请输入账号名称', namePlaceholder: '请输入账号名称',
@@ -1125,6 +1228,7 @@ export default {
todayOverview: '今日概览', todayOverview: '今日概览',
cost: '费用', cost: '费用',
requests: '请求', requests: '请求',
tokens: 'Token',
highestCostDay: '最高费用日', highestCostDay: '最高费用日',
highestRequestDay: '最高请求日', highestRequestDay: '最高请求日',
date: '日期', date: '日期',
@@ -1217,8 +1321,8 @@ export default {
batchAdd: '快捷添加', batchAdd: '快捷添加',
batchInput: '代理列表', batchInput: '代理列表',
batchInputPlaceholder: batchInputPlaceholder:
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443", "每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口", batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
parsedCount: '有效 {count} 个', parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个', invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个', duplicateCount: '重复 {count} 个',
@@ -1364,6 +1468,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 +1518,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 +1545,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 +1564,7 @@ export default {
title: '发送测试邮件', title: '发送测试邮件',
description: '发送测试邮件以验证 SMTP 配置', description: '发送测试邮件以验证 SMTP 配置',
recipientEmail: '收件人邮箱', recipientEmail: '收件人邮箱',
recipientEmailPlaceholder: "test{'@'}example.com",
sendTestEmail: '发送测试邮件', sendTestEmail: '发送测试邮件',
sending: '发送中...', sending: '发送中...',
enterRecipientHint: '请输入收件人邮箱地址' enterRecipientHint: '请输入收件人邮箱地址'

View File

@@ -488,6 +488,17 @@
@apply bg-gray-900 text-gray-100; @apply bg-gray-900 text-gray-100;
@apply overflow-x-auto rounded-xl p-4; @apply overflow-x-auto rounded-xl p-4;
} }
/* ============ 表格页面布局优化 ============ */
/* 表格容器 - 默认仅支持水平滚动 */
.table-wrapper {
overflow-x: auto;
}
/* 表头固定时添加底部阴影,增强视觉层次 */
.table-wrapper thead.sticky {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
} }
@layer utilities { @layer utilities {

View File

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

View File

@@ -3,30 +3,32 @@
* 参考 CRS 项目的 format.js 实现 * 参考 CRS 项目的 format.js 实现
*/ */
import { i18n } from '@/i18n'
/** /**
* 格式化相对时间 * 格式化相对时间
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago" * @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
*/ */
export function formatRelativeTime(date: string | Date | null | undefined): string { export function formatRelativeTime(date: string | Date | null | undefined): string {
if (!date) return 'Never' if (!date) return i18n.global.t('common.time.never')
const now = new Date() const now = new Date()
const past = new Date(date) const past = new Date(date)
const diffMs = now.getTime() - past.getTime() const diffMs = now.getTime() - past.getTime()
// 处理未来时间或无效日期 // 处理未来时间或无效日期
if (diffMs < 0 || isNaN(diffMs)) return 'Never' if (diffMs < 0 || isNaN(diffMs)) return i18n.global.t('common.time.never')
const diffSecs = Math.floor(diffMs / 1000) const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60) const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60) const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24) const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) return `${diffDays}d ago` if (diffDays > 0) return i18n.global.t('common.time.daysAgo', { n: diffDays })
if (diffHours > 0) return `${diffHours}h ago` if (diffHours > 0) return i18n.global.t('common.time.hoursAgo', { n: diffHours })
if (diffMins > 0) return `${diffMins}m ago` if (diffMins > 0) return i18n.global.t('common.time.minutesAgo', { n: diffMins })
return 'Just now' return i18n.global.t('common.time.justNow')
} }
/** /**
@@ -114,3 +116,30 @@ export function formatDate(
.replace('mm', minutes) .replace('mm', minutes)
.replace('ss', seconds) .replace('ss', seconds)
} }
/**
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串,格式为 YYYY-MM-DD
*/
export function formatDateOnly(date: string | Date | null | undefined): string {
return formatDate(date, 'YYYY-MM-DD')
}
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss
*/
export function formatDateTime(date: string | Date | null | undefined): string {
return formatDate(date, 'YYYY-MM-DD HH:mm:ss')
}
/**
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串,格式为 HH:mm
*/
export function formatTime(date: string | Date | null | undefined): string {
return formatDate(date, 'HH:mm')
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadProxies" @click="loadProxies"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
@@ -35,11 +35,12 @@
</svg> </svg>
{{ t('admin.proxies.createProxy') }} {{ t('admin.proxies.createProxy') }}
</button> </button>
</div> </div>
</template>
<!-- Search and Filters --> <template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <div class="relative max-w-md flex-1">
<svg <svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
fill="none" fill="none"
@@ -60,8 +61,8 @@
class="input pl-10" class="input pl-10"
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.protocol" v-model="filters.protocol"
:options="protocolOptions" :options="protocolOptions"
@@ -76,11 +77,11 @@
class="w-36" class="w-36"
@change="loadProxies" @change="loadProxies"
/> />
</div>
</div> </div>
</div> </template>
<!-- Proxies Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="proxies" :loading="loading"> <DataTable :columns="columns" :data="proxies" :loading="loading">
<template #cell-name="{ value }"> <template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
@@ -111,8 +112,7 @@
<button <button
@click="handleTestConnection(row)" @click="handleTestConnection(row)"
:disabled="testingProxyIds.has(row.id)" :disabled="testingProxyIds.has(row.id)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
:title="t('admin.proxies.testConnection')"
> >
<svg <svg
v-if="testingProxyIds.has(row.id)" v-if="testingProxyIds.has(row.id)"
@@ -148,11 +148,11 @@
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
</button> </button>
<button <button
@click="handleEdit(row)" @click="handleEdit(row)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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 <svg
class="h-4 w-4" class="h-4 w-4"
@@ -167,11 +167,11 @@
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" 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> </svg>
<span class="text-xs">{{ t('common.edit') }}</span>
</button> </button>
<button <button
@click="handleDelete(row)" @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" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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 <svg
class="h-4 w-4" class="h-4 w-4"
@@ -186,6 +186,7 @@
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" 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> </svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -199,17 +200,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create Proxy Modal --> <!-- Create Proxy Modal -->
<Modal <Modal
@@ -291,7 +293,7 @@
v-model="createForm.host" v-model="createForm.host"
type="text" type="text"
required required
placeholder="proxy.example.com" :placeholder="t('admin.proxies.form.hostPlaceholder')"
class="input" class="input"
/> />
</div> </div>
@@ -303,7 +305,7 @@
required required
min="1" min="1"
max="65535" max="65535"
placeholder="8080" :placeholder="t('admin.proxies.form.portPlaceholder')"
class="input" class="input"
/> />
</div> </div>
@@ -577,6 +579,7 @@ import { adminAPI } from '@/api/admin'
import type { Proxy, ProxyProtocol } from '@/types' import type { Proxy, ProxyProtocol } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'

View File

@@ -1,9 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadCodes" @click="loadCodes"
:disabled="loading" :disabled="loading"
class="btn btn-secondary" class="btn btn-secondary"
@@ -26,11 +26,12 @@
<button @click="showGenerateDialog = true" class="btn btn-primary"> <button @click="showGenerateDialog = true" class="btn btn-primary">
{{ t('admin.redeem.generateCodes') }} {{ t('admin.redeem.generateCodes') }}
</button> </button>
</div> </div>
</template>
<!-- Filters and Actions --> <template #filters>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="max-w-md flex-1"> <div class="max-w-md flex-1">
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
@@ -38,8 +39,8 @@
class="input" class="input"
@input="handleSearch" @input="handleSearch"
/> />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Select <Select
v-model="filters.type" v-model="filters.type"
:options="filterTypeOptions" :options="filterTypeOptions"
@@ -55,11 +56,11 @@
<button @click="handleExportCodes" class="btn btn-secondary"> <button @click="handleExportCodes" class="btn btn-secondary">
{{ t('admin.redeem.exportCsv') }} {{ t('admin.redeem.exportCsv') }}
</button> </button>
</div>
</div> </div>
</div> </template>
<!-- Redeem Codes Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="codes" :loading="loading"> <DataTable :columns="columns" :data="codes" :loading="loading">
<template #cell-code="{ value }"> <template #cell-code="{ value }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -151,7 +152,7 @@
<template #cell-used_at="{ value }"> <template #cell-used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ <span class="text-sm text-gray-500 dark:text-dark-400">{{
value ? formatDate(value) : '-' value ? formatDateTime(value) : '-'
}}</span> }}</span>
</template> </template>
@@ -160,8 +161,7 @@
<button <button
v-if="row.status === 'unused'" v-if="row.status === 'unused'"
@click="handleDelete(row)" @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" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -171,29 +171,31 @@
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg> </svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button> </button>
<span v-else class="text-gray-400 dark:text-dark-500">-</span> <span v-else class="text-gray-400 dark:text-dark-500">-</span>
</div> </div>
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
<!-- Batch Actions --> <!-- Batch Actions -->
<div v-if="filters.status === 'unused'" class="flex justify-end"> <div v-if="filters.status === 'unused'" class="flex justify-end">
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger"> <button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
{{ t('admin.redeem.deleteAllUnused') }} {{ t('admin.redeem.deleteAllUnused') }}
</button> </button>
</div> </div>
</div> </template>
</TablePageLayout>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -416,10 +418,13 @@
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 { useClipboard } from '@/composables/useClipboard'
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'
@@ -427,6 +432,7 @@ import Select from '@/components/common/Select.vue'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const showGenerateDialog = ref(false) const showGenerateDialog = ref(false)
const showResultDialog = ref(false) const showResultDialog = ref(false)
@@ -549,10 +555,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 {
@@ -618,15 +620,12 @@ const handleGenerateCodes = async () => {
} }
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { const success = await clipboardCopy(text, t('admin.redeem.copied'))
await navigator.clipboard.writeText(text) if (success) {
copiedCode.value = text copiedCode.value = text
setTimeout(() => { setTimeout(() => {
copiedCode.value = null copiedCode.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('admin.redeem.failedToCopy'))
console.error('Error copying to clipboard:', error)
} }
} }

View File

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

View File

@@ -1,7 +1,8 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <!-- Page Header Actions -->
<template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadSubscriptions" @click="loadSubscriptions"
@@ -36,8 +37,10 @@
{{ t('admin.subscriptions.assignSubscription') }} {{ t('admin.subscriptions.assignSubscription') }}
</button> </button>
</div> </div>
</template>
<!-- Filters --> <!-- Filters -->
<template #filters>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Select <Select
v-model="filters.status" v-model="filters.status"
@@ -54,9 +57,10 @@
@change="loadSubscriptions" @change="loadSubscriptions"
/> />
</div> </div>
</template>
<!-- Subscriptions Table --> <!-- Subscriptions Table -->
<div class="card overflow-hidden"> <template #table>
<DataTable :columns="columns" :data="subscriptions" :loading="loading"> <DataTable :columns="columns" :data="subscriptions" :loading="loading">
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -222,7 +226,7 @@
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
" "
> >
{{ formatDate(value) }} {{ formatDateOnly(value) }}
</span> </span>
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500"> <div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }} {{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
@@ -253,8 +257,7 @@
<button <button
v-if="row.status === 'active'" v-if="row.status === 'active'"
@click="handleExtend(row)" @click="handleExtend(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" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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.subscriptions.extend')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -269,12 +272,12 @@
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
</button> </button>
<button <button
v-if="row.status === 'active'" v-if="row.status === 'active'"
@click="handleRevoke(row)" @click="handleRevoke(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="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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.subscriptions.revoke')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -289,6 +292,7 @@
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/> />
</svg> </svg>
<span class="text-xs">{{ t('admin.subscriptions.revoke') }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -302,9 +306,10 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <!-- Pagination -->
<template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
@@ -312,7 +317,8 @@
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Assign Subscription Modal --> <!-- Assign Subscription Modal -->
<Modal <Modal
@@ -401,7 +407,7 @@
<span class="font-medium text-gray-900 dark:text-white"> <span class="font-medium text-gray-900 dark:text-white">
{{ {{
extendingSubscription.expires_at extendingSubscription.expires_at
? formatDate(extendingSubscription.expires_at) ? formatDateOnly(extendingSubscription.expires_at)
: t('admin.subscriptions.noExpiration') : t('admin.subscriptions.noExpiration')
}} }}
</span> </span>
@@ -444,7 +450,9 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { UserSubscription, Group, User } from '@/types' import type { UserSubscription, Group, User } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { formatDateOnly } from '@/utils/format'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
@@ -640,14 +648,6 @@ const confirmRevoke = async () => {
} }
// Helper functions // Helper functions
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getDaysRemaining = (expiresAt: string): number | null => { const getDaysRemaining = (expiresAt: string): number | null => {
const now = new Date() const now = new Date()
const expires = new Date(expiresAt) const expires = new Date(expiresAt)

View File

@@ -1,10 +1,10 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <div class="space-y-6">
<!-- Summary Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests --> <!-- Total Requests -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg <svg
@@ -130,10 +130,10 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Charts Section --> <!-- Charts Section -->
<div class="space-y-4"> <div class="space-y-4">
<!-- Chart Controls --> <!-- Chart Controls -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -157,9 +157,9 @@
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters Section -->
<div class="card"> <div class="card">
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<!-- User Search --> <!-- User Search -->
<div class="min-w-[200px]"> <div class="min-w-[200px]">
@@ -229,6 +229,61 @@
/> />
</div> </div>
<!-- Model Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('usage.model') }}</label>
<Select
v-model="filters.model"
:options="modelOptions"
:placeholder="t('admin.usage.allModels')"
@change="applyFilters"
/>
</div>
<!-- Account Filter -->
<div class="min-w-[180px]">
<label class="input-label">{{ t('admin.usage.account') }}</label>
<Select
v-model="filters.account_id"
:options="accountOptions"
:placeholder="t('admin.usage.allAccounts')"
@change="applyFilters"
/>
</div>
<!-- Stream Type Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select
v-model="filters.stream"
:options="streamOptions"
:placeholder="t('admin.usage.allTypes')"
@change="applyFilters"
/>
</div>
<!-- Billing Type Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('usage.billingType') }}</label>
<Select
v-model="filters.billing_type"
:options="billingTypeOptions"
:placeholder="t('admin.usage.allBillingTypes')"
@change="applyFilters"
/>
</div>
<!-- Group Filter -->
<div class="min-w-[150px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
<Select
v-model="filters.group_id"
:options="groupOptions"
:placeholder="t('admin.usage.allGroups')"
@change="applyFilters"
/>
</div>
<!-- Date Range Filter --> <!-- Date Range Filter -->
<div> <div>
<label class="input-label">{{ t('usage.timeRange') }}</label> <label class="input-label">{{ t('usage.timeRange') }}</label>
@@ -252,9 +307,10 @@
</div> </div>
</div> </div>
<!-- Usage Table --> <!-- Table Section -->
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <div class="overflow-auto">
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ <span class="font-medium text-gray-900 dark:text-white">{{
@@ -270,10 +326,26 @@
}}</span> }}</span>
</template> </template>
<template #cell-account="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{
row.account?.name || '-'
}}</span>
</template>
<template #cell-model="{ value }"> <template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
<template #cell-group="{ row }">
<span
v-if="row.group"
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
>
{{ row.group.name }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-stream="{ row }"> <template #cell-stream="{ row }">
<span <span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
@@ -383,7 +455,11 @@
${{ row.actual_cost.toFixed(6) }} ${{ row.actual_cost.toFixed(6) }}
</span> </span>
<!-- Cost Detail Tooltip --> <!-- Cost Detail Tooltip -->
<div class="group relative"> <div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div <div
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50" class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
> >
@@ -399,39 +475,6 @@
/> />
</svg> </svg>
</div> </div>
<!-- Tooltip Content (right side) -->
<div
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
>
<div
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="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400"
>{{ (row.rate_multiplier || 1).toFixed(2) }}x</span
>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
</div>
<div
class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400"
>${{ row.actual_cost.toFixed(6) }}</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -471,10 +514,17 @@
}}</span> }}</span>
</template> </template>
<template #cell-request_id="{ row }">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
row.request_id || '-'
}}</span>
</template>
<template #empty> <template #empty>
<EmptyState :message="t('usage.noRecords')" /> <EmptyState :message="t('usage.noRecords')" />
</template> </template>
</DataTable> </DataTable>
</div>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@@ -487,6 +537,66 @@
/> />
</div> </div>
</AppLayout> </AppLayout>
<!-- Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div
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="tooltipData && tooltipData.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">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.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">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.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">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.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">${{ tooltipData.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"
>{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span
>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost.toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400"
>${{ tooltipData?.actual_cost.toFixed(6) }}</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -498,6 +608,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'
@@ -514,6 +625,11 @@ import type {
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
// Tooltip state
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Usage stats from API // Usage stats from API
const usageStats = ref<AdminUsageStatsResponse | null>(null) const usageStats = ref<AdminUsageStatsResponse | null>(null)
@@ -532,17 +648,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 +686,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 +740,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 +863,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 +876,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 +941,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 +975,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
} }
@@ -856,8 +1053,25 @@ const handleClickOutside = (event: MouseEvent) => {
} }
} }
// Tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
tooltipPosition.value.x = rect.right + 8
tooltipPosition.value.y = rect.top + rect.height / 2
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadFilterOptions()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()

View File

@@ -1,8 +1,9 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <!-- Page Header Actions -->
<div class="flex justify-end gap-3"> <template #actions>
<div class="flex justify-end gap-3">
<button <button
@click="loadUsers" @click="loadUsers"
:disabled="loading" :disabled="loading"
@@ -36,8 +37,10 @@
{{ t('admin.users.createUser') }} {{ t('admin.users.createUser') }}
</button> </button>
</div> </div>
</template>
<!-- Search and Filters --> <!-- Search and Filters -->
<template #filters>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="relative max-w-md flex-1"> <div class="relative max-w-md flex-1">
<svg <svg
@@ -78,10 +81,11 @@
/> />
</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" :actions-count="7">
<template #cell-email="{ value }"> <template #cell-email="{ value }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
@@ -135,7 +139,7 @@
:subscription-type="sub.group?.subscription_type" :subscription-type="sub.group?.subscription_type"
:rate-multiplier="sub.group?.rate_multiplier" :rate-multiplier="sub.group?.rate_multiplier"
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null" :days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''" :title="sub.expires_at ? formatDateTime(sub.expires_at) : ''"
/> />
</div> </div>
<span <span
@@ -191,27 +195,65 @@
</template> </template>
<template #cell-created_at="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row, expanded }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Toggle Status (hidden for admin users) --> <!-- 主要操作编辑和删除始终显示 -->
<button
@click="handleEdit(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
>
<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>
<span class="text-xs">{{ t('common.edit') }}</span>
</button>
<button <button
v-if="row.role !== 'admin'" v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)" @click="handleDelete(row)"
:class="[ class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
'rounded-lg p-2 transition-colors',
row.status === 'active'
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
]"
:title="
row.status === 'active'
? t('admin.users.disableUser')
: t('admin.users.enableUser')
"
> >
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button>
<!-- 次要操作展开时显示 -->
<template v-if="expanded">
<!-- Toggle Status (hidden for admin users) -->
<button
v-if="row.role !== 'admin'"
@click="handleToggleStatus(row)"
:class="[
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 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'
]"
>
<svg <svg
v-if="row.status === 'active'" v-if="row.status === 'active'"
class="h-4 w-4" class="h-4 w-4"
@@ -240,120 +282,81 @@
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
</button> <span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
<!-- Allowed Groups --> </button>
<button <!-- Allowed Groups -->
@click="handleAllowedGroups(row)" <button
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" @click="handleAllowedGroups(row)"
:title="t('admin.users.setAllowedGroups')" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
> >
<path <svg
stroke-linecap="round" class="h-4 w-4"
stroke-linejoin="round" fill="none"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> stroke-width="1.5"
</button> >
<!-- View API Keys --> <path
<button stroke-linecap="round"
@click="handleViewApiKeys(row)" stroke-linejoin="round"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
:title="t('admin.users.viewApiKeys')" />
> </svg>
<svg <span class="text-xs">{{ t('admin.users.groups') }}</span>
class="h-4 w-4" </button>
fill="none" <!-- View API Keys -->
stroke="currentColor" <button
viewBox="0 0 24 24" @click="handleViewApiKeys(row)"
stroke-width="1.5" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
> >
<path <svg
stroke-linecap="round" class="h-4 w-4"
stroke-linejoin="round" fill="none"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> stroke-width="1.5"
</button> >
<!-- Deposit --> <path
<button stroke-linecap="round"
@click="handleDeposit(row)" stroke-linejoin="round"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
:title="t('admin.users.deposit')" />
> </svg>
<svg <span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
class="h-4 w-4" </button>
fill="none" <!-- Deposit -->
stroke="currentColor" <button
viewBox="0 0 24 24" @click="handleDeposit(row)"
stroke-width="1.5" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <svg
</svg> class="h-4 w-4"
</button> fill="none"
<!-- Withdraw --> stroke="currentColor"
<button viewBox="0 0 24 24"
@click="handleWithdraw(row)" stroke-width="1.5"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400" >
:title="t('admin.users.withdraw')" <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
> </svg>
<svg <span class="text-xs">{{ t('admin.users.deposit') }}</span>
class="h-4 w-4" </button>
fill="none" <!-- Withdraw -->
stroke="currentColor" <button
viewBox="0 0 24 24" @click="handleWithdraw(row)"
stroke-width="1.5" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" /> <svg
</svg> class="h-4 w-4"
</button> fill="none"
<!-- Edit --> stroke="currentColor"
<button viewBox="0 0 24 24"
@click="handleEdit(row)" stroke-width="1.5"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" >
:title="t('common.edit')" <path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
> </svg>
<svg <span class="text-xs">{{ t('admin.users.withdraw') }}</span>
class="h-4 w-4" </button>
fill="none" </template>
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 +369,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 +380,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 +813,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 +1169,8 @@
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 { useClipboard } from '@/composables/useClipboard'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@@ -1171,6 +1178,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'
@@ -1180,6 +1188,7 @@ import Select from '@/components/common/Select.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true }, { key: 'email', label: t('admin.users.columns.user'), sortable: true },
@@ -1274,15 +1283,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 +1291,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 = ''
@@ -1316,27 +1310,23 @@ const generateEditPassword = () => {
const copyPassword = async () => { const copyPassword = async () => {
if (!createForm.password) return if (!createForm.password) return
try { const success = await clipboardCopy(createForm.password, t('admin.users.passwordCopied'))
await navigator.clipboard.writeText(createForm.password) if (success) {
passwordCopied.value = true passwordCopied.value = true
setTimeout(() => { setTimeout(() => {
passwordCopied.value = false passwordCopied.value = false
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }
const copyEditPassword = async () => { const copyEditPassword = async () => {
if (!editForm.password) return if (!editForm.password) return
try { const success = await clipboardCopy(editForm.password, t('admin.users.passwordCopied'))
await navigator.clipboard.writeText(editForm.password) if (success) {
editPasswordCopied.value = true editPasswordCopied.value = true
setTimeout(() => { setTimeout(() => {
editPasswordCopied.value = false editPasswordCopied.value = false
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Page Header Actions --> <template #actions>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="loadApiKeys" @click="loadApiKeys"
:disabled="loading" :disabled="loading"
@@ -36,9 +36,9 @@
{{ t('keys.createKey') }} {{ t('keys.createKey') }}
</button> </button>
</div> </div>
</template>
<!-- API Keys Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="apiKeys" :loading="loading"> <DataTable :columns="columns" :data="apiKeys" :loading="loading">
<template #cell-key="{ value, row }"> <template #cell-key="{ value, row }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -146,7 +146,7 @@
</template> </template>
<template #cell-created_at="{ value }"> <template #cell-created_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
@@ -154,8 +154,7 @@
<!-- Use Key Button --> <!-- Use Key Button -->
<button <button
@click="openUseKeyModal(row)" @click="openUseKeyModal(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" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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('keys.useKey')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -170,12 +169,12 @@
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/> />
</svg> </svg>
<span class="text-xs">{{ t('keys.useKey') }}</span>
</button> </button>
<!-- Import to CC Switch Button --> <!-- Import to CC Switch Button -->
<button <button
@click="importToCcswitch(row.key)" @click="importToCcswitch(row.key)"
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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('keys.importToCcSwitch')"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -190,17 +189,17 @@
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/> />
</svg> </svg>
<span class="text-xs">{{ t('keys.importToCcSwitch') }}</span>
</button> </button>
<!-- Toggle Status Button --> <!-- Toggle Status Button -->
<button <button
@click="toggleKeyStatus(row)" @click="toggleKeyStatus(row)"
:class="[ :class="[
'rounded-lg p-2 transition-colors', 'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
row.status === 'active' row.status === 'active'
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400' ? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-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('keys.disable') : t('keys.enable')"
> >
<svg <svg
v-if="row.status === 'active'" v-if="row.status === 'active'"
@@ -230,12 +229,12 @@
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<span class="text-xs">{{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }}</span>
</button> </button>
<!-- Edit Button --> <!-- Edit Button -->
<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="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -250,12 +249,12 @@
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" 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> </svg>
<span class="text-xs">{{ t('common.edit') }}</span>
</button> </button>
<!-- Delete Button --> <!-- Delete Button -->
<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="flex flex-col items-center gap-0.5 rounded-lg p-1.5 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"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -270,6 +269,7 @@
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" 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> </svg>
<span class="text-xs">{{ t('common.delete') }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -283,17 +283,18 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<Modal <Modal
@@ -492,10 +493,12 @@
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue' import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
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 +510,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
@@ -517,6 +521,7 @@ interface GroupOption {
} }
const appStore = useAppStore() const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'name', label: t('common.name'), sortable: true }, { key: 'name', label: t('common.name'), sortable: true },
@@ -613,26 +618,15 @@ const maskKey = (key: string): string => {
} }
const copyToClipboard = async (text: string, keyId: number) => { const copyToClipboard = async (text: string, keyId: number) => {
try { const success = await clipboardCopy(text, t('keys.copied'))
await navigator.clipboard.writeText(text) if (success) {
copiedKeyId.value = keyId copiedKeyId.value = keyId
setTimeout(() => { setTimeout(() => {
copiedKeyId.value = null copiedKeyId.value = null
}, 2000) }, 2000)
} catch (error) {
appStore.showError(t('common.copyFailed'))
} }
} }
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const loadApiKeys = async () => { const loadApiKeys = async () => {
loading.value = true loading.value = true
try { try {

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<template> <template>
<AppLayout> <AppLayout>
<div class="space-y-6"> <TablePageLayout>
<!-- Summary Stats Cards --> <template #actions>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Total Requests --> <!-- Total Requests -->
<div class="card p-4"> <div class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"> <div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<svg <svg
@@ -131,11 +131,12 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Filters --> <template #filters>
<div class="card"> <div class="card">
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<!-- API Key Filter --> <!-- API Key Filter -->
<div class="min-w-[180px]"> <div class="min-w-[180px]">
@@ -169,11 +170,17 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Usage Table --> <template #table>
<div class="card overflow-hidden">
<DataTable :columns="columns" :data="usageLogs" :loading="loading"> <DataTable :columns="columns" :data="usageLogs" :loading="loading">
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{
row.api_key?.name || '-'
}}</span>
</template>
<template #cell-model="{ value }"> <template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </template>
@@ -287,7 +294,11 @@
${{ row.actual_cost.toFixed(6) }} ${{ row.actual_cost.toFixed(6) }}
</span> </span>
<!-- Cost Detail Tooltip --> <!-- Cost Detail Tooltip -->
<div class="group relative"> <div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div <div
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50" class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
> >
@@ -303,39 +314,6 @@
/> />
</svg> </svg>
</div> </div>
<!-- Tooltip Content (right side) -->
<div
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
>
<div
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="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400"
>{{ (row.rate_multiplier || 1).toFixed(2) }}x</span
>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
</div>
<div
class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400"
>${{ row.actual_cost.toFixed(6) }}</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -379,18 +357,58 @@
<EmptyState :message="t('usage.noRecords')" /> <EmptyState :message="t('usage.noRecords')" />
</template> </template>
</DataTable> </DataTable>
</div> </template>
<!-- Pagination --> <template #pagination>
<Pagination <Pagination
v-if="pagination.total > 0" v-if="pagination.total > 0"
:page="pagination.page" :page="pagination.page"
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
/> />
</div> </template>
</TablePageLayout>
</AppLayout> </AppLayout>
<!-- Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div
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="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400"
>{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span
>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost.toFixed(6) }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400"
>${{ tooltipData?.actual_cost.toFixed(6) }}</span
>
</div>
</div>
<!-- Tooltip Arrow (left side) -->
<div
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
></div>
</div>
</div>
</Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -399,6 +417,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { usageAPI, keysAPI } from '@/api' import { usageAPI, keysAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
@@ -406,14 +425,21 @@ import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue' import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types' import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
// Tooltip state
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Usage stats from API // Usage stats from API
const usageStats = ref<UsageStatsResponse | null>(null) const usageStats = ref<UsageStatsResponse | null>(null)
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true }, { key: 'model', label: t('usage.model'), sortable: true },
{ key: 'stream', label: t('usage.type'), sortable: false }, { key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
@@ -505,17 +531,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 {
@@ -629,6 +644,23 @@ const exportToCSV = () => {
appStore.showSuccess(t('usage.exportSuccess')) appStore.showSuccess(t('usage.exportSuccess'))
} }
// Tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
// Position to the right of the icon, vertically centered
tooltipPosition.value.x = rect.right + 8
tooltipPosition.value.y = rect.top + rect.height / 2
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadApiKeys() loadApiKeys()

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
declare const _default: import('vite').UserConfig
export default _default