Compare commits

...

27 Commits

Author SHA1 Message Date
shaw
fb9d087838 Merge PR #62: refactor(frontend): 前端界面优化与订阅状态管理增强 2025-12-28 16:26:13 +08:00
Wesley Liddick
18c6686fed Merge branch 'main' into feature/ui-improvements-clean 2025-12-28 03:22:11 -05:00
IanShaw027
386f6da14d fix(frontend): 移除DataTable中未使用的函数和变量
- 移除未使用的 hasExpandableActions 计算属性
- 移除未使用的 toggleActionsExpanded 函数
- 修复 TypeScript 类型检查错误
2025-12-28 14:53:36 +08:00
IanShaw027
d895a2c469 refactor(frontend): 移除DataTable表头中废弃的展开/折叠按钮
- 移除操作列表头的展开/折叠按钮和图标
- 该功能已被操作列内的'更多'按钮替代
- 保留底层的展开/收起逻辑供'更多'按钮使用
2025-12-28 14:53:36 +08:00
IanShaw027
5f2d81d154 fix(frontend): 修复UI改进分支中的关键问题
- 修复RedeemView订阅刷新失败导致流程中断的问题
  将订阅刷新隔离到独立try/catch,失败时仅显示警告
- 修复DataTable resize事件监听器泄漏问题
  确保添加和移除使用同一个回调引用
- 修复订阅状态缓存导致强制刷新失效的问题
  force=true时绕过activePromise缓存,clear()清空缓存
- 修复图表主题切换后颜色不更新的问题
  添加图表ref并在主题切换时调用update()方法
2025-12-28 14:53:36 +08:00
IanShaw027
4e3499c0d7 fix(frontend): 改进订阅状态实时刷新机制
- 在 Dashboard 页面加载时强制刷新订阅状态
- 在兑换订阅卡密后立即刷新订阅状态
- 清理订阅轮询相关注释
2025-12-28 14:53:36 +08:00
IanShaw027
26cdb1805d fix(frontend): 补充缺失的BaseDialog组件 2025-12-28 14:53:36 +08:00
IanShaw027
506cb21cb1 refactor(frontend): UI/UX改进和组件优化
- DataTable组件操作列自适应
- 优化各种Modal弹窗
- 统一API调用方式(AbortSignal)
- 添加全局订阅状态管理
- 优化各管理视图的交互和布局
- 修复国际化翻译问题
2025-12-28 14:53:36 +08:00
yangjianbo
fd51ff6970 fix: 代码的核心问题是判错条件用错了层级:
- apiKeyService.GetByKey(...) 返回的“找不到 API key”在这个项目里通常会被翻译成业务错误(比如
    service.ErrApiKeyNotFound 这类 ApplicationError),而不是直接把 gorm.ErrRecordNotFound 透传到中
    间件层。
  - 因此你在中间件里用 errors.Is(err, gorm.ErrRecordNotFound) 去判断“无效 key”,很容易匹配不到(尤其
    是:后面加 Redis 缓存、换存储实现、或测试里用 stub repo 时,根本不会出现 gorm 的错误)。
  - 匹配不到时就会走到 500 Failed to validate API key,导致无效 API key 被错误地当成服务端故障返回
    500(应该是 401)。

  修复思路:中间件不要依赖 gorm 的错误,改成判断业务层错误,例如:

  if errors.Is(err, service.ErrApiKeyNotFound) {
      abortWithGoogleError(c, 401, "Invalid API key")
      return
  }

  如果你把 GetByKey 的“not found”统一封装成业务错误,这样才不会被底层实现(gorm/redis/mock)影响。
2025-12-28 14:34:05 +08:00
程序猿MT
295d71be0a Merge branch 'Wei-Shaw:main' into main 2025-12-28 13:16:57 +08:00
shaw
9bbe468c91 fix: 修复安装脚本通过 pipe 执行时 root 权限检查失效的问题
使用 `id -u` 替代 `$EUID` 进行 root 权限检查。
`$EUID` 是 bash 内置变量,在通过 pipe 执行脚本时可能不可靠。
2025-12-28 12:25:55 +08:00
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
1d1da7362b Merge branch 'Wei-Shaw:main' into main 2025-12-27 23:09:48 +08:00
yangjianbo
a8c173f043 Merge branch 'main' of https://github.com/mt21625457/aicodex2api 2025-12-27 23:09:11 +08:00
yangjianbo
97ab649d16 fix(仪表盘): 修复最近用量查询日期参数格式
问题:仪表盘“最近用量”调用 /usage 时传入完整 ISO 时间戳(含时分秒/时区),后端 start_date/end_date 仅接受 YYYY-MM-DD,导致请求参数校验失败,页面无法正常展示最近用量。

解决:
- loadRecentUsage 改为传入 YYYY-MM-DD(从 toISOString() 取日期部分),与后端参数格式约定保持一致
- 补充注释说明:后端会将 end_date 扩展到当日结束时间,以及 toISOString() 为 UTC 可能带来的统计口径差异
- 同步修正 usageAPI.getByDateRange 的参数注释,避免后续误用

验证:npm -C frontend run build
2025-12-27 23:08:38 +08:00
程序猿MT
d3e73f1260 feat: 增加caddy 安全配置示例 (#57)
feat 增加 caddy 示例安全反向代理
2025-12-27 21:36:26 +08:00
程序猿MT
f3da4b202e Merge branch 'Wei-Shaw:main' into main 2025-12-27 21:32:08 +08:00
yangjianbo
530f6ad81c feat: 增加caddy 安全配置示例 2025-12-27 21:31:06 +08:00
yangjianbo
3252c378aa feat 增加 caddy 示例安全反向代理 2025-12-27 21:30:14 +08:00
程序猿MT
937b1fb05d Merge branch 'Wei-Shaw:main' into main 2025-12-27 20:01:56 +08:00
程序猿MT
7bdb0e6b12 Merge branch 'Wei-Shaw:main' into main 2025-12-27 16:48:31 +08:00
程序猿MT
17c3cb2403 Merge branch 'Wei-Shaw:main' into main 2025-12-27 16:05:11 +08:00
程序猿MT
a413fa3b17 Merge branch 'Wei-Shaw:main' into main 2025-12-27 10:58:30 +08:00
yangjianbo
3a8dbf5a99 feat:
golang 1.24-> 1.25
node 20 -> node 24
具体提升请查看官方文档
2025-12-27 10:57:53 +08:00
70 changed files with 2275 additions and 772 deletions

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

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

@@ -18,8 +18,10 @@ func DefaultModels() []Model {
methods := []string{"generateContent", "streamGenerateContent"} methods := []string{"generateContent", "streamGenerateContent"}
return []Model{ return []Model{
{Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods},
{Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods},
{Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods},

View File

@@ -11,11 +11,11 @@ type Model struct {
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. // DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
var DefaultModels = []Model{ var DefaultModels = []Model{
{ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""},
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""},
{ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""},
{ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""},
} }
// DefaultTestModel is the default model to preselect in test flows. // DefaultTestModel is the default model to preselect in test flows.
const DefaultTestModel = "gemini-2.5-pro" const DefaultTestModel = "gemini-3-pro-preview"

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

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

@@ -8,7 +8,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
@@ -30,7 +29,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, service.ErrApiKeyNotFound) {
abortWithGoogleError(c, 401, "Invalid API key") abortWithGoogleError(c, 401, "Invalid API key")
return return
} }

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"encoding/json"
"strconv" "strconv"
"time" "time"
) )
@@ -94,6 +95,9 @@ func (a *Account) GetCredential(key string) string {
switch val := v.(type) { switch val := v.(type) {
case string: case string:
return val return val
case json.Number:
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
return val.String()
case float64: case float64:
// JSON 解析后数字默认为 float64 // JSON 解析后数字默认为 float64
return strconv.FormatInt(int64(val), 10) return strconv.FormatInt(int64(val), 10)

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

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

@@ -103,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
@@ -118,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,
@@ -131,6 +133,7 @@ func NewGatewayService(
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
identityService: identityService, identityService: identityService,
httpUpstream: httpUpstream, httpUpstream: httpUpstream,
deferredService: deferredService,
} }
} }
@@ -1095,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

@@ -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,
} }
} }
@@ -772,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,19 +43,13 @@ 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 {
var expiresAt int64 s := account.GetCredential("expires_at")
if s == "" {
return false
}
// 方式1: 通过 GetCredential 获取(处理字符串和部分数字类型) expiresAt, err := strconv.ParseInt(s, 10, 64)
if s := account.GetCredential("expires_at"); s != "" { if err != nil {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return false
}
expiresAt = v
} else if v, ok := account.Credentials["expires_at"].(float64); ok {
// 方式2: 直接获取 float64处理某些 JSON 解码器将数字解析为 float64 的情况)
expiresAt = int64(v)
} else {
return false return false
} }

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,
) )

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

@@ -404,7 +404,9 @@ configure_server() {
# Check if running as root # Check if running as root
check_root() { check_root() {
if [ "$EUID" -ne 0 ]; then # Use 'id -u' instead of $EUID for better compatibility
# $EUID may not work reliably when script is piped to bash
if [ "$(id -u)" -ne 0 ]; then
print_error "$(msg 'run_as_root')" print_error "$(msg 'run_as_root')"
exit 1 exit 1
fi fi

View File

@@ -2,12 +2,14 @@
import { RouterView, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import Toast from '@/components/common/Toast.vue' import Toast from '@/components/common/Toast.vue'
import { useAppStore } from '@/stores' import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
import { getSetupStatus } from '@/api/setup' import { getSetupStatus } from '@/api/setup'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
/** /**
* Update favicon dynamically * Update favicon dynamically
@@ -46,6 +48,24 @@ watch(
{ immediate: true } { immediate: true }
) )
// Watch for authentication state and manage subscription data
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
if (isAuthenticated) {
// User logged in: preload subscriptions and start polling
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to preload subscriptions:', error)
})
subscriptionStore.startPolling()
} else {
// User logged out: clear data and stop polling
subscriptionStore.clear()
}
},
{ immediate: true }
)
onMounted(async () => { onMounted(async () => {
// Check if setup is needed // Check if setup is needed
try { try {

View File

@@ -30,6 +30,9 @@ export async function list(
type?: string type?: string
status?: string status?: string
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Account>> { ): Promise<PaginatedResponse<Account>> {
const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', { const { data } = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
@@ -37,7 +40,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -26,6 +26,9 @@ export async function list(
platform?: GroupPlatform platform?: GroupPlatform
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
is_exclusive?: boolean is_exclusive?: boolean
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Group>> { ): Promise<PaginatedResponse<Group>> {
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', { const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
@@ -33,7 +36,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -20,6 +20,9 @@ export async function list(
protocol?: string protocol?: string
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<Proxy>> { ): Promise<PaginatedResponse<Proxy>> {
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', { const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
@@ -27,7 +30,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -25,6 +25,9 @@ export async function list(
type?: RedeemCodeType type?: RedeemCodeType
status?: 'active' | 'used' | 'expired' | 'unused' status?: 'active' | 'used' | 'expired' | 'unused'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<RedeemCode>> { ): Promise<PaginatedResponse<RedeemCode>> {
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', { const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
@@ -32,7 +35,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -27,6 +27,9 @@ export async function list(
status?: 'active' | 'expired' | 'revoked' status?: 'active' | 'expired' | 'revoked'
user_id?: number user_id?: number
group_id?: number group_id?: number
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<UserSubscription>> { ): Promise<PaginatedResponse<UserSubscription>> {
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>( const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
@@ -36,7 +39,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
} }
) )
return data return data

View File

@@ -41,9 +41,13 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
* @param params - Query parameters for filtering and pagination * @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function list(params: AdminUsageQueryParams): Promise<PaginatedResponse<UsageLog>> { export async function list(
params: AdminUsageQueryParams,
options?: { signal?: AbortSignal }
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
params params,
signal: options?.signal
}) })
return data return data
} }

View File

@@ -11,6 +11,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 20) * @param pageSize - Items per page (default: 20)
* @param filters - Optional filters (status, role, search) * @param filters - Optional filters (status, role, search)
* @param options - Optional request options (signal)
* @returns Paginated list of users * @returns Paginated list of users
*/ */
export async function list( export async function list(
@@ -20,6 +21,9 @@ export async function list(
status?: 'active' | 'disabled' status?: 'active' | 'disabled'
role?: 'admin' | 'user' role?: 'admin' | 'user'
search?: string search?: string
},
options?: {
signal?: AbortSignal
} }
): Promise<PaginatedResponse<User>> { ): Promise<PaginatedResponse<User>> {
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', { const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
@@ -27,7 +31,8 @@ export async function list(
page, page,
page_size: pageSize, page_size: pageSize,
...filters ...filters
} },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -10,14 +10,19 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons
* List all API keys for current user * List all API keys for current user
* @param page - Page number (default: 1) * @param page - Page number (default: 1)
* @param pageSize - Items per page (default: 10) * @param pageSize - Items per page (default: 10)
* @param options - Optional request options
* @returns Paginated list of API keys * @returns Paginated list of API keys
*/ */
export async function list( export async function list(
page: number = 1, page: number = 1,
pageSize: number = 10 pageSize: number = 10,
options?: {
signal?: AbortSignal
}
): Promise<PaginatedResponse<ApiKey>> { ): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', { const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
params: { page, page_size: pageSize } params: { page, page_size: pageSize },
signal: options?.signal
}) })
return data return data
} }

View File

@@ -90,8 +90,12 @@ export async function list(
* @param params - Query parameters for filtering and pagination * @param params - Query parameters for filtering and pagination
* @returns Paginated list of usage logs * @returns Paginated list of usage logs
*/ */
export async function query(params: UsageQueryParams): Promise<PaginatedResponse<UsageLog>> { export async function query(
params: UsageQueryParams,
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', { const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
...config,
params params
}) })
return data return data
@@ -148,8 +152,8 @@ export async function getStatsByDateRange(
/** /**
* Get usage by date range * Get usage by date range
* @param startDate - Start date (ISO format) * @param startDate - Start date (YYYY-MM-DD format)
* @param endDate - End date (ISO format) * @param endDate - End date (YYYY-MM-DD format)
* @param apiKeyId - Optional API key ID filter * @param apiKeyId - Optional API key ID filter
* @returns Usage logs within date range * @returns Usage logs within date range
*/ */
@@ -232,15 +236,22 @@ export interface BatchApiKeysUsageResponse {
/** /**
* Get batch usage stats for user's own API keys * Get batch usage stats for user's own API keys
* @param apiKeyIds - Array of API key IDs * @param apiKeyIds - Array of API key IDs
* @param options - Optional request options
* @returns Usage stats map keyed by API key ID * @returns Usage stats map keyed by API key ID
*/ */
export async function getDashboardApiKeysUsage( export async function getDashboardApiKeysUsage(
apiKeyIds: number[] apiKeyIds: number[],
options?: {
signal?: AbortSignal
}
): Promise<BatchApiKeysUsageResponse> { ): Promise<BatchApiKeysUsageResponse> {
const { data } = await apiClient.post<BatchApiKeysUsageResponse>( const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
'/usage/dashboard/api-keys-usage', '/usage/dashboard/api-keys-usage',
{ {
api_key_ids: apiKeyIds api_key_ids: apiKeyIds
},
{
signal: options?.signal
} }
) )
return data return data

View File

@@ -1,5 +1,10 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.usageStatistics')" size="2xl" @close="handleClose"> <BaseDialog
:show="show"
:title="t('admin.accounts.usageStatistics')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-6"> <div class="space-y-6">
<!-- Account Info Header --> <!-- Account Info Header -->
<div <div
@@ -521,7 +526,7 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -539,7 +544,7 @@ import {
Filler Filler
} from 'chart.js' } from 'chart.js'
import { Line } from 'vue-chartjs' import { Line } from 'vue-chartjs'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'

View File

@@ -1,8 +1,8 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.testAccountConnection')" :title="t('admin.accounts.testAccountConnection')"
size="md" width="normal"
@close="handleClose" @close="handleClose"
> >
<div class="space-y-4"> <div class="space-y-4">
@@ -273,13 +273,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 BaseDialog from '@/components/common/BaseDialog.vue'
import { useClipboard } from '@/composables/useClipboard' 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'

View File

@@ -1,6 +1,11 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose"> <BaseDialog
<form class="space-y-5" @submit.prevent="handleSubmit"> :show="show"
:title="t('admin.accounts.bulkEdit.title')"
width="wide"
@close="handleClose"
>
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
<!-- Info --> <!-- Info -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20"> <div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400"> <p class="text-sm text-blue-700 dark:text-blue-400">
@@ -19,20 +24,30 @@
<!-- Base URL (API Key only) --> <!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label> <label
id="bulk-edit-base-url-label"
class="input-label mb-0"
for="bulk-edit-base-url-enabled"
>
{{ t('admin.accounts.baseUrl') }}
</label>
<input <input
v-model="enableBaseUrl" v-model="enableBaseUrl"
id="bulk-edit-base-url-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-base-url"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model="baseUrl" v-model="baseUrl"
id="bulk-edit-base-url"
type="text" type="text"
:disabled="!enableBaseUrl" :disabled="!enableBaseUrl"
class="input" class="input"
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'" :class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')" :placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
aria-labelledby="bulk-edit-base-url-label"
/> />
<p class="input-hint"> <p class="input-hint">
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }} {{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
@@ -42,15 +57,28 @@
<!-- Model restriction --> <!-- Model restriction -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label> <label
id="bulk-edit-model-restriction-label"
class="input-label mb-0"
for="bulk-edit-model-restriction-enabled"
>
{{ t('admin.accounts.modelRestriction') }}
</label>
<input <input
v-model="enableModelRestriction" v-model="enableModelRestriction"
id="bulk-edit-model-restriction-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-model-restriction-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableModelRestriction && 'pointer-events-none opacity-50'"> <div
id="bulk-edit-model-restriction-body"
:class="!enableModelRestriction && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-model-restriction-label"
>
<!-- Mode Toggle --> <!-- Mode Toggle -->
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
<button <button
@@ -267,19 +295,27 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label> <label
id="bulk-edit-custom-error-codes-label"
class="input-label mb-0"
for="bulk-edit-custom-error-codes-enabled"
>
{{ t('admin.accounts.customErrorCodes') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }} {{ t('admin.accounts.customErrorCodesHint') }}
</p> </p>
</div> </div>
<input <input
v-model="enableCustomErrorCodes" v-model="enableCustomErrorCodes"
id="bulk-edit-custom-error-codes-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-custom-error-codes-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div v-if="enableCustomErrorCodes" class="space-y-3"> <div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3">
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"> <div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
<p class="text-xs text-amber-700 dark:text-amber-400"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <svg
@@ -321,11 +357,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
v-model="customErrorCodeInput" v-model="customErrorCodeInput"
id="bulk-edit-custom-error-code-input"
type="number" type="number"
min="100" min="100"
max="599" max="599"
class="input flex-1" class="input flex-1"
:placeholder="t('admin.accounts.enterErrorCode')" :placeholder="t('admin.accounts.enterErrorCode')"
aria-labelledby="bulk-edit-custom-error-codes-label"
@keyup.enter="addCustomErrorCode" @keyup.enter="addCustomErrorCode"
/> />
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode"> <button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
@@ -374,20 +412,26 @@
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1 pr-4"> <div class="flex-1 pr-4">
<label class="input-label mb-0">{{ <label
t('admin.accounts.interceptWarmupRequests') id="bulk-edit-intercept-warmup-label"
}}</label> class="input-label mb-0"
for="bulk-edit-intercept-warmup-enabled"
>
{{ t('admin.accounts.interceptWarmupRequests') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }} {{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p> </p>
</div> </div>
<input <input
v-model="enableInterceptWarmup" v-model="enableInterceptWarmup"
id="bulk-edit-intercept-warmup-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-intercept-warmup-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div v-if="enableInterceptWarmup" class="mt-3"> <div v-if="enableInterceptWarmup" id="bulk-edit-intercept-warmup-body" class="mt-3">
<button <button
type="button" type="button"
:class="[ :class="[
@@ -409,15 +453,27 @@
<!-- Proxy --> <!-- Proxy -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label> <label
id="bulk-edit-proxy-label"
class="input-label mb-0"
for="bulk-edit-proxy-enabled"
>
{{ t('admin.accounts.proxy') }}
</label>
<input <input
v-model="enableProxy" v-model="enableProxy"
id="bulk-edit-proxy-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-proxy-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableProxy && 'pointer-events-none opacity-50'"> <div id="bulk-edit-proxy-body" :class="!enableProxy && 'pointer-events-none opacity-50'">
<ProxySelector v-model="proxyId" :proxies="proxies" /> <ProxySelector
v-model="proxyId"
:proxies="proxies"
aria-labelledby="bulk-edit-proxy-label"
/>
</div> </div>
</div> </div>
@@ -425,38 +481,58 @@
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label> <label
id="bulk-edit-concurrency-label"
class="input-label mb-0"
for="bulk-edit-concurrency-enabled"
>
{{ t('admin.accounts.concurrency') }}
</label>
<input <input
v-model="enableConcurrency" v-model="enableConcurrency"
id="bulk-edit-concurrency-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-concurrency"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model.number="concurrency" v-model.number="concurrency"
id="bulk-edit-concurrency"
type="number" type="number"
min="1" min="1"
:disabled="!enableConcurrency" :disabled="!enableConcurrency"
class="input" class="input"
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'" :class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-concurrency-label"
/> />
</div> </div>
<div> <div>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label> <label
id="bulk-edit-priority-label"
class="input-label mb-0"
for="bulk-edit-priority-enabled"
>
{{ t('admin.accounts.priority') }}
</label>
<input <input
v-model="enablePriority" v-model="enablePriority"
id="bulk-edit-priority-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-priority"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<input <input
v-model.number="priority" v-model.number="priority"
id="bulk-edit-priority"
type="number" type="number"
min="1" min="1"
:disabled="!enablePriority" :disabled="!enablePriority"
class="input" class="input"
:class="!enablePriority && 'cursor-not-allowed opacity-50'" :class="!enablePriority && 'cursor-not-allowed opacity-50'"
aria-labelledby="bulk-edit-priority-label"
/> />
</div> </div>
</div> </div>
@@ -464,39 +540,69 @@
<!-- Status --> <!-- Status -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('common.status') }}</label> <label
id="bulk-edit-status-label"
class="input-label mb-0"
for="bulk-edit-status-enabled"
>
{{ t('common.status') }}
</label>
<input <input
v-model="enableStatus" v-model="enableStatus"
id="bulk-edit-status-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-status"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableStatus && 'pointer-events-none opacity-50'"> <div id="bulk-edit-status" :class="!enableStatus && 'pointer-events-none opacity-50'">
<Select v-model="status" :options="statusOptions" /> <Select
v-model="status"
:options="statusOptions"
aria-labelledby="bulk-edit-status-label"
/>
</div> </div>
</div> </div>
<!-- Groups --> <!-- Groups -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('nav.groups') }}</label> <label
id="bulk-edit-groups-label"
class="input-label mb-0"
for="bulk-edit-groups-enabled"
>
{{ t('nav.groups') }}
</label>
<input <input
v-model="enableGroups" v-model="enableGroups"
id="bulk-edit-groups-enabled"
type="checkbox" type="checkbox"
aria-controls="bulk-edit-groups"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/> />
</div> </div>
<div :class="!enableGroups && 'pointer-events-none opacity-50'"> <div id="bulk-edit-groups" :class="!enableGroups && 'pointer-events-none opacity-50'">
<GroupSelector v-model="groupIds" :groups="groups" /> <GroupSelector
v-model="groupIds"
:groups="groups"
aria-labelledby="bulk-edit-groups-label"
/>
</div> </div>
</div> </div>
</form>
<!-- Action buttons --> <template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose"> <button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="bulk-edit-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -522,8 +628,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -532,7 +638,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Proxy, Group } from '@/types' import type { Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'

View File

@@ -1,5 +1,10 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="xl" @close="handleClose"> <BaseDialog
:show="show"
:title="t('admin.accounts.createAccount')"
width="wide"
@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">
@@ -34,7 +39,12 @@
</div> </div>
<!-- Step 1: Basic Info --> <!-- Step 1: Basic Info -->
<form v-if="step === 1" @submit.prevent="handleSubmit" class="space-y-5"> <form
v-if="step === 1"
id="create-account-form"
@submit.prevent="handleSubmit"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.accounts.accountName') }}</label> <label class="input-label">{{ t('admin.accounts.accountName') }}</label>
<input <input
@@ -963,11 +973,40 @@
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" /> <GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
<div class="flex justify-end gap-3 pt-4"> </form>
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<template #footer>
<div v-if="step === 1" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="create-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -997,28 +1036,7 @@
}} }}
</button> </button>
</div> </div>
</form> <div v-else class="flex justify-between gap-3">
<!-- Step 2: OAuth Authorization -->
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="goBackToBasicInfo"> <button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
{{ t('common.back') }} {{ t('common.back') }}
</button> </button>
@@ -1056,8 +1074,8 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -1073,7 +1091,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'

View File

@@ -1,6 +1,16 @@
<template> <template>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose"> <BaseDialog
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5"> :show="show"
:title="t('admin.accounts.editAccount')"
width="wide"
@close="handleClose"
>
<form
v-if="account"
id="edit-account-form"
@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>
<input v-model="form.name" type="text" required class="input" /> <input v-model="form.name" type="text" required class="input" />
@@ -459,11 +469,19 @@
<!-- Group Selection --> <!-- Group Selection -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" /> <GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div v-if="account" class="flex justify-end gap-3">
<button @click="handleClose" type="button" class="btn btn-secondary"> <button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="edit-account-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -487,8 +505,8 @@
{{ submitting ? t('admin.accounts.updating') : t('common.update') }} {{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -497,7 +515,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30" class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30"
> >
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">

View File

@@ -1,11 +1,11 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.reAuthorizeAccount')" :title="t('admin.accounts.reAuthorizeAccount')"
size="lg" width="wide"
@close="handleClose" @close="handleClose"
> >
<div v-if="account" class="space-y-5"> <div v-if="account" class="space-y-4">
<!-- Account Info --> <!-- Account Info -->
<div <div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700" class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
@@ -53,8 +53,8 @@
</div> </div>
<!-- Add Method Selection (Claude only) --> <!-- Add Method Selection (Claude only) -->
<div v-if="isAnthropic"> <fieldset v-if="isAnthropic" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4"> <div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center"> <label class="flex cursor-pointer items-center">
<input <input
@@ -79,11 +79,11 @@
}}</span> }}</span>
</label> </label>
</div> </div>
</div> </fieldset>
<!-- Gemini OAuth Type Selection --> <!-- Gemini OAuth Type Selection -->
<div v-if="isGemini"> <fieldset v-if="isGemini" class="border-0 p-0">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label> <legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-2 gap-3"> <div class="mt-2 grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
@@ -187,7 +187,7 @@
</div> </div>
</button> </button>
</div> </div>
</div> </fieldset>
<OAuthAuthorizationFlow <OAuthAuthorizationFlow
ref="oauthFlowRef" ref="oauthFlowRef"
@@ -207,7 +207,10 @@
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
/> />
<div class="flex justify-between gap-3 pt-4"> </div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose"> <button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
@@ -245,8 +248,8 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -262,7 +265,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types' import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component

View File

@@ -1,12 +1,12 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('admin.accounts.syncFromCrsTitle')" :title="t('admin.accounts.syncFromCrsTitle')"
size="lg" width="normal"
close-on-click-outside close-on-click-outside
@close="handleClose" @close="handleClose"
> >
<div class="space-y-4"> <form id="sync-from-crs-form" class="space-y-4" @submit.prevent="handleSync">
<div class="text-sm text-gray-600 dark:text-dark-300"> <div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }} {{ t('admin.accounts.syncFromCrsDesc') }}
</div> </div>
@@ -84,25 +84,30 @@
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose"> <button class="btn btn-secondary" type="button" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button class="btn btn-primary" :disabled="syncing" @click="handleSync"> <button
class="btn btn-primary"
type="submit"
form="sync-from-crs-form"
:disabled="syncing"
>
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }} {{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'

View File

@@ -0,0 +1,118 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="modal-overlay"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
@click.self="handleClose"
>
<!-- Modal panel -->
<div :class="['modal-content', widthClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
aria-label="Close modal"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
type DialogWidth = 'narrow' | 'normal' | 'wide' | 'extra-wide' | 'full'
interface Props {
show: boolean
title: string
width?: DialogWidth
closeOnEscape?: boolean
closeOnClickOutside?: boolean
}
interface Emits {
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
width: 'normal',
closeOnEscape: true,
closeOnClickOutside: false
})
const emit = defineEmits<Emits>()
const widthClasses = computed(() => {
const widths: Record<DialogWidth, string> = {
narrow: 'max-w-md',
normal: 'max-w-lg',
wide: 'max-w-4xl',
'extra-wide': 'max-w-6xl',
full: 'max-w-7xl'
}
return widths[props.width]
})
const handleClose = () => {
if (props.closeOnClickOutside) {
emit('close')
}
}
const handleEscape = (event: KeyboardEvent) => {
if (props.show && props.closeOnEscape && event.key === 'Escape') {
emit('close')
}
}
// Prevent body scroll when modal is open
watch(
() => props.show,
(isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<Modal :show="show" :title="title" size="sm" @close="handleCancel"> <BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div> </div>
@@ -27,13 +27,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue' import BaseDialog from './BaseDialog.vue'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -24,37 +24,6 @@
> >
<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"
@@ -182,8 +151,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新 // 等待DOM更新
nextTick(() => { nextTick(() => {
// 测量所有按钮的总宽度 // 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button') const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
if (buttons.length <= 2) { if (actionItems.length <= 2) {
actionsColumnNeedsExpanding.value = false actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded actionsExpanded.value = wasExpanded
return return
@@ -191,9 +160,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度包括gap // 计算所有按钮的总宽度包括gap
let totalWidth = 0 let totalWidth = 0
buttons.forEach((btn, index) => { actionItems.forEach((item, index) => {
totalWidth += (btn as HTMLElement).offsetWidth totalWidth += (item as HTMLElement).offsetWidth
if (index < buttons.length - 1) { if (index < actionItems.length - 1) {
totalWidth += 4 // gap-1 = 4px totalWidth += 4 // gap-1 = 4px
} }
}) })
@@ -211,6 +180,7 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化 // 监听尺寸变化
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let resizeHandler: (() => void) | null = null
onMounted(() => { onMounted(() => {
checkScrollable() checkScrollable()
@@ -223,17 +193,20 @@ onMounted(() => {
resizeObserver.observe(tableWrapperRef.value) resizeObserver.observe(tableWrapperRef.value)
} else { } else {
// 降级方案:不支持 ResizeObserver 时使用 window resize // 降级方案:不支持 ResizeObserver 时使用 window resize
const handleResize = () => { resizeHandler = () => {
checkScrollable() checkScrollable()
checkActionsColumnWidth() checkActionsColumnWidth()
} }
window.addEventListener('resize', handleResize) window.addEventListener('resize', resizeHandler)
} }
}) })
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
window.removeEventListener('resize', checkScrollable) if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
}) })
interface Props { interface Props {
@@ -298,26 +271,6 @@ const sortedData = computed(() => {
}) })
}) })
// 检查是否有可展开的操作列
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(() => { const hasSelectColumn = computed(() => {
return props.columns.length > 0 && props.columns[0].key === 'select' return props.columns.length > 0 && props.columns[0].key === 'select'

View File

@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') 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
if (props.page !== 1) {
emit('update:page', 1)
}
} }
</script> </script>

View File

@@ -30,7 +30,11 @@
</button> </button>
<Transition name="select-dropdown"> <Transition name="select-dropdown">
<div v-if="isOpen" class="select-dropdown"> <div
v-if="isOpen"
ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input --> <!-- Search input -->
<div v-if="searchable" class="select-search"> <div v-if="searchable" class="select-search">
<svg <svg
@@ -141,6 +145,8 @@ const isOpen = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const getOptionValue = ( const getOptionValue = (
option: SelectOption | Record<string, unknown> option: SelectOption | Record<string, unknown>
@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
return getOptionValue(option) === props.modelValue return getOptionValue(option) === props.modelValue
} }
const calculateDropdownPosition = () => {
if (!containerRef.value) return
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => { const toggle = () => {
if (props.disabled) return if (props.disabled) return
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) { if (isOpen.value) {
nextTick(() => { calculateDropdownPosition()
searchInputRef.value?.focus() if (props.searchable) {
}) nextTick(() => {
searchInputRef.value?.focus()
})
}
} }
} }
@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply overflow-hidden; @apply overflow-hidden;
} }
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search { .select-search {
@apply flex items-center gap-2 px-3 py-2; @apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700; @apply border-b border-gray-100 dark:border-dark-700;
@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from, .select-dropdown-enter-from,
.select-dropdown-leave-to { .select-dropdown-leave-to {
opacity: 0; opacity: 0;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
transform: translateY(-8px); transform: translateY(-8px);
} }
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style> </style>

View File

@@ -178,17 +178,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions' import { useSubscriptionStore } from '@/stores'
import type { UserSubscription } from '@/types' import type { UserSubscription } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const subscriptionStore = useSubscriptionStore()
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const tooltipOpen = ref(false) const tooltipOpen = ref(false)
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0) // Use store data instead of local state
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
const hasActiveSubscriptions = computed(() => subscriptionStore.hasActiveSubscriptions)
const displaySubscriptions = computed(() => { const displaySubscriptions = computed(() => {
// Sort by most usage (highest percentage first) // Sort by most usage (highest percentage first)
@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
} }
} }
async function loadSubscriptions() {
try {
loading.value = true
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
activeSubscriptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
loadSubscriptions() // Trigger initial fetch if not already loaded
// The actual data loading is handled by App.vue globally
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to load subscriptions in SubscriptionProgressMini:', error)
})
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside) document.removeEventListener('click', handleClickOutside)
}) })
// Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,6 +2,7 @@
export { default as DataTable } from './DataTable.vue' export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue' export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue' export { default as Modal } from './Modal.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue' export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue' export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue' export { default as Toast } from './Toast.vue'

View File

@@ -1,8 +1,8 @@
<template> <template>
<Modal <BaseDialog
:show="show" :show="show"
:title="t('keys.useKeyModal.title')" :title="t('keys.useKeyModal.title')"
size="lg" width="wide"
@close="emit('close')" @close="emit('close')"
> >
<div class="space-y-4"> <div class="space-y-4">
@@ -112,13 +112,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 BaseDialog from '@/components/common/BaseDialog.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import type { GroupPlatform } from '@/types' import type { GroupPlatform } from '@/types'

View File

@@ -119,6 +119,7 @@ export default {
info: 'Info', info: 'Info',
active: 'Active', active: 'Active',
inactive: 'Inactive', inactive: 'Inactive',
more: 'More',
close: 'Close', close: 'Close',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled', disabled: 'Disabled',
@@ -344,6 +345,8 @@ export default {
allApiKeys: 'All API Keys', allApiKeys: 'All API Keys',
timeRange: 'Time Range', timeRange: 'Time Range',
exportCsv: 'Export CSV', exportCsv: 'Export CSV',
exporting: 'Exporting...',
preparingExport: 'Preparing export...',
model: 'Model', model: 'Model',
type: 'Type', type: 'Type',
tokens: 'Tokens', tokens: 'Tokens',
@@ -364,6 +367,7 @@ export default {
failedToLoad: 'Failed to load usage logs', failedToLoad: 'Failed to load usage logs',
noDataToExport: 'No data to export', noDataToExport: 'No data to export',
exportSuccess: 'Usage data exported successfully', exportSuccess: 'Usage data exported successfully',
exportFailed: 'Failed to export usage data',
billingType: 'Billing', billingType: 'Billing',
balance: 'Balance', balance: 'Balance',
subscription: 'Subscription' subscription: 'Subscription'
@@ -406,7 +410,8 @@ export default {
subscriptionDays: '{days} days', subscriptionDays: '{days} days',
days: ' days', days: ' days',
codeRedeemSuccess: 'Code redeemed successfully!', codeRedeemSuccess: 'Code redeemed successfully!',
failedToRedeem: 'Failed to redeem code. Please check the code and try again.' failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.'
}, },
// Profile // Profile
@@ -427,6 +432,7 @@ export default {
updating: 'Updating...', updating: 'Updating...',
updateSuccess: 'Profile updated successfully', updateSuccess: 'Profile updated successfully',
updateFailed: 'Failed to update profile', updateFailed: 'Failed to update profile',
usernameRequired: 'Username is required',
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
newPassword: 'New Password', newPassword: 'New Password',

View File

@@ -116,6 +116,7 @@ export default {
info: '提示', info: '提示',
active: '启用', active: '启用',
inactive: '禁用', inactive: '禁用',
more: '更多',
close: '关闭', close: '关闭',
enabled: '已启用', enabled: '已启用',
disabled: '已禁用', disabled: '已禁用',
@@ -340,6 +341,8 @@ export default {
allApiKeys: '全部密钥', allApiKeys: '全部密钥',
timeRange: '时间范围', timeRange: '时间范围',
exportCsv: '导出 CSV', exportCsv: '导出 CSV',
exporting: '导出中...',
preparingExport: '正在准备导出...',
model: '模型', model: '模型',
type: '类型', type: '类型',
tokens: 'Token', tokens: 'Token',
@@ -360,6 +363,7 @@ export default {
failedToLoad: '加载使用记录失败', failedToLoad: '加载使用记录失败',
noDataToExport: '没有可导出的数据', noDataToExport: '没有可导出的数据',
exportSuccess: '使用数据导出成功', exportSuccess: '使用数据导出成功',
exportFailed: '使用数据导出失败',
billingType: '消费类型', billingType: '消费类型',
balance: '余额', balance: '余额',
subscription: '订阅' subscription: '订阅'
@@ -402,7 +406,8 @@ export default {
subscriptionDays: '{days} 天', subscriptionDays: '{days} 天',
days: '天', days: '天',
codeRedeemSuccess: '兑换成功!', codeRedeemSuccess: '兑换成功!',
failedToRedeem: '兑换失败,请检查兑换码后重试。' failedToRedeem: '兑换失败,请检查兑换码后重试。',
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。'
}, },
// Profile // Profile
@@ -423,6 +428,7 @@ export default {
updating: '更新中...', updating: '更新中...',
updateSuccess: '资料更新成功', updateSuccess: '资料更新成功',
updateFailed: '资料更新失败', updateFailed: '资料更新失败',
usernameRequired: '用户名不能为空',
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
newPassword: '新密码', newPassword: '新密码',

View File

@@ -5,6 +5,7 @@
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useAppStore } from './app' export { useAppStore } from './app'
export { useSubscriptionStore } from './subscriptions'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'

View File

@@ -0,0 +1,140 @@
/**
* Subscription Store
* Global state management for user subscriptions with caching and deduplication
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
// Cache TTL: 60 seconds
const CACHE_TTL_MS = 60_000
// Request generation counter to invalidate stale in-flight responses
let requestGeneration = 0
export const useSubscriptionStore = defineStore('subscriptions', () => {
// State
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const loaded = ref(false)
const lastFetchedAt = ref<number | null>(null)
// In-flight request deduplication
let activePromise: Promise<UserSubscription[]> | null = null
// Auto-refresh interval
let pollerInterval: ReturnType<typeof setInterval> | null = null
// Computed
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
/**
* Fetch active subscriptions with caching and deduplication
* @param force - Force refresh even if cache is valid
*/
async function fetchActiveSubscriptions(force = false): Promise<UserSubscription[]> {
const now = Date.now()
// Return cached data if valid
if (
!force &&
loaded.value &&
lastFetchedAt.value &&
now - lastFetchedAt.value < CACHE_TTL_MS
) {
return activeSubscriptions.value
}
// Return in-flight request if exists (deduplication)
if (activePromise && !force) {
return activePromise
}
const currentGeneration = ++requestGeneration
// Start new request
loading.value = true
const requestPromise = subscriptionsAPI
.getActiveSubscriptions()
.then((data) => {
if (currentGeneration === requestGeneration) {
activeSubscriptions.value = data
loaded.value = true
lastFetchedAt.value = Date.now()
}
return data
})
.catch((error) => {
console.error('Failed to fetch active subscriptions:', error)
throw error
})
.finally(() => {
if (activePromise === requestPromise) {
loading.value = false
activePromise = null
}
})
activePromise = requestPromise
return activePromise
}
/**
* Start auto-refresh polling
*/
function startPolling() {
if (pollerInterval) return
pollerInterval = setInterval(() => {
fetchActiveSubscriptions(true).catch((error) => {
console.error('Subscription polling failed:', error)
})
}, 5 * 60 * 1000)
}
/**
* Stop auto-refresh polling
*/
function stopPolling() {
if (pollerInterval) {
clearInterval(pollerInterval)
pollerInterval = null
}
}
/**
* Clear all subscription data and stop polling
*/
function clear() {
requestGeneration++
activePromise = null
activeSubscriptions.value = []
loaded.value = false
lastFetchedAt.value = null
stopPolling()
}
/**
* Invalidate cache (force next fetch to reload)
*/
function invalidateCache() {
lastFetchedAt.value = null
}
return {
// State
activeSubscriptions,
loading,
hasActiveSubscriptions,
// Actions
fetchActiveSubscriptions,
startPolling,
stopPolling,
clear,
invalidateCache
}
})

View File

@@ -307,6 +307,35 @@
@apply flex items-center justify-end gap-3; @apply flex items-center justify-end gap-3;
} }
/* ============ Dialog ============ */
.dialog-overlay {
@apply fixed inset-0 z-50;
@apply bg-black/40 dark:bg-black/60;
@apply flex items-center justify-center p-4;
}
.dialog-container {
@apply flex w-full flex-col;
@apply max-h-[90vh];
@apply rounded-2xl bg-white dark:bg-dark-800;
@apply shadow-xl;
}
.dialog-header {
@apply border-b border-gray-100 px-6 py-4 dark:border-dark-700;
@apply flex items-center justify-between;
}
.dialog-body {
@apply overflow-y-auto px-6 py-4;
}
.dialog-footer {
@apply border-t border-gray-100 px-6 py-4 dark:border-dark-700;
@apply bg-gray-50/60 dark:bg-dark-900/40;
@apply flex items-center justify-end gap-3;
}
/* ============ Toast 通知 ============ */ /* ============ Toast 通知 ============ */
.toast { .toast {
@apply fixed right-4 top-4 z-[100]; @apply fixed right-4 top-4 z-[100];

View File

@@ -165,7 +165,7 @@
</div> </div>
</div> </div>
<DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6"> <DataTable :columns="columns" :data="accounts" :loading="loading">
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input <input
type="checkbox" type="checkbox"
@@ -275,9 +275,9 @@
</span> </span>
</template> </template>
<template #cell-actions="{ row, expanded }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- 主要操作编辑和删除始终显示 --> <!-- Edit Button -->
<button <button
@click="handleEdit(row)" @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" 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"
@@ -297,6 +297,8 @@
</svg> </svg>
<span class="text-xs">{{ t('common.edit') }}</span> <span class="text-xs">{{ t('common.edit') }}</span>
</button> </button>
<!-- Delete Button -->
<button <button
@click="handleDelete(row)" @click="handleDelete(row)"
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" 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"
@@ -317,131 +319,28 @@
<span class="text-xs">{{ t('common.delete') }}</span> <span class="text-xs">{{ t('common.delete') }}</span>
</button> </button>
<!-- 次要操作展开时显示 --> <!-- More Actions Menu Trigger -->
<template v-if="expanded"> <button
<!-- Reset Status button for error accounts --> :ref="(el) => setActionButtonRef(row.id, el)"
<button @click="openActionMenu(row)"
v-if="row.status === 'error'" class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
@click="handleResetStatus(row)" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
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 <svg
class="h-4 w-4" class="h-4 w-4"
fill="none" fill="none"
stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/> />
</svg> </svg>
<span class="text-xs">{{ t('admin.accounts.resetStatus') }}</span> <span class="text-xs">{{ t('common.more') }}</span>
</button> </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>
@@ -463,6 +362,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
@@ -537,11 +437,61 @@
@close="showBulkEditModal = false" @close="showBulkEditModal = false"
@updated="handleBulkUpdated" @updated="handleBulkUpdated"
/> />
<!-- Action Menu (Teleported) -->
<Teleport to="body">
<div
v-if="activeMenuId !== null && menuPosition"
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
>
<div class="py-1">
<template v-for="account in accounts" :key="account.id">
<template v-if="account.id === activeMenuId">
<button
@click="handleTest(account); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.testConnection') }}
</button>
<button
@click="handleViewStats(account); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="handleReAuth(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700">
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="handleRefreshToken(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700">
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h5M20 20v-5h-5M4 4l16 16" /></svg>
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited(account) || isOverloaded(account)" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="handleResetStatus(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited(account) || isOverloaded(account)" @click="handleClearRateLimit(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</template>
</div>
</div>
</Teleport>
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, 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 { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@@ -628,6 +578,7 @@ const pagination = reactive({
total: 0, total: 0,
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
// Modal states // Modal states
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -647,6 +598,49 @@ const statsAccount = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null) const togglingSchedulable = ref<number | null>(null)
const bulkDeleting = ref(false) const bulkDeleting = ref(false)
// Action Menu State
const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (accountId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(accountId, el)
} else {
actionButtonRefs.value.delete(accountId)
}
}
const openActionMenu = (account: Account) => {
if (activeMenuId.value === account.id) {
closeActionMenu()
} else {
const buttonEl = actionButtonRefs.value.get(account.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
// Position menu to the left of the button, slightly below
menuPosition.value = {
top: rect.bottom + 4,
left: rect.right - 208 // w-52 is 208px
}
}
activeMenuId.value = account.id
}
}
const closeActionMenu = () => {
activeMenuId.value = null
menuPosition.value = null
}
// Close menu when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
closeActionMenu()
}
}
// Bulk selection // Bulk selection
const selectedAccountIds = ref<number[]>([]) const selectedAccountIds = ref<number[]>([])
const selectCurrentPageAccounts = () => { const selectCurrentPageAccounts = () => {
@@ -668,6 +662,9 @@ const isOverloaded = (account: Account): boolean => {
// Data loading // Data loading
const loadAccounts = async () => { const loadAccounts = async () => {
abortController?.abort()
const currentAbortController = new AbortController()
abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.accounts.list(pagination.page, pagination.page_size, { const response = await adminAPI.accounts.list(pagination.page, pagination.page_size, {
@@ -675,15 +672,24 @@ const loadAccounts = async () => {
type: filters.type || undefined, type: filters.type || undefined,
status: filters.status || undefined, status: filters.status || undefined,
search: searchQuery.value || undefined search: searchQuery.value || undefined
}, {
signal: currentAbortController.signal
}) })
if (currentAbortController.signal.aborted) return
accounts.value = response.items accounts.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.accounts.failedToLoad')) appStore.showError(t('admin.accounts.failedToLoad'))
console.error('Error loading accounts:', error) console.error('Error loading accounts:', error)
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
}
} }
} }
@@ -720,6 +726,12 @@ const handlePageChange = (page: number) => {
loadAccounts() loadAccounts()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadAccounts()
}
const handleCrsSynced = () => { const handleCrsSynced = () => {
showCrsSyncModal.value = false showCrsSyncModal.value = false
loadAccounts() loadAccounts()
@@ -909,5 +921,12 @@ onMounted(() => {
loadAccounts() loadAccounts()
loadProxies() loadProxies()
loadGroups() loadGroups()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
abortController?.abort()
abortController = null
document.removeEventListener('click', handleClickOutside)
}) })
</script> </script>

View File

@@ -223,18 +223,19 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create Group Modal --> <!-- Create Group Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.groups.createGroup')" :title="t('admin.groups.createGroup')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<form @submit.prevent="handleCreateGroup" class="space-y-5"> <form id="create-group-form" @submit.prevent="handleCreateGroup" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input <input
@@ -345,11 +346,19 @@
</div> </div>
</div> </div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="create-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -373,17 +382,22 @@
{{ submitting ? t('admin.groups.creating') : t('common.create') }} {{ submitting ? t('admin.groups.creating') : t('common.create') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Edit Group Modal --> <!-- Edit Group Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.groups.editGroup')" :title="t('admin.groups.editGroup')"
size="lg" width="normal"
@close="closeEditModal" @close="closeEditModal"
> >
<form v-if="editingGroup" @submit.prevent="handleUpdateGroup" class="space-y-5"> <form
v-if="editingGroup"
id="edit-group-form"
@submit.prevent="handleUpdateGroup"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.name') }}</label> <label class="input-label">{{ t('admin.groups.form.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" /> <input v-model="editForm.name" type="text" required class="input" />
@@ -490,11 +504,19 @@
</div> </div>
</div> </div>
</form>
<template #footer>
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="edit-group-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -518,8 +540,8 @@
{{ submitting ? t('admin.groups.updating') : t('common.update') }} {{ submitting ? t('admin.groups.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -546,7 +568,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.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 BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
@@ -616,6 +638,8 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showEditModal = ref(false) const showEditModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
@@ -660,21 +684,33 @@ const deleteConfirmMessage = computed(() => {
}) })
const loadGroups = async () => { const loadGroups = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
const { signal } = currentController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
platform: (filters.platform as GroupPlatform) || undefined, platform: (filters.platform as GroupPlatform) || undefined,
status: filters.status as any, status: filters.status as any,
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
}) }, { signal })
if (signal.aborted) return
groups.value = response.items groups.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.groups.failedToLoad')) appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading groups:', error) console.error('Error loading groups:', error)
} finally { } finally {
loading.value = false if (abortController === currentController && !signal.aborted) {
loading.value = false
}
} }
} }
@@ -683,6 +719,12 @@ const handlePageChange = (page: number) => {
loadGroups() loadGroups()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadGroups()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createForm.name = '' createForm.name = ''

View File

@@ -209,15 +209,16 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create Proxy Modal --> <!-- Create Proxy Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.proxies.createProxy')" :title="t('admin.proxies.createProxy')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<!-- Tab Switch --> <!-- Tab Switch -->
@@ -271,7 +272,12 @@
</div> </div>
<!-- Standard Add Form --> <!-- Standard Add Form -->
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5"> <form
v-if="createMode === 'standard'"
id="create-proxy-form"
@submit.prevent="handleCreateProxy"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.proxies.name') }}</label> <label class="input-label">{{ t('admin.proxies.name') }}</label>
<input <input
@@ -329,34 +335,6 @@
/> />
</div> </div>
<div class="flex justify-end gap-3 pt-4">
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
</div>
</form> </form>
<!-- Batch Add Form --> <!-- Batch Add Form -->
@@ -435,11 +413,44 @@
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
v-if="createMode === 'standard'"
type="submit"
form="create-proxy-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg
v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ submitting ? t('admin.proxies.creating') : t('common.create') }}
</button>
<button
v-else
@click="handleBatchCreate" @click="handleBatchCreate"
type="button" type="button"
:disabled="submitting || batchParseResult.valid === 0" :disabled="submitting || batchParseResult.valid === 0"
@@ -472,17 +483,22 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
<!-- Edit Proxy Modal --> <!-- Edit Proxy Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.proxies.editProxy')" :title="t('admin.proxies.editProxy')"
size="lg" width="normal"
@close="closeEditModal" @close="closeEditModal"
> >
<form v-if="editingProxy" @submit.prevent="handleUpdateProxy" class="space-y-5"> <form
v-if="editingProxy"
id="edit-proxy-form"
@submit.prevent="handleUpdateProxy"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.proxies.name') }}</label> <label class="input-label">{{ t('admin.proxies.name') }}</label>
<input v-model="editForm.name" type="text" required class="input" /> <input v-model="editForm.name" type="text" required class="input" />
@@ -526,11 +542,20 @@
<Select v-model="editForm.status" :options="editStatusOptions" /> <Select v-model="editForm.status" :options="editStatusOptions" />
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
v-if="editingProxy"
type="submit"
form="edit-proxy-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -554,8 +579,8 @@
{{ submitting ? t('admin.proxies.updating') : t('common.update') }} {{ submitting ? t('admin.proxies.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -582,7 +607,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.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 BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
@@ -682,22 +707,44 @@ const editForm = reactive({
status: 'active' as 'active' | 'inactive' status: 'active' as 'active' | 'inactive'
}) })
let abortController: AbortController | null = null
const isAbortError = (error: unknown) => {
if (!error || typeof error !== 'object') return false
const maybeError = error as { name?: string; code?: string }
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
}
const loadProxies = async () => { const loadProxies = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, { const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
protocol: filters.protocol || undefined, protocol: filters.protocol || undefined,
status: filters.status as any, status: filters.status as any,
search: searchQuery.value || undefined search: searchQuery.value || undefined
}) }, { signal: currentAbortController.signal })
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
return
}
proxies.value = response.items proxies.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('admin.proxies.failedToLoad')) appStore.showError(t('admin.proxies.failedToLoad'))
console.error('Error loading proxies:', error) console.error('Error loading proxies:', error)
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
abortController = null
}
} }
} }
@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
loadProxies() loadProxies()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadProxies()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createMode.value = 'standard' createMode.value = 'standard'

View File

@@ -186,6 +186,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
<!-- Batch Actions --> <!-- Batch Actions -->
@@ -542,6 +543,8 @@ const pagination = reactive({
pages: 0 pages: 0
}) })
let abortController: AbortController | null = null
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showDeleteUnusedDialog = ref(false) const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null) const deletingCode = ref<RedeemCode | null>(null)
@@ -556,21 +559,46 @@ const generateForm = reactive({
}) })
const loadCodes = async () => { const loadCodes = async () => {
if (abortController) {
abortController.abort()
}
const currentController = new AbortController()
abortController = currentController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.redeem.list(pagination.page, pagination.page_size, { const response = await adminAPI.redeem.list(
type: filters.type as RedeemCodeType, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined {
}) type: filters.type as RedeemCodeType,
status: filters.status as any,
search: searchQuery.value || undefined
},
{
signal: currentController.signal
}
)
if (currentController.signal.aborted) {
return
}
codes.value = response.items codes.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (
currentController.signal.aborted ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
appStore.showError(t('admin.redeem.failedToLoad')) appStore.showError(t('admin.redeem.failedToLoad'))
console.error('Error loading redeem codes:', error) console.error('Error loading redeem codes:', error)
} finally { } finally {
loading.value = false if (abortController === currentController && !currentController.signal.aborted) {
loading.value = false
abortController = null
}
} }
} }
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
loadCodes() loadCodes()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadCodes()
}
const handleGenerateCodes = async () => { const handleGenerateCodes = async () => {
// 订阅类型必须选择分组 // 订阅类型必须选择分组
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {

View File

@@ -316,18 +316,23 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Assign Subscription Modal --> <!-- Assign Subscription Modal -->
<Modal <BaseDialog
:show="showAssignModal" :show="showAssignModal"
:title="t('admin.subscriptions.assignSubscription')" :title="t('admin.subscriptions.assignSubscription')"
size="lg" width="normal"
@close="closeAssignModal" @close="closeAssignModal"
> >
<form @submit.prevent="handleAssignSubscription" class="space-y-5"> <form
id="assign-subscription-form"
@submit.prevent="handleAssignSubscription"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label> <label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<Select <Select
@@ -351,12 +356,18 @@
<input v-model.number="assignForm.validity_days" type="number" min="1" class="input" /> <input v-model.number="assignForm.validity_days" type="number" min="1" class="input" />
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p> <p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeAssignModal" type="button" class="btn btn-secondary"> <button @click="closeAssignModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="assign-subscription-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -380,18 +391,19 @@
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }} {{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Extend Subscription Modal --> <!-- Extend Subscription Modal -->
<Modal <BaseDialog
:show="showExtendModal" :show="showExtendModal"
:title="t('admin.subscriptions.extendSubscription')" :title="t('admin.subscriptions.extendSubscription')"
size="md" width="narrow"
@close="closeExtendModal" @close="closeExtendModal"
> >
<form <form
v-if="extendingSubscription" v-if="extendingSubscription"
id="extend-subscription-form"
@submit.prevent="handleExtendSubscription" @submit.prevent="handleExtendSubscription"
class="space-y-5" class="space-y-5"
> >
@@ -417,17 +429,23 @@
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label> <label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
<input v-model.number="extendForm.days" type="number" min="1" required class="input" /> <input v-model.number="extendForm.days" type="number" min="1" required class="input" />
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div v-if="extendingSubscription" class="flex justify-end gap-3">
<button @click="closeExtendModal" type="button" class="btn btn-secondary"> <button @click="closeExtendModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="extend-subscription-form"
:disabled="submitting"
class="btn btn-primary"
>
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }} {{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Revoke Confirmation Dialog --> <!-- Revoke Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -455,7 +473,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.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 BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
const groups = ref<Group[]>([]) const groups = ref<Group[]>([])
const users = ref<User[]>([]) const users = ref<User[]>([])
const loading = ref(false) const loading = ref(false)
let abortController: AbortController | null = null
const filters = reactive({ const filters = reactive({
status: '', status: '',
group_id: '' group_id: ''
@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email }))) const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email })))
const loadSubscriptions = async () => { const loadSubscriptions = async () => {
if (abortController) {
abortController.abort()
}
const requestController = new AbortController()
abortController = requestController
const { signal } = requestController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, { const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
status: (filters.status as any) || undefined, status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined group_id: filters.group_id ? parseInt(filters.group_id) : undefined
}, {
signal
}) })
if (signal.aborted || abortController !== requestController) return
subscriptions.value = response.items subscriptions.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error: any) {
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.subscriptions.failedToLoad')) appStore.showError(t('admin.subscriptions.failedToLoad'))
console.error('Error loading subscriptions:', error) console.error('Error loading subscriptions:', error)
} finally { } finally {
loading.value = false if (abortController === requestController) {
loading.value = false
abortController = null
}
} }
} }
@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
loadSubscriptions() loadSubscriptions()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadSubscriptions()
}
const closeAssignModal = () => { const closeAssignModal = () => {
showAssignModal.value = false showAssignModal.value = false
assignForm.user_id = null assignForm.user_id = null

View File

@@ -224,7 +224,7 @@
v-model="filters.api_key_id" v-model="filters.api_key_id"
:options="apiKeyOptions" :options="apiKeyOptions"
:placeholder="t('usage.allApiKeys')" :placeholder="t('usage.allApiKeys')"
:disabled="!selectedUser && apiKeys.length === 0" searchable
@change="applyFilters" @change="applyFilters"
/> />
</div> </div>
@@ -236,6 +236,7 @@
v-model="filters.model" v-model="filters.model"
:options="modelOptions" :options="modelOptions"
:placeholder="t('admin.usage.allModels')" :placeholder="t('admin.usage.allModels')"
searchable
@change="applyFilters" @change="applyFilters"
/> />
</div> </div>
@@ -534,6 +535,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</div> </div>
</AppLayout> </AppLayout>
@@ -666,6 +668,7 @@ const models = ref<string[]>([])
const accounts = ref<any[]>([]) const accounts = ref<any[]>([])
const groups = ref<any[]>([]) const groups = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
let abortController: AbortController | null = null
// User search state // User search state
const userSearchKeyword = ref('') const userSearchKeyword = ref('')
@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
const selectedUser = ref<SimpleUser | null>(null) const selectedUser = ref<SimpleUser | null>(null)
let searchTimeout: ReturnType<typeof setTimeout> | null = null let searchTimeout: ReturnType<typeof setTimeout> | null = null
// API Key options computed from selected user's keys // API Key options computed from loaded keys
const apiKeyOptions = computed(() => { const apiKeyOptions = computed(() => {
return [ return [
{ value: null, label: t('usage.allApiKeys') }, { value: null, label: t('usage.allApiKeys') },
@@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => {
filters.value.api_key_id = undefined filters.value.api_key_id = undefined
// Load API keys for selected user // Load API keys for selected user
await loadApiKeysForUser(user.id) await loadApiKeys(user.id)
applyFilters() applyFilters()
} }
@@ -807,10 +810,11 @@ const clearUserFilter = () => {
filters.value.user_id = undefined filters.value.user_id = undefined
filters.value.api_key_id = undefined filters.value.api_key_id = undefined
apiKeys.value = [] apiKeys.value = []
loadApiKeys()
applyFilters() applyFilters()
} }
const loadApiKeysForUser = async (userId: number) => { const loadApiKeys = async (userId?: number) => {
try { try {
apiKeys.value = await adminAPI.usage.searchApiKeys(userId) apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
} catch (error) { } catch (error) {
@@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => {
return value.toLocaleString() return value.toLocaleString()
} }
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === 'AbortError') {
return true
}
if (typeof error === 'object' && error !== null) {
const maybeError = error as { code?: string; name?: string }
return maybeError.code === 'ERR_CANCELED' || maybeError.name === 'CanceledError'
}
return false
}
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true loading.value = true
try { try {
const params: AdminUsageQueryParams = { const params: AdminUsageQueryParams = {
@@ -872,17 +893,23 @@ const loadUsageLogs = async () => {
...filters.value ...filters.value
} }
const response = await adminAPI.usage.list(params) const response = await adminAPI.usage.list(params, { signal })
if (signal.aborted) {
return
}
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) {
if (signal.aborted || isAbortError(error)) {
return
}
appStore.showError(t('usage.failedToLoad')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
loading.value = false if (!signal.aborted && abortController === controller) {
loading.value = false
}
} }
} }
@@ -944,27 +971,37 @@ const applyFilters = () => {
// Load filter options // Load filter options
const loadFilterOptions = async () => { const loadFilterOptions = async () => {
try { try {
// Load accounts const [accountsResponse, groupsResponse] = await Promise.all([
const accountsResponse = await adminAPI.accounts.list(1, 1000) adminAPI.accounts.list(1, 1000),
adminAPI.groups.list(1, 1000)
])
accounts.value = accountsResponse.items || [] accounts.value = accountsResponse.items || []
// Load groups
const groupsResponse = await adminAPI.groups.list(1, 1000)
groups.value = groupsResponse.items || [] groups.value = groupsResponse.items || []
} catch (error) { } catch (error) {
console.error('Failed to load filter options:', error) console.error('Failed to load filter options:', error)
} }
await loadModelOptions()
} }
// Extract unique models from usage logs const loadModelOptions = async () => {
const extractModelsFromLogs = () => { try {
const uniqueModels = new Set<string>() const endDate = new Date()
usageLogs.value.forEach(log => { const startDateRange = new Date(endDate)
if (log.model) { startDateRange.setDate(startDateRange.getDate() - 29)
uniqueModels.add(log.model) const response = await adminAPI.dashboard.getModelStats({
} start_date: startDateRange.toISOString().split('T')[0],
}) end_date: endDate.toISOString().split('T')[0]
models.value = Array.from(uniqueModels).sort() })
const uniqueModels = new Set<string>()
response.models?.forEach((stat) => {
if (stat.model) {
uniqueModels.add(stat.model)
}
})
models.value = Array.from(uniqueModels).sort()
} catch (error) {
console.error('Failed to load model options:', error)
}
} }
const resetFilters = () => { const resetFilters = () => {
@@ -987,6 +1024,7 @@ const resetFilters = () => {
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() initializeDateRange()
pagination.value.page = 1 pagination.value.page = 1
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => {
loadUsageLogs() loadUsageLogs()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadUsageLogs()
}
const exportToCSV = () => { const exportToCSV = () => {
if (usageLogs.value.length === 0) { if (usageLogs.value.length === 0) {
appStore.showWarning(t('usage.noDataToExport')) appStore.showWarning(t('usage.noDataToExport'))
@@ -1072,6 +1116,7 @@ const hideTooltip = () => {
onMounted(() => { onMounted(() => {
initializeDateRange() initializeDateRange()
loadFilterOptions() loadFilterOptions()
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -1083,5 +1128,8 @@ onUnmounted(() => {
if (searchTimeout) { if (searchTimeout) {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
} }
if (abortController) {
abortController.abort()
}
}) })
</script> </script>

View File

@@ -198,12 +198,13 @@
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
</template> </template>
<template #cell-actions="{ row, expanded }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- 主要操作编辑和删除始终显示 --> <!-- Edit Button -->
<button <button
@click="handleEdit(row)" @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" class="flex h-8 w-8 items-center justify-center rounded-lg 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"
@@ -218,145 +219,29 @@
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
v-if="row.role !== 'admin'"
@click="handleDelete(row)"
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"
>
<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> </button>
<!-- 次要操作展开时显示 --> <!-- More Actions Menu Trigger -->
<template v-if="expanded"> <button
<!-- Toggle Status (hidden for admin users) --> :ref="(el) => setActionButtonRef(row.id, el)"
<button @click="openActionMenu(row)"
v-if="row.role !== 'admin'" class="action-menu-trigger flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
@click="handleToggleStatus(row)" :class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
: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'" class="h-5 w-5"
class="h-4 w-4"
fill="none" fill="none"
stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5" stroke-width="1.5"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
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="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
/> />
</svg> </svg>
<svg </button>
v-else
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 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
</button>
<!-- Allowed Groups -->
<button
@click="handleAllowedGroups(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="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span class="text-xs">{{ t('admin.users.groups') }}</span>
</button>
<!-- View API Keys -->
<button
@click="handleViewApiKeys(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="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
</button>
<!-- Deposit -->
<button
@click="handleDeposit(row)"
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"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
</button>
<!-- Withdraw -->
<button
@click="handleWithdraw(row)"
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"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
</svg>
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
</button>
</template>
</div> </div>
</template> </template>
@@ -379,18 +264,121 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Action Menu (Teleported) -->
<Teleport to="body">
<div
v-if="activeMenuId !== null && menuPosition"
class="action-menu-content fixed z-[9999] w-48 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
>
<div class="py-1">
<template v-for="user in users" :key="user.id">
<template v-if="user.id === activeMenuId">
<!-- View API Keys -->
<button
@click="handleViewApiKeys(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11.536 16.207l-1.414 1.414a2 2 0 01-2.828 0l-1.414-1.414a2 2 0 010-2.828l-1.414-1.414a2 2 0 010-2.828l1.414-1.414L10.257 6.257A6 6 0 1121 11.257V11.257" />
</svg>
{{ t('admin.users.apiKeys') }}
</button>
<!-- Allowed Groups -->
<button
@click="handleAllowedGroups(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{{ t('admin.users.groups') }}
</button>
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Deposit -->
<button
@click="handleDeposit(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
{{ t('admin.users.deposit') }}
</button>
<!-- Withdraw -->
<button
@click="handleWithdraw(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
{{ t('admin.users.withdraw') }}
</button>
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Toggle Status (not for admin) -->
<button
v-if="user.role !== 'admin'"
@click="handleToggleStatus(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
v-if="user.status === 'active'"
class="h-4 w-4 text-orange-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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
v-else
class="h-4 w-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ user.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}
</button>
<!-- Delete (not for admin) -->
<button
v-if="user.role !== 'admin'"
@click="handleDelete(user); closeActionMenu()"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
{{ t('common.delete') }}
</button>
</template>
</template>
</div>
</div>
</Teleport>
<!-- Create User Modal --> <!-- Create User Modal -->
<Modal <BaseDialog
:show="showCreateModal" :show="showCreateModal"
:title="t('admin.users.createUser')" :title="t('admin.users.createUser')"
size="lg" width="normal"
@close="closeCreateModal" @close="closeCreateModal"
> >
<form @submit.prevent="handleCreateUser" class="space-y-5"> <form id="create-user-form" @submit.prevent="handleCreateUser" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('admin.users.email') }}</label> <label class="input-label">{{ t('admin.users.email') }}</label>
<input <input
@@ -512,12 +500,19 @@
<input v-model.number="createForm.concurrency" type="number" class="input" /> <input v-model.number="createForm.concurrency" type="number" class="input" />
</div> </div>
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeCreateModal" type="button" class="btn btn-secondary"> <button @click="closeCreateModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="create-user-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -541,17 +536,22 @@
{{ submitting ? t('admin.users.creating') : t('common.create') }} {{ submitting ? t('admin.users.creating') : t('common.create') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<Modal <BaseDialog
:show="showEditModal" :show="showEditModal"
:title="t('admin.users.editUser')" :title="t('admin.users.editUser')"
size="lg" width="normal"
@close="closeEditModal" @close="closeEditModal"
> >
<form v-if="editingUser" @submit.prevent="handleUpdateUser" class="space-y-5"> <form
v-if="editingUser"
id="edit-user-form"
@submit.prevent="handleUpdateUser"
class="space-y-5"
>
<div> <div>
<label class="input-label">{{ t('admin.users.email') }}</label> <label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="editForm.email" type="email" class="input" /> <input v-model="editForm.email" type="email" class="input" />
@@ -664,11 +664,19 @@
<input v-model.number="editForm.concurrency" type="number" class="input" /> <input v-model.number="editForm.concurrency" type="number" class="input" />
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeEditModal" type="button" class="btn btn-secondary"> <button @click="closeEditModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button
type="submit"
form="edit-user-form"
:disabled="submitting"
class="btn btn-primary"
>
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -692,14 +700,14 @@
{{ submitting ? t('admin.users.updating') : t('common.update') }} {{ submitting ? t('admin.users.updating') : t('common.update') }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- View API Keys Modal --> <!-- View API Keys Modal -->
<Modal <BaseDialog
:show="showApiKeysModal" :show="showApiKeysModal"
:title="t('admin.users.userApiKeys')" :title="t('admin.users.userApiKeys')"
size="xl" width="wide"
@close="closeApiKeysModal" @close="closeApiKeysModal"
> >
<div v-if="viewingUser" class="space-y-4"> <div v-if="viewingUser" class="space-y-4">
@@ -828,13 +836,13 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
<!-- Allowed Groups Modal --> <!-- Allowed Groups Modal -->
<Modal <BaseDialog
:show="showAllowedGroupsModal" :show="showAllowedGroupsModal"
:title="t('admin.users.setAllowedGroups')" :title="t('admin.users.setAllowedGroups')"
size="lg" width="normal"
@close="closeAllowedGroupsModal" @close="closeAllowedGroupsModal"
> >
<div v-if="allowedGroupsUser" class="space-y-4"> <div v-if="allowedGroupsUser" class="space-y-4">
@@ -994,16 +1002,21 @@
</button> </button>
</div> </div>
</template> </template>
</Modal> </BaseDialog>
<!-- Deposit/Withdraw Modal --> <!-- Deposit/Withdraw Modal -->
<Modal <BaseDialog
:show="showBalanceModal" :show="showBalanceModal"
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" :title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
size="md" width="narrow"
@close="closeBalanceModal" @close="closeBalanceModal"
> >
<form v-if="balanceUser" @submit.prevent="handleBalanceSubmit" class="space-y-5"> <form
v-if="balanceUser"
id="balance-form"
@submit.prevent="handleBalanceSubmit"
class="space-y-5"
>
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"> <div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div <div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30" class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
@@ -1098,12 +1111,16 @@
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 pt-4"> </form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeBalanceModal" type="button" class="btn btn-secondary"> <button @click="closeBalanceModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button <button
type="submit" type="submit"
form="balance-form"
:disabled=" :disabled="
balanceSubmitting || balanceSubmitting ||
!balanceForm.amount || !balanceForm.amount ||
@@ -1148,8 +1165,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -1166,7 +1183,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, 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' import { useClipboard } from '@/composables/useClipboard'
@@ -1181,7 +1198,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.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 BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
@@ -1244,6 +1261,63 @@ const viewingUser = ref<User | null>(null)
const userApiKeys = ref<ApiKey[]>([]) const userApiKeys = ref<ApiKey[]>([])
const loadingApiKeys = ref(false) const loadingApiKeys = ref(false)
const passwordCopied = ref(false) const passwordCopied = ref(false)
let abortController: AbortController | null = null
// Action Menu State
const activeMenuId = ref<number | null>(null)
const menuPosition = ref<{ top: number; left: number } | null>(null)
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
if (el instanceof HTMLElement) {
actionButtonRefs.value.set(userId, el)
} else {
actionButtonRefs.value.delete(userId)
}
}
const openActionMenu = (user: User) => {
if (activeMenuId.value === user.id) {
closeActionMenu()
} else {
const buttonEl = actionButtonRefs.value.get(user.id)
if (buttonEl) {
const rect = buttonEl.getBoundingClientRect()
const menuWidth = 192
const menuHeight = 240
const padding = 8
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const left = Math.min(
Math.max(rect.right - menuWidth, padding),
Math.max(viewportWidth - menuWidth - padding, padding)
)
let top = rect.bottom + 4
if (top + menuHeight > viewportHeight - padding) {
top = Math.max(rect.top - menuHeight - 4, padding)
}
// Position menu near the trigger, clamped to viewport
menuPosition.value = {
top,
left
}
}
activeMenuId.value = user.id
}
}
const closeActionMenu = () => {
activeMenuId.value = null
menuPosition.value = null
}
// Close menu when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
closeActionMenu()
}
}
// Allowed groups modal state // Allowed groups modal state
const showAllowedGroupsModal = ref(false) const showAllowedGroupsModal = ref(false)
@@ -1331,13 +1405,25 @@ const copyEditPassword = async () => {
} }
const loadUsers = async () => { const loadUsers = async () => {
abortController?.abort()
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const response = await adminAPI.users.list(pagination.page, pagination.page_size, { const response = await adminAPI.users.list(
role: filters.role as any, pagination.page,
status: filters.status as any, pagination.page_size,
search: searchQuery.value || undefined {
}) role: filters.role as any,
status: filters.status as any,
search: searchQuery.value || undefined
},
{ signal }
)
if (signal.aborted) {
return
}
users.value = response.items users.value = response.items
pagination.total = response.total pagination.total = response.total
pagination.pages = response.pages pagination.pages = response.pages
@@ -1347,16 +1433,28 @@ const loadUsers = async () => {
const userIds = response.items.map((u) => u.id) const userIds = response.items.map((u) => u.id)
try { try {
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds) const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
if (signal.aborted) {
return
}
usageStats.value = usageResponse.stats usageStats.value = usageResponse.stats
} catch (e) { } catch (e) {
if (signal.aborted) {
return
}
console.error('Failed to load usage stats:', e) console.error('Failed to load usage stats:', e)
} }
} }
} catch (error) { } catch (error) {
const errorInfo = error as { name?: string; code?: string }
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('admin.users.failedToLoad')) appStore.showError(t('admin.users.failedToLoad'))
console.error('Error loading users:', error) console.error('Error loading users:', error)
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
}
} }
} }
@@ -1374,6 +1472,12 @@ const handlePageChange = (page: number) => {
loadUsers() loadUsers()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.page_size = pageSize
pagination.page = 1
loadUsers()
}
const closeCreateModal = () => { const closeCreateModal = () => {
showCreateModal.value = false showCreateModal.value = false
createForm.email = '' createForm.email = ''
@@ -1620,5 +1724,10 @@ const handleBalanceSubmit = async () => {
onMounted(() => { onMounted(() => {
loadUsers() loadUsers()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
}) })
</script> </script>

View File

@@ -39,6 +39,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"

View File

@@ -66,6 +66,7 @@
v-model="formData.email" v-model="formData.email"
type="email" type="email"
required required
autofocus
autocomplete="email" autocomplete="email"
:disabled="isLoading" :disabled="isLoading"
class="input pl-11" class="input pl-11"

View File

@@ -563,13 +563,13 @@ const installing = ref(false)
const confirmPassword = ref('') const confirmPassword = ref('')
const serviceReady = ref(false) const serviceReady = ref(false)
// Get current server port from browser location (set by install.sh) // Default server port
const getCurrentPort = (): number => { const getCurrentPort = (): number => {
const port = window.location.port const port = window.location.port
if (port) { if (port) {
return parseInt(port, 10) return parseInt(port, 10)
} }
// Default port based on protocol
return window.location.protocol === 'https:' ? 443 : 80 return window.location.protocol === 'https:' ? 443 : 80
} }
@@ -674,42 +674,35 @@ async function performInstall() {
// Wait for service to restart and become available // Wait for service to restart and become available
async function waitForServiceRestart() { async function waitForServiceRestart() {
const maxAttempts = 30 // 30 attempts, ~30 seconds max const maxAttempts = 60 // Increase to 60 attempts, ~60 seconds max
const interval = 1000 // 1 second between attempts const interval = 1000 // 1 second between attempts
// Wait a moment for the service to start restarting // Wait a moment for the service to start restarting
await new Promise((resolve) => setTimeout(resolve, 2000)) await new Promise((resolve) => setTimeout(resolve, 3000))
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
// Try to access the health endpoint // Use setup status endpoint as it tells us the real mode
const response = await fetch('/health', { // Service might return 404 or connection refused while restarting
const response = await fetch('/setup/status', {
method: 'GET', method: 'GET',
cache: 'no-store' cache: 'no-store'
}) })
if (response.ok) { if (response.ok) {
// Service is up, check if setup is no longer needed const data = await response.json()
const statusResponse = await fetch('/setup/status', { // If needs_setup is false, service has restarted in normal mode
method: 'GET', if (data.data && !data.data.needs_setup) {
cache: 'no-store' serviceReady.value = true
}) // Redirect to login page after a short delay
setTimeout(() => {
if (statusResponse.ok) { window.location.href = '/login'
const data = await statusResponse.json() }, 1500)
// If needs_setup is false, service has restarted in normal mode return
if (data.data && !data.data.needs_setup) {
serviceReady.value = true
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = '/login'
}, 1500)
return
}
} }
} }
} catch { } catch {
// Service not ready yet, continue polling // Service not ready or network error during restart, continue polling
} }
await new Promise((resolve) => setTimeout(resolve, interval)) await new Promise((resolve) => setTimeout(resolve, interval))

View File

@@ -322,7 +322,13 @@
<!-- Charts Grid --> <!-- Charts Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Model Distribution Chart --> <!-- Model Distribution Chart -->
<div class="card p-4"> <div class="card relative overflow-hidden p-4">
<div
v-if="loadingCharts"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.modelDistribution') }} {{ t('dashboard.modelDistribution') }}
</h3> </h3>
@@ -330,6 +336,7 @@
<div class="h-48 w-48"> <div class="h-48 w-48">
<Doughnut <Doughnut
v-if="modelChartData" v-if="modelChartData"
ref="modelChartRef"
:data="modelChartData" :data="modelChartData"
:options="doughnutOptions" :options="doughnutOptions"
/> />
@@ -383,12 +390,23 @@
</div> </div>
<!-- Token Usage Trend Chart --> <!-- Token Usage Trend Chart -->
<div class="card p-4"> <div class="card relative overflow-hidden p-4">
<div
v-if="loadingCharts"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50"
>
<LoadingSpinner size="md" />
</div>
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('dashboard.tokenUsageTrend') }} {{ t('dashboard.tokenUsageTrend') }}
</h3> </h3>
<div class="h-48"> <div class="h-48">
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" /> <Line
v-if="trendChartData"
ref="trendChartRef"
:data="trendChartData"
:options="lineOptions"
/>
<div <div
v-else v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400" class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
@@ -645,10 +663,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch, nextTick } 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 { useSubscriptionStore } from '@/stores/subscriptions'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
@@ -689,15 +708,21 @@ ChartJS.register(
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const stats = ref<UserDashboardStats | null>(null) const stats = ref<UserDashboardStats | null>(null)
const loading = ref(false) const loading = ref(false)
const loadingUsage = ref(false) const loadingUsage = ref(false)
const loadingCharts = ref(false)
type ChartComponentRef = { chart?: ChartJS }
// Chart data // Chart data
const trendData = ref<TrendDataPoint[]>([]) const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const modelChartRef = ref<ChartComponentRef | null>(null)
const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage // Recent usage
const recentUsage = ref<UsageLog[]>([]) const recentUsage = ref<UsageLog[]>([])
@@ -964,6 +989,7 @@ const loadDashboardStats = async () => {
} }
const loadChartData = async () => { const loadChartData = async () => {
loadingCharts.value = true
try { try {
const params = { const params = {
start_date: startDate.value, start_date: startDate.value,
@@ -981,14 +1007,16 @@ const loadChartData = async () => {
modelStats.value = modelResponse.models || [] modelStats.value = modelResponse.models || []
} catch (error) { } catch (error) {
console.error('Error loading chart data:', error) console.error('Error loading chart data:', error)
} finally {
loadingCharts.value = false
} }
} }
const loadRecentUsage = async () => { const loadRecentUsage = async () => {
loadingUsage.value = true loadingUsage.value = true
try { try {
const endDate = new Date().toISOString() const endDate = new Date().toISOString().split('T')[0]
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const usageResponse = await usageAPI.getByDateRange(startDate, endDate) const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5) recentUsage.value = usageResponse.items.slice(0, 5)
} catch (error) { } catch (error) {
@@ -998,16 +1026,30 @@ const loadRecentUsage = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
loadDashboardStats() // Load critical data first
await loadDashboardStats()
// Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore.fetchActiveSubscriptions(true).catch((error) => {
console.error('Failed to refresh subscription status:', error)
})
// Initialize date range (synchronous)
initializeDateRange() initializeDateRange()
loadChartData()
loadRecentUsage() // Load chart data and recent usage in parallel (non-critical)
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
console.error('Error loading secondary data:', error)
})
}) })
// Watch for dark mode changes // Watch for dark mode changes
watch(isDarkMode, () => { watch(isDarkMode, () => {
// Force chart re-render on theme change nextTick(() => {
modelChartRef.value?.chart?.update()
trendChartRef.value?.chart?.update()
})
}) })
</script> </script>

View File

@@ -292,17 +292,19 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<Modal <BaseDialog
:show="showCreateModal || showEditModal" :show="showCreateModal || showEditModal"
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')" :title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
width="narrow"
@close="closeModals" @close="closeModals"
> >
<form @submit.prevent="handleSubmit" class="space-y-5"> <form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
<div> <div>
<label class="input-label">{{ t('keys.nameLabel') }}</label> <label class="input-label">{{ t('keys.nameLabel') }}</label>
<input <input
@@ -383,12 +385,13 @@
:placeholder="t('keys.selectStatus')" :placeholder="t('keys.selectStatus')"
/> />
</div> </div>
</form>
<div class="flex justify-end gap-3 pt-4"> <template #footer>
<div class="flex justify-end gap-3">
<button @click="closeModals" type="button" class="btn btn-secondary"> <button @click="closeModals" type="button" class="btn btn-secondary">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</button> </button>
<button type="submit" :disabled="submitting" class="btn btn-primary"> <button form="key-form" type="submit" :disabled="submitting" class="btn btn-primary">
<svg <svg
v-if="submitting" v-if="submitting"
class="-ml-1 mr-2 h-4 w-4 animate-spin" class="-ml-1 mr-2 h-4 w-4 animate-spin"
@@ -418,8 +421,8 @@
}} }}
</button> </button>
</div> </div>
</form> </template>
</Modal> </BaseDialog>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<ConfirmDialog <ConfirmDialog
@@ -501,7 +504,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.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 BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
@@ -557,6 +560,7 @@ const publicSettings = ref<PublicSettings | null>(null)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<{ top: number; left: number } | null>(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null)
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map()) const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
let abortController: AbortController | null = null
// Get the currently selected key for group change // Get the currently selected key for group change
const selectedKeyForGroup = computed(() => { const selectedKeyForGroup = computed(() => {
@@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => {
copiedKeyId.value = keyId copiedKeyId.value = keyId
setTimeout(() => { setTimeout(() => {
copiedKeyId.value = null copiedKeyId.value = null
}, 2000) }, 800)
} }
} }
const isAbortError = (error: unknown) => {
if (!error || typeof error !== 'object') return false
const { name, code } = error as { name?: string; code?: string }
return name === 'AbortError' || code === 'ERR_CANCELED'
}
const loadApiKeys = async () => { const loadApiKeys = async () => {
abortController?.abort()
const controller = new AbortController()
abortController = controller
const { signal } = controller
loading.value = true loading.value = true
try { try {
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size) const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, {
signal
})
if (signal.aborted) return
apiKeys.value = response.items apiKeys.value = response.items
pagination.value.total = response.total pagination.value.total = response.total
pagination.value.pages = response.pages pagination.value.pages = response.pages
@@ -639,16 +656,24 @@ const loadApiKeys = async () => {
if (response.items.length > 0) { if (response.items.length > 0) {
const keyIds = response.items.map((k) => k.id) const keyIds = response.items.map((k) => k.id)
try { try {
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds) const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
if (signal.aborted) return
usageStats.value = usageResponse.stats usageStats.value = usageResponse.stats
} catch (e) { } catch (e) {
console.error('Failed to load usage stats:', e) if (!isAbortError(e)) {
console.error('Failed to load usage stats:', e)
}
} }
} }
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return
}
appStore.showError(t('keys.failedToLoad')) appStore.showError(t('keys.failedToLoad'))
} finally { } finally {
loading.value = false if (abortController === controller) {
loading.value = false
}
} }
} }
@@ -683,6 +708,12 @@ const handlePageChange = (page: number) => {
loadApiKeys() loadApiKeys()
} }
const handlePageSizeChange = (pageSize: number) => {
pagination.value.page_size = pageSize
pagination.value.page = 1
loadApiKeys()
}
const editKey = (key: ApiKey) => { const editKey = (key: ApiKey) => {
selectedKey.value = key selectedKey.value = key
formData.value = { formData.value = {

View File

@@ -244,6 +244,12 @@
autocomplete="new-password" autocomplete="new-password"
class="input" class="input"
/> />
<p
v-if="passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class="input-error-text"
>
{{ t('profile.passwordsNotMatch') }}
</p>
</div> </div>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
@@ -392,6 +398,12 @@ const handleChangePassword = async () => {
} }
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
// Basic validation
if (!profileForm.value.username.trim()) {
appStore.showError(t('profile.usernameRequired'))
return
}
updatingProfile.value = true updatingProfile.value = true
try { try {
const updatedUser = await userAPI.updateProfile({ const updatedUser = await userAPI.updateProfile({

View File

@@ -445,6 +445,7 @@ import { ref, computed, 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 { useSubscriptionStore } from '@/stores/subscriptions'
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' import { formatDateTime } from '@/utils/format'
@@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore() const authStore = useAuthStore()
const appStore = useAppStore() const appStore = useAppStore()
const subscriptionStore = useSubscriptionStore()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
@@ -544,6 +546,16 @@ const handleRedeem = async () => {
// Refresh user data to get updated balance/concurrency // Refresh user data to get updated balance/concurrency
await authStore.refreshUser() await authStore.refreshUser()
// If subscription type, immediately refresh subscription status
if (result.type === 'subscription') {
try {
await subscriptionStore.fetchActiveSubscriptions(true) // force refresh
} catch (error) {
console.error('Failed to refresh subscriptions after redeem:', error)
appStore.showWarning(t('redeem.subscriptionRefreshFailed'))
}
}
// Clear the input // Clear the input
redeemCode.value = '' redeemCode.value = ''

View File

@@ -164,8 +164,28 @@
<button @click="resetFilters" class="btn btn-secondary"> <button @click="resetFilters" class="btn btn-secondary">
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </button>
<button @click="exportToCSV" class="btn btn-primary"> <button @click="exportToCSV" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportCsv') }} <svg
v-if="exporting"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ exporting ? t('usage.exporting') : t('usage.exportCsv') }}
</button> </button>
</div> </div>
</div> </div>
@@ -366,6 +386,7 @@
:total="pagination.total" :total="pagination.total"
:page-size="pagination.page_size" :page-size="pagination.page_size"
@update:page="handlePageChange" @update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/> />
</template> </template>
</TablePageLayout> </TablePageLayout>
@@ -412,7 +433,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' 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'
@@ -430,6 +451,8 @@ import { formatDateTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
let abortController: AbortController | null = null
// Tooltip state // Tooltip state
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 }) const tooltipPosition = ref({ x: 0, y: 0 })
@@ -453,6 +476,7 @@ const columns = computed<Column[]>(() => [
const usageLogs = ref<UsageLog[]>([]) const usageLogs = ref<UsageLog[]>([])
const apiKeys = ref<ApiKey[]>([]) const apiKeys = ref<ApiKey[]>([])
const loading = ref(false) const loading = ref(false)
const exporting = ref(false)
const apiKeyOptions = computed(() => { const apiKeyOptions = computed(() => {
return [ return [
@@ -498,7 +522,7 @@ const onDateRangeChange = (range: {
applyFilters() applyFilters()
} }
const pagination = ref({ const pagination = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
total: 0, total: 0,
@@ -532,22 +556,40 @@ const formatCacheTokens = (value: number): string => {
} }
const loadUsageLogs = async () => { const loadUsageLogs = async () => {
if (abortController) {
abortController.abort()
}
const currentAbortController = new AbortController()
abortController = currentAbortController
const { signal } = currentAbortController
loading.value = true loading.value = true
try { try {
const params: UsageQueryParams = { const params: UsageQueryParams = {
page: pagination.value.page, page: pagination.page,
page_size: pagination.value.page_size, page_size: pagination.page_size,
...filters.value ...filters.value
} }
const response = await usageAPI.query(params) const response = await usageAPI.query(params, { signal })
if (signal.aborted) {
return
}
usageLogs.value = response.items usageLogs.value = response.items
pagination.value.total = response.total pagination.total = response.total
pagination.value.pages = response.pages pagination.pages = response.pages
} catch (error) { } catch (error) {
if (signal.aborted) {
return
}
const abortError = error as { name?: string; code?: string }
if (abortError?.name === 'AbortError' || abortError?.code === 'ERR_CANCELED') {
return
}
appStore.showError(t('usage.failedToLoad')) appStore.showError(t('usage.failedToLoad'))
} finally { } finally {
loading.value = false if (abortController === currentAbortController) {
loading.value = false
}
} }
} }
@@ -575,7 +617,7 @@ const loadUsageStats = async () => {
} }
const applyFilters = () => { const applyFilters = () => {
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
@@ -588,60 +630,128 @@ const resetFilters = () => {
} }
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() initializeDateRange()
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
pagination.value.page = page pagination.page = page
loadUsageLogs() loadUsageLogs()
} }
const exportToCSV = () => { const handlePageSizeChange = (pageSize: number) => {
if (usageLogs.value.length === 0) { pagination.page_size = pageSize
pagination.page = 1
loadUsageLogs()
}
/**
* Escape CSV value to prevent injection and handle special characters
*/
const escapeCSVValue = (value: unknown): string => {
if (value == null) return ''
const str = String(value)
const escaped = str.replace(/"/g, '""')
// Prevent formula injection by prefixing dangerous characters with single quote
if (/^[=+\-@\t\r]/.test(str)) {
return `"\'${escaped}"`
}
// Escape values containing comma, quote, or newline
if (/[,"\n\r]/.test(str)) {
return `"${escaped}"`
}
return str
}
const exportToCSV = async () => {
if (pagination.total === 0) {
appStore.showWarning(t('usage.noDataToExport')) appStore.showWarning(t('usage.noDataToExport'))
return return
} }
const headers = [ exporting.value = true
'Model', appStore.showInfo(t('usage.preparingExport'))
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Write Tokens',
'Total Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)',
'Time'
]
const rows = usageLogs.value.map((log) => [
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.total_cost.toFixed(6),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '',
log.duration_ms,
log.created_at
])
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n') try {
const allLogs: UsageLog[] = []
const pageSize = 100 // Use a larger page size for export to reduce requests
const totalRequests = Math.ceil(pagination.total / pageSize)
const blob = new Blob([csvContent], { type: 'text/csv' }) for (let page = 1; page <= totalRequests; page++) {
const url = window.URL.createObjectURL(blob) const params: UsageQueryParams = {
const link = document.createElement('a') page: page,
link.href = url page_size: pageSize,
link.download = `usage_${new Date().toISOString().split('T')[0]}.csv` ...filters.value
link.click() }
window.URL.revokeObjectURL(url) const response = await usageAPI.query(params)
allLogs.push(...response.items)
}
appStore.showSuccess(t('usage.exportSuccess')) if (allLogs.length === 0) {
appStore.showWarning(t('usage.noDataToExport'))
return
}
const headers = [
'Time',
'API Key Name',
'Model',
'Type',
'Input Tokens',
'Output Tokens',
'Cache Read Tokens',
'Cache Creation Tokens',
'Rate Multiplier',
'Billed Cost',
'Original Cost',
'Billing Type',
'First Token (ms)',
'Duration (ms)'
]
const rows = allLogs.map((log) =>
[
log.created_at,
log.api_key?.name || '',
log.model,
log.stream ? 'Stream' : 'Sync',
log.input_tokens,
log.output_tokens,
log.cache_read_tokens,
log.cache_creation_tokens,
log.rate_multiplier,
log.actual_cost.toFixed(8),
log.total_cost.toFixed(8),
log.billing_type === 1 ? 'Subscription' : 'Balance',
log.first_token_ms ?? '',
log.duration_ms
].map(escapeCSVValue)
)
const csvContent = [
headers.map(escapeCSVValue).join(','),
...rows.map((row) => row.join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `usage_${filters.value.start_date}_to_${filters.value.end_date}.csv`
link.click()
window.URL.revokeObjectURL(url)
appStore.showSuccess(t('usage.exportSuccess'))
} catch (error) {
appStore.showError(t('usage.exportFailed'))
console.error('CSV Export failed:', error)
} finally {
exporting.value = false
}
} }
// Tooltip functions // Tooltip functions

View File

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