Merge branch 'main' into test-dev

This commit is contained in:
yangjianbo
2025-12-29 10:50:46 +08:00
78 changed files with 2847 additions and 990 deletions

View File

@@ -297,6 +297,16 @@ go generate ./cmd/server
--- ---
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
- 启用方式:设置环境变量 `RUN_MODE=simple`
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
---
## 项目结构 ## 项目结构
``` ```

View File

@@ -108,6 +108,14 @@ func runSetupServer() {
} }
func runMainServer() { func runMainServer() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if cfg.RunMode == config.RunModeSimple {
log.Println("⚠️ WARNING: Running in SIMPLE mode - billing and quota checks are DISABLED")
}
buildInfo := handler.BuildInfo{ buildInfo := handler.BuildInfo{
Version: Version, Version: Version,
BuildType: BuildType, BuildType: BuildType,

View File

@@ -54,7 +54,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
emailQueueService := service.ProvideEmailQueueService(emailService) emailQueueService := service.ProvideEmailQueueService(emailService)
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService) authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
userService := service.NewUserService(userRepository) userService := service.NewUserService(userRepository)
authHandler := handler.NewAuthHandler(authService, userService) authHandler := handler.NewAuthHandler(configConfig, authService, userService)
userHandler := handler.NewUserHandler(userService) userHandler := handler.NewUserHandler(userService)
apiKeyRepository := repository.NewApiKeyRepository(client) apiKeyRepository := repository.NewApiKeyRepository(client)
groupRepository := repository.NewGroupRepository(client, sqlDB) groupRepository := repository.NewGroupRepository(client, sqlDB)
@@ -67,7 +67,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
redeemCodeRepository := repository.NewRedeemCodeRepository(client) redeemCodeRepository := repository.NewRedeemCodeRepository(client)
billingCache := repository.NewBillingCache(redisClient) billingCache := repository.NewBillingCache(redisClient)
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository) billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService)
redeemCache := repository.NewRedeemCache(redisClient) redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
@@ -133,7 +133,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService) apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
httpServer := server.ProvideHTTPServer(configConfig, engine) httpServer := server.ProvideHTTPServer(configConfig, engine)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)

View File

@@ -7,6 +7,11 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
const (
RunModeStandard = "standard"
RunModeSimple = "simple"
)
type Config struct { type Config struct {
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
@@ -17,6 +22,7 @@ type Config struct {
Pricing PricingConfig `mapstructure:"pricing"` Pricing PricingConfig `mapstructure:"pricing"`
Gateway GatewayConfig `mapstructure:"gateway"` Gateway GatewayConfig `mapstructure:"gateway"`
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
Gemini GeminiConfig `mapstructure:"gemini"` Gemini GeminiConfig `mapstructure:"gemini"`
} }
@@ -135,6 +141,16 @@ type RateLimitConfig struct {
OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟)
} }
func NormalizeRunMode(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case RunModeStandard, RunModeSimple:
return normalized
default:
return RunModeStandard
}
}
func Load() (*Config, error) { func Load() (*Config, error) {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
@@ -161,6 +177,8 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("unmarshal config error: %w", err) return nil, fmt.Errorf("unmarshal config error: %w", err)
} }
cfg.RunMode = NormalizeRunMode(cfg.RunMode)
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config error: %w", err) return nil, fmt.Errorf("validate config error: %w", err)
} }
@@ -169,6 +187,8 @@ func Load() (*Config, error) {
} }
func setDefaults() { func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
// Server // Server
viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.port", 8080) viper.SetDefault("server.port", 8080)

View File

@@ -0,0 +1,23 @@
package config
import "testing"
func TestNormalizeRunMode(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"simple", "simple"},
{"SIMPLE", "simple"},
{"standard", "standard"},
{"invalid", "standard"},
{"", "standard"},
}
for _, tt := range tests {
result := NormalizeRunMode(tt.input)
if result != tt.expected {
t.Errorf("NormalizeRunMode(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -11,13 +12,15 @@ import (
// AuthHandler handles authentication-related requests // AuthHandler handles authentication-related requests
type AuthHandler struct { type AuthHandler struct {
cfg *config.Config
authService *service.AuthService authService *service.AuthService
userService *service.UserService userService *service.UserService
} }
// NewAuthHandler creates a new AuthHandler // NewAuthHandler creates a new AuthHandler
func NewAuthHandler(authService *service.AuthService, userService *service.UserService) *AuthHandler { func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
cfg: cfg,
authService: authService, authService: authService,
userService: userService, userService: userService,
} }
@@ -157,5 +160,15 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
return return
} }
response.Success(c, dto.UserFromService(user)) type UserResponse struct {
*dto.User
RunMode string `json:"run_mode"`
}
runMode := config.RunModeStandard
if h.cfg != nil {
runMode = h.cfg.RunMode
}
response.Success(c, UserResponse{User: dto.UserFromService(user), RunMode: runMode})
} }

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

@@ -0,0 +1,106 @@
package repository
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.
// Persistence models are defined within individual `*_repo.go` files.
func AutoMigrate(db *gorm.DB) error {
err := db.AutoMigrate(
&userModel{},
&apiKeyModel{},
&groupModel{},
&accountModel{},
&accountGroupModel{},
&proxyModel{},
&redeemCodeModel{},
&usageLogModel{},
&settingModel{},
&userSubscriptionModel{},
)
if err != nil {
return err
}
// 创建默认分组(简易模式支持)
if err := ensureDefaultGroups(db); 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
}
// ensureDefaultGroups 确保默认分组存在(简易模式支持)
// 为每个平台创建一个默认分组,配置最大权限以确保简易模式下不受限制
func ensureDefaultGroups(db *gorm.DB) error {
defaultGroups := []struct {
name string
platform string
description string
}{
{
name: "anthropic-default",
platform: "anthropic",
description: "Default group for Anthropic accounts (Simple Mode)",
},
{
name: "openai-default",
platform: "openai",
description: "Default group for OpenAI accounts (Simple Mode)",
},
{
name: "gemini-default",
platform: "gemini",
description: "Default group for Gemini accounts (Simple Mode)",
},
}
for _, dg := range defaultGroups {
var count int64
if err := db.Model(&groupModel{}).Where("name = ?", dg.name).Count(&count).Error; err != nil {
return err
}
if count == 0 {
group := &groupModel{
Name: dg.name,
Description: dg.description,
Platform: dg.platform,
RateMultiplier: 1.0,
IsExclusive: false,
Status: "active",
SubscriptionType: "standard",
}
if err := db.Create(group).Error; err != nil {
log.Printf("[AutoMigrate] Failed to create default group %s: %v", dg.name, err)
return err
}
log.Printf("[AutoMigrate] Created default group: %s (platform: %s)", dg.name, dg.platform)
}
}
return nil
}

View File

@@ -118,8 +118,9 @@ func (s *GroupRepoSuite) TestList() {
groups, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}) groups, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
s.Require().NoError(err, "List") s.Require().NoError(err, "List")
s.Require().Len(groups, 2) // 3 default groups + 2 test groups = 5 total
s.Require().Equal(int64(2), page.Total) s.Require().Len(groups, 5)
s.Require().Equal(int64(5), page.Total)
} }
func (s *GroupRepoSuite) TestListWithFilters_Platform() { func (s *GroupRepoSuite) TestListWithFilters_Platform() {
@@ -142,8 +143,12 @@ func (s *GroupRepoSuite) TestListWithFilters_Platform() {
groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", nil) groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", nil)
s.Require().NoError(err) s.Require().NoError(err)
s.Require().Len(groups, 1) // 1 default openai group + 1 test openai group = 2 total
s.Require().Equal(service.PlatformOpenAI, groups[0].Platform) s.Require().Len(groups, 2)
// Verify all groups are OpenAI platform
for _, g := range groups {
s.Require().Equal(service.PlatformOpenAI, g.Platform)
}
} }
func (s *GroupRepoSuite) TestListWithFilters_Status() { func (s *GroupRepoSuite) TestListWithFilters_Status() {
@@ -257,8 +262,17 @@ func (s *GroupRepoSuite) TestListActive() {
groups, err := s.repo.ListActive(s.ctx) groups, err := s.repo.ListActive(s.ctx)
s.Require().NoError(err, "ListActive") s.Require().NoError(err, "ListActive")
s.Require().Len(groups, 1) // 3 default groups (all active) + 1 test active group = 4 total
s.Require().Equal("active1", groups[0].Name) s.Require().Len(groups, 4)
// Verify our test group is in the results
var found bool
for _, g := range groups {
if g.Name == "active1" {
found = true
break
}
}
s.Require().True(found, "active1 group should be in results")
} }
func (s *GroupRepoSuite) TestListActiveByPlatform() { func (s *GroupRepoSuite) TestListActiveByPlatform() {
@@ -289,8 +303,17 @@ func (s *GroupRepoSuite) TestListActiveByPlatform() {
groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic) groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
s.Require().NoError(err, "ListActiveByPlatform") s.Require().NoError(err, "ListActiveByPlatform")
s.Require().Len(groups, 1) // 1 default anthropic group + 1 test active anthropic group = 2 total
s.Require().Equal("g1", groups[0].Name) s.Require().Len(groups, 2)
// Verify our test group is in the results
var found bool
for _, g := range groups {
if g.Name == "g1" {
found = true
break
}
}
s.Require().True(found, "g1 group should be in results")
} }
// --- ExistsByName --- // --- ExistsByName ---

View File

@@ -59,7 +59,8 @@ func TestAPIContracts(t *testing.T) {
"status": "active", "status": "active",
"allowed_groups": null, "allowed_groups": null,
"created_at": "2025-01-02T03:04:05Z", "created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z" "updated_at": "2025-01-02T03:04:05Z",
"run_mode": "standard"
} }
}`, }`,
}, },
@@ -369,6 +370,7 @@ func newContractDeps(t *testing.T) *contractDeps {
Default: config.DefaultConfig{ Default: config.DefaultConfig{
ApiKeyPrefix: "sk-", ApiKeyPrefix: "sk-",
}, },
RunMode: config.RunModeStandard,
} }
userService := service.NewUserService(userRepo) userService := service.NewUserService(userRepo)
@@ -380,7 +382,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingRepo := newStubSettingRepo() settingRepo := newStubSettingRepo()
settingService := service.NewSettingService(settingRepo, cfg) settingService := service.NewSettingService(settingRepo, cfg)
authHandler := handler.NewAuthHandler(nil, userService) authHandler := handler.NewAuthHandler(cfg, nil, userService)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil)

View File

@@ -36,7 +36,7 @@ func ProvideRouter(
r := gin.New() r := gin.New()
r.Use(middleware2.Recovery()) r.Use(middleware2.Recovery())
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg)
} }
// ProvideHTTPServer 提供 HTTP 服务器 // ProvideHTTPServer 提供 HTTP 服务器

View File

@@ -5,18 +5,19 @@ import (
"log" "log"
"strings" "strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// NewApiKeyAuthMiddleware 创建 API Key 认证中间件 // NewApiKeyAuthMiddleware 创建 API Key 认证中间件
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) ApiKeyAuthMiddleware { func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) ApiKeyAuthMiddleware {
return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService)) return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg))
} }
// apiKeyAuthWithSubscription API Key认证中间件支持订阅验证 // apiKeyAuthWithSubscription API Key认证中间件支持订阅验证
func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc { func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// 尝试从Authorization header中提取API key (Bearer scheme) // 尝试从Authorization header中提取API key (Bearer scheme)
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
@@ -85,6 +86,18 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti
return return
} }
if cfg.RunMode == config.RunModeSimple {
// 简易模式:跳过余额和订阅检查,但仍需设置必要的上下文
c.Set(string(ContextKeyApiKey), apiKey)
c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
c.Next()
return
}
// 判断计费方式:订阅模式 vs 余额模式 // 判断计费方式:订阅模式 vs 余额模式
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"strings" "strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -11,15 +12,15 @@ import (
) )
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService) gin.HandlerFunc { func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService, cfg *config.Config) gin.HandlerFunc {
return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil) return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)
} }
// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors: // ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}} // {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
// //
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations. // It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc { func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
apiKeyString := extractAPIKeyFromRequest(c) apiKeyString := extractAPIKeyFromRequest(c)
if apiKeyString == "" { if apiKeyString == "" {
@@ -50,6 +51,18 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
return return
} }
// 简易模式:跳过余额和订阅检查
if cfg.RunMode == config.RunModeSimple {
c.Set(string(ContextKeyApiKey), apiKey)
c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
c.Next()
return
}
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
if isSubscriptionType && subscriptionService != nil { if isSubscriptionType && subscriptionService != nil {
subscription, err := subscriptionService.GetActiveSubscription( subscription, err := subscriptionService.GetActiveSubscription(

View File

@@ -0,0 +1,286 @@
//go:build unit
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
gin.SetMode(gin.TestMode)
limit := 1.0
group := &service.Group{
ID: 42,
Name: "sub",
Status: service.StatusActive,
SubscriptionType: service.SubscriptionTypeSubscription,
DailyLimitUSD: &limit,
}
user := &service.User{
ID: 7,
Role: service.RoleUser,
Status: service.StatusActive,
Balance: 10,
Concurrency: 3,
}
apiKey := &service.ApiKey{
ID: 100,
UserID: user.ID,
Key: "test-key",
Status: service.StatusActive,
User: user,
Group: group,
}
apiKey.GroupID = &group.ID
apiKeyRepo := &stubApiKeyRepo{
getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) {
if key != apiKey.Key {
return nil, service.ErrApiKeyNotFound
}
clone := *apiKey
return &clone, nil
},
}
t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) {
cfg := &config.Config{RunMode: config.RunModeSimple}
apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil)
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/t", nil)
req.Header.Set("x-api-key", apiKey.Key)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
})
t.Run("standard_mode_enforces_quota_check", func(t *testing.T) {
cfg := &config.Config{RunMode: config.RunModeStandard}
apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
now := time.Now()
sub := &service.UserSubscription{
ID: 55,
UserID: user.ID,
GroupID: group.ID,
Status: service.SubscriptionStatusActive,
ExpiresAt: now.Add(24 * time.Hour),
DailyWindowStart: &now,
DailyUsageUSD: 10,
}
subscriptionRepo := &stubUserSubscriptionRepo{
getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
if userID != sub.UserID || groupID != sub.GroupID {
return nil, service.ErrSubscriptionNotFound
}
clone := *sub
return &clone, nil
},
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
}
subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil)
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/t", nil)
req.Header.Set("x-api-key", apiKey.Key)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusTooManyRequests, w.Code)
require.Contains(t, w.Body.String(), "USAGE_LIMIT_EXCEEDED")
})
}
func newAuthTestRouter(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
router := gin.New()
router.Use(gin.HandlerFunc(NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
router.GET("/t", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
return router
}
type stubApiKeyRepo struct {
getByKey func(ctx context.Context, key string) (*service.ApiKey, error)
}
func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error {
return errors.New("not implemented")
}
func (r *stubApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) {
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) {
if r.getByKey != nil {
return r.getByKey(ctx, key)
}
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error {
return errors.New("not implemented")
}
func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
return 0, errors.New("not implemented")
}
func (r *stubApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) {
return false, errors.New("not implemented")
}
func (r *stubApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) {
return nil, errors.New("not implemented")
}
func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
type stubUserSubscriptionRepo struct {
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
activateWindow func(ctx context.Context, id int64, start time.Time) error
resetDaily func(ctx context.Context, id int64, start time.Time) error
resetWeekly func(ctx context.Context, id int64, start time.Time) error
resetMonthly func(ctx context.Context, id int64, start time.Time) error
}
func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
return nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
return nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
if r.getActive != nil {
return r.getActive(ctx, userID, groupID)
}
return nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
return nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) {
return nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
return nil, nil, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
return false, errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID int64, status string) error {
if r.updateStatus != nil {
return r.updateStatus(ctx, subscriptionID, status)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
if r.activateWindow != nil {
return r.activateWindow(ctx, id, start)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
if r.resetDaily != nil {
return r.resetDaily(ctx, id, newWindowStart)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ResetWeeklyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
if r.resetWeekly != nil {
return r.resetWeekly(ctx, id, newWindowStart)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
if r.resetMonthly != nil {
return r.resetMonthly(ctx, id, newWindowStart)
}
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
return errors.New("not implemented")
}
func (r *stubUserSubscriptionRepo) BatchUpdateExpiredStatus(ctx context.Context) (int64, error) {
return 0, errors.New("not implemented")
}

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/routes" "github.com/Wei-Shaw/sub2api/internal/server/routes"
@@ -19,6 +20,7 @@ func SetupRouter(
apiKeyAuth middleware2.ApiKeyAuthMiddleware, apiKeyAuth middleware2.ApiKeyAuthMiddleware,
apiKeyService *service.ApiKeyService, apiKeyService *service.ApiKeyService,
subscriptionService *service.SubscriptionService, subscriptionService *service.SubscriptionService,
cfg *config.Config,
) *gin.Engine { ) *gin.Engine {
// 应用中间件 // 应用中间件
r.Use(middleware2.Logger()) r.Use(middleware2.Logger())
@@ -30,7 +32,7 @@ func SetupRouter(
} }
// 注册路由 // 注册路由
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg)
return r return r
} }
@@ -44,6 +46,7 @@ func registerRoutes(
apiKeyAuth middleware2.ApiKeyAuthMiddleware, apiKeyAuth middleware2.ApiKeyAuthMiddleware,
apiKeyService *service.ApiKeyService, apiKeyService *service.ApiKeyService,
subscriptionService *service.SubscriptionService, subscriptionService *service.SubscriptionService,
cfg *config.Config,
) { ) {
// 通用路由(健康检查、状态等) // 通用路由(健康检查、状态等)
routes.RegisterCommonRoutes(r) routes.RegisterCommonRoutes(r)
@@ -55,5 +58,5 @@ func registerRoutes(
routes.RegisterAuthRoutes(v1, h, jwtAuth) routes.RegisterAuthRoutes(v1, h, jwtAuth)
routes.RegisterUserRoutes(v1, h, jwtAuth) routes.RegisterUserRoutes(v1, h, jwtAuth)
routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterAdminRoutes(v1, h, adminAuth)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, cfg)
} }

View File

@@ -1,6 +1,7 @@
package routes package routes
import ( import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
@@ -15,6 +16,7 @@ func RegisterGatewayRoutes(
apiKeyAuth middleware.ApiKeyAuthMiddleware, apiKeyAuth middleware.ApiKeyAuthMiddleware,
apiKeyService *service.ApiKeyService, apiKeyService *service.ApiKeyService,
subscriptionService *service.SubscriptionService, subscriptionService *service.SubscriptionService,
cfg *config.Config,
) { ) {
// API网关Claude API兼容 // API网关Claude API兼容
gateway := r.Group("/v1") gateway := r.Group("/v1")
@@ -30,7 +32,7 @@ func RegisterGatewayRoutes(
// Gemini 原生 API 兼容层Gemini SDK/CLI 直连) // Gemini 原生 API 兼容层Gemini SDK/CLI 直连)
gemini := r.Group("/v1beta") gemini := r.Group("/v1beta")
gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService)) gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
{ {
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels) gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)

View File

@@ -54,15 +54,23 @@ type UsageLogRepository interface {
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
} }
// usageCache 用于缓存usage数据 // apiUsageCache 缓存从 Anthropic API 获取的使用率数据utilization, resets_at
type usageCache struct { type apiUsageCache struct {
data *UsageInfo response *ClaudeUsageResponse
timestamp time.Time
}
// windowStatsCache 缓存从本地数据库查询的窗口统计requests, tokens, cost
type windowStatsCache struct {
stats *WindowStats
timestamp time.Time timestamp time.Time
} }
var ( var (
usageCacheMap = sync.Map{} apiCacheMap = sync.Map{} // 缓存 API 响应
cacheTTL = 10 * time.Minute windowStatsCacheMap = sync.Map{} // 缓存窗口统计
apiCacheTTL = 10 * time.Minute
windowStatsCacheTTL = 1 * time.Minute
) )
// WindowStats 窗口期统计 // WindowStats 窗口期统计
@@ -126,7 +134,7 @@ func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLog
} }
// GetUsage 获取账号使用量 // GetUsage 获取账号使用量
// OAuth账号: 调用Anthropic API获取真实数据需要profile scope缓存10分钟 // OAuth账号: 调用Anthropic API获取真实数据需要profile scopeAPI响应缓存10分钟窗口统计缓存1分钟
// Setup Token账号: 根据session_window推算5h窗口7d数据不可用没有profile scope // Setup Token账号: 根据session_window推算5h窗口7d数据不可用没有profile scope
// API Key账号: 不支持usage查询 // API Key账号: 不支持usage查询
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
@@ -137,30 +145,34 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
// 只有oauth类型账号可以通过API获取usage有profile scope // 只有oauth类型账号可以通过API获取usage有profile scope
if account.CanGetUsage() { if account.CanGetUsage() {
// 检查缓存 var apiResp *ClaudeUsageResponse
if cached, ok := usageCacheMap.Load(accountID); ok {
cache, ok := cached.(*usageCache) // 1. 检查 API 缓存10 分钟)
if !ok { if cached, ok := apiCacheMap.Load(accountID); ok {
usageCacheMap.Delete(accountID) if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL {
} else if time.Since(cache.timestamp) < cacheTTL { apiResp = cache.response
return cache.data, nil
} }
} }
// API获取数据 // 2. 如果没有缓存,从 API 获取
usage, err := s.fetchOAuthUsage(ctx, account) if apiResp == nil {
if err != nil { apiResp, err = s.fetchOAuthUsageRaw(ctx, account)
return nil, err if err != nil {
return nil, err
}
// 缓存 API 响应
apiCacheMap.Store(accountID, &apiUsageCache{
response: apiResp,
timestamp: time.Now(),
})
} }
// 添加5h窗口统计数据 // 3. 构建 UsageInfo每次都重新计算 RemainingSeconds
s.addWindowStats(ctx, account, usage) now := time.Now()
usage := s.buildUsageInfo(apiResp, &now)
// 缓存结果 // 4. 添加窗口统计有独立缓存1 分钟)
usageCacheMap.Store(accountID, &usageCache{ s.addWindowStats(ctx, account, usage)
data: usage,
timestamp: time.Now(),
})
return usage, nil return usage, nil
} }
@@ -177,31 +189,54 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
return nil, fmt.Errorf("account type %s does not support usage query", account.Type) return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
} }
// addWindowStats 为usage数据添加窗口期统计 // addWindowStats 为 usage 数据添加窗口期统计
// 使用独立缓存1 分钟),与 API 缓存分离
func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) { func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) {
if usage.FiveHour == nil { // 修复:即使 FiveHour 为 nil也要尝试获取统计数据
// 因为 SevenDay/SevenDaySonnet 可能需要
if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil {
return return
} }
// 使用session_window_start作为统计起始时间 // 检查窗口统计缓存1 分钟)
var startTime time.Time var windowStats *WindowStats
if account.SessionWindowStart != nil { if cached, ok := windowStatsCacheMap.Load(account.ID); ok {
startTime = *account.SessionWindowStart if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL {
} else { windowStats = cache.stats
// 如果没有窗口信息使用5小时前作为默认 }
startTime = time.Now().Add(-5 * time.Hour)
} }
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime) // 如果没有缓存,从数据库查询
if err != nil { if windowStats == nil {
log.Printf("Failed to get window stats for account %d: %v", account.ID, err) var startTime time.Time
return if account.SessionWindowStart != nil {
startTime = *account.SessionWindowStart
} else {
startTime = time.Now().Add(-5 * time.Hour)
}
stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime)
if err != nil {
log.Printf("Failed to get window stats for account %d: %v", account.ID, err)
return
}
windowStats = &WindowStats{
Requests: stats.Requests,
Tokens: stats.Tokens,
Cost: stats.Cost,
}
// 缓存窗口统计1 分钟)
windowStatsCacheMap.Store(account.ID, &windowStatsCache{
stats: windowStats,
timestamp: time.Now(),
})
} }
usage.FiveHour.WindowStats = &WindowStats{ // 为 FiveHour 添加 WindowStats5h 窗口统计)
Requests: stats.Requests, if usage.FiveHour != nil {
Tokens: stats.Tokens, usage.FiveHour.WindowStats = windowStats
Cost: stats.Cost,
} }
} }
@@ -227,8 +262,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI
return stats, nil return stats, nil
} }
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量 // fetchOAuthUsageRaw Anthropic API 获取原始响应(不构建 UsageInfo
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Account) (*UsageInfo, error) { func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) {
accessToken := account.GetCredential("access_token") accessToken := account.GetCredential("access_token")
if accessToken == "" { if accessToken == "" {
return nil, fmt.Errorf("no access token available") return nil, fmt.Errorf("no access token available")
@@ -239,13 +274,7 @@ func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Acco
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
} }
usageResp, err := s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) return s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL)
if err != nil {
return nil, err
}
now := time.Now()
return s.buildUsageInfo(usageResp, &now), nil
} }
// parseTime 尝试多种格式解析时间 // parseTime 尝试多种格式解析时间
@@ -270,20 +299,16 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
} }
// 5小时窗口 // 5小时窗口 - 始终创建对象(即使 ResetsAt 为空)
info.FiveHour = &UsageProgress{
Utilization: resp.FiveHour.Utilization,
}
if resp.FiveHour.ResetsAt != "" { if resp.FiveHour.ResetsAt != "" {
if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil { if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil {
info.FiveHour = &UsageProgress{ info.FiveHour.ResetsAt = &fiveHourReset
Utilization: resp.FiveHour.Utilization, info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds())
ResetsAt: &fiveHourReset,
RemainingSeconds: int(time.Until(fiveHourReset).Seconds()),
}
} else { } else {
log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err) log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err)
// 即使解析失败也返回utilization
info.FiveHour = &UsageProgress{
Utilization: resp.FiveHour.Utilization,
}
} }
} }

View File

@@ -609,12 +609,30 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
if err := s.accountRepo.Create(ctx, account); err != nil { if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err return nil, err
} }
// 绑定分组 // 绑定分组
if len(input.GroupIDs) > 0 { groupIDs := input.GroupIDs
if err := s.accountRepo.BindGroups(ctx, account.ID, input.GroupIDs); err != nil { // 如果没有指定分组,自动绑定对应平台的默认分组
if len(groupIDs) == 0 {
defaultGroupName := input.Platform + "-default"
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
if err == nil {
for _, g := range groups {
if g.Name == defaultGroupName {
groupIDs = []int64{g.ID}
log.Printf("[CreateAccount] Auto-binding account %d to default group %s (ID: %d)", account.ID, defaultGroupName, g.ID)
break
}
}
}
}
if len(groupIDs) > 0 {
if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
return nil, err return nil, err
} }
} }
return account, nil return account, nil
} }

View File

@@ -6,6 +6,7 @@ import (
"log" "log"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors"
) )
@@ -32,14 +33,16 @@ type BillingCacheService struct {
cache BillingCache cache BillingCache
userRepo UserRepository userRepo UserRepository
subRepo UserSubscriptionRepository subRepo UserSubscriptionRepository
cfg *config.Config
} }
// NewBillingCacheService 创建计费缓存服务 // NewBillingCacheService 创建计费缓存服务
func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository) *BillingCacheService { func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, cfg *config.Config) *BillingCacheService {
return &BillingCacheService{ return &BillingCacheService{
cache: cache, cache: cache,
userRepo: userRepo, userRepo: userRepo,
subRepo: subRepo, subRepo: subRepo,
cfg: cfg,
} }
} }
@@ -224,6 +227,11 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID
// 余额模式:检查缓存余额 > 0 // 余额模式:检查缓存余额 > 0
// 订阅模式检查缓存用量未超过限额Group限额从参数传入 // 订阅模式检查缓存用量未超过限额Group限额从参数传入
func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error { func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error {
// 简易模式:跳过所有计费检查
if s.cfg.RunMode == config.RunModeSimple {
return nil
}
// 判断计费模式 // 判断计费模式
isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil

View File

@@ -313,7 +313,10 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
// 2. 获取可调度账号列表(排除限流和过载的账号,仅限 Anthropic 平台) // 2. 获取可调度账号列表(排除限流和过载的账号,仅限 Anthropic 平台)
var accounts []Account var accounts []Account
var err error var err error
if groupID != nil { if s.cfg.RunMode == config.RunModeSimple {
// 简易模式:忽略 groupID查询所有可用账号
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformAnthropic)
} else if groupID != nil {
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformAnthropic) accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformAnthropic)
} else { } else {
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformAnthropic) accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformAnthropic)
@@ -1065,6 +1068,12 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
log.Printf("Create usage log failed: %v", err) log.Printf("Create usage log failed: %v", err)
} }
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil
}
// 根据计费类型执行扣费 // 根据计费类型执行扣费
if isSubscriptionBilling { if isSubscriptionBilling {
// 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率) // 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率)

View File

@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
@@ -155,7 +156,10 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
// 2. Get schedulable OpenAI accounts // 2. Get schedulable OpenAI accounts
var accounts []Account var accounts []Account
var err error var err error
if groupID != nil { // 简易模式:忽略分组限制,查询所有可用账号
if s.cfg.RunMode == config.RunModeSimple {
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI)
} else if groupID != nil {
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
} else { } else {
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI) accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI)
@@ -754,6 +758,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
_ = s.usageLogRepo.Create(ctx, usageLog) _ = s.usageLogRepo.Create(ctx, usageLog)
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
s.deferredService.ScheduleLastUsedUpdate(account.ID)
return nil
}
// Deduct based on billing type // Deduct based on billing type
if isSubscriptionBilling { if isSubscriptionBilling {
if cost.TotalCost > 0 { if cost.TotalCost > 0 {

View File

@@ -164,6 +164,14 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl
return nil return nil
} }
// UpdateConcurrency 更新用户并发数(管理员功能)
func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error {
if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
return fmt.Errorf("update concurrency: %w", err)
}
return nil
}
// UpdateStatus 更新用户状态(管理员功能) // UpdateStatus 更新用户状态(管理员功能)
func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error { func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
user, err := s.userRepo.GetByID(ctx, userID) user, err := s.userRepo.GetByID(ctx, userID)

View File

@@ -20,6 +20,10 @@ SERVER_PORT=8080
# Server mode: release or debug # Server mode: release or debug
SERVER_MODE=release SERVER_MODE=release
# 运行模式: standard (默认) 或 simple (内部自用)
# standard: 完整 SaaS 功能,包含计费/余额校验simple: 隐藏 SaaS 功能并跳过计费/余额校验
RUN_MODE=standard
# Timezone # Timezone
TZ=Asia/Shanghai TZ=Asia/Shanghai

View File

@@ -13,6 +13,14 @@ server:
# Mode: "debug" for development, "release" for production # Mode: "debug" for development, "release" for production
mode: "release" mode: "release"
# =============================================================================
# Run Mode Configuration
# =============================================================================
# Run mode: "standard" (default) or "simple" (for internal use)
# - standard: Full SaaS features with billing/balance checks
# - simple: Hides SaaS features and skips billing/balance checks
run_mode: "standard"
# ============================================================================= # =============================================================================
# Database Configuration (PostgreSQL) # Database Configuration (PostgreSQL)
# ============================================================================= # =============================================================================

View File

@@ -36,6 +36,7 @@ services:
- SERVER_HOST=0.0.0.0 - SERVER_HOST=0.0.0.0
- SERVER_PORT=8080 - SERVER_PORT=8080
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- RUN_MODE=${RUN_MODE:-standard}
# ======================================================================= # =======================================================================
# Database Configuration (PostgreSQL) # Database Configuration (PostgreSQL)

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

@@ -8,7 +8,7 @@ import type {
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
AuthResponse, AuthResponse,
User, CurrentUserResponse,
SendVerifyCodeRequest, SendVerifyCodeRequest,
SendVerifyCodeResponse, SendVerifyCodeResponse,
PublicSettings PublicSettings
@@ -70,9 +70,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse>
* Get current authenticated user * Get current authenticated user
* @returns User profile data * @returns User profile data
*/ */
export async function getCurrentUser(): Promise<User> { export async function getCurrentUser() {
const { data } = await apiClient.get<User>('/auth/me') return apiClient.get<CurrentUserResponse>('/auth/me')
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
@@ -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
@@ -520,7 +530,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label> <label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
@@ -537,13 +547,7 @@
: 'sk-ant-...' : 'sk-ant-...'
" "
/> />
<p class="input-hint"> <p class="input-hint">{{ apiKeyHint }}</p>
{{
form.platform === 'gemini'
? t('admin.accounts.gemini.apiKeyHint')
: t('admin.accounts.apiKeyHint')
}}
</p>
</div> </div>
<!-- Model Restriction Section (不适用于 Gemini) --> <!-- Model Restriction Section (不适用于 Gemini) -->
@@ -960,14 +964,48 @@
</div> </div>
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" /> <GroupSelector
v-if="!authStore.isSimpleMode"
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 +1035,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,14 +1073,15 @@
}} }}
</button> </button>
</div> </div>
</div> </template>
</Modal> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { import {
useAccountOAuth, useAccountOAuth,
@@ -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'
@@ -1090,6 +1108,7 @@ interface OAuthFlowExposed {
} }
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore()
const oauthStepTitle = computed(() => { const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
@@ -1097,6 +1116,19 @@ const oauthStepTitle = computed(() => {
return t('admin.accounts.oauth.title') return t('admin.accounts.oauth.title')
}) })
// Platform-specific hints for API Key type
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
interface Props { interface Props {
show: boolean show: boolean
proxies: Proxy[] proxies: Proxy[]

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" />
@@ -22,7 +32,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label> <label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
@@ -456,14 +466,27 @@
<Select v-model="form.status" :options="statusOptions" /> <Select v-model="form.status" :options="statusOptions" />
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" /> <GroupSelector
v-if="!authStore.isSimpleMode"
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,17 +510,18 @@
{{ 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">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
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'
@@ -517,6 +541,15 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
// Model mapping type // Model mapping type
interface ModelMapping { interface ModelMapping {

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()
})
}
} }
} }
@@ -267,7 +297,7 @@ onUnmounted(() => {
} }
.select-dropdown { .select-dropdown {
@apply absolute z-[100] mt-2 w-full; @apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
@@ -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;
@@ -305,7 +339,7 @@ onUnmounted(() => {
} }
.select-option-label { .select-option-label {
@apply truncate; @apply flex-1 min-w-0 truncate text-left;
} }
.select-empty { .select-empty {
@@ -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

@@ -45,8 +45,8 @@
</router-link> </router-link>
</div> </div>
<!-- Personal Section for Admin --> <!-- Personal Section for Admin (hidden in simple mode) -->
<div class="sidebar-section"> <div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title"> <div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }} {{ t('nav.myAccount') }}
</div> </div>
@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = {
} }
// User navigation items (for regular users) // User navigation items (for regular users)
const userNavItems = computed(() => [ const userNavItems = computed(() => {
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const items = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Personal navigation items (for admin's "My Account" section, without Dashboard) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => [ const personalNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, const items = [
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Admin navigation items // Admin navigation items
const adminNavItems = computed(() => [ const adminNavItems = computed(() => {
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const baseItems = [
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon }, { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon } { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
]) ]
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return filtered
}
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return baseItems
})
function toggleSidebar() { function toggleSidebar() {
appStore.toggleSidebar() appStore.toggleSidebar()

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',
@@ -670,14 +676,21 @@ export default {
description: 'Description', description: 'Description',
platform: 'Platform', platform: 'Platform',
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
status: 'Status' status: 'Status',
exclusive: 'Exclusive Group'
}, },
enterGroupName: 'Enter group name', enterGroupName: 'Enter group name',
optionalDescription: 'Optional description', optionalDescription: 'Optional description',
platformHint: 'Select the platform this group is associated with', platformHint: 'Select the platform this group is associated with',
platformNotEditable: 'Platform cannot be changed after creation', platformNotEditable: 'Platform cannot be changed after creation',
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)', rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
exclusiveHint: 'Exclusive (requires explicit user access)', exclusiveHint: 'Exclusive group, manually assign to specific users',
exclusiveTooltip: {
title: 'What is an exclusive group?',
description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.',
example: 'Use case:',
exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.'
},
noGroupsYet: 'No groups yet', noGroupsYet: 'No groups yet',
createFirstGroup: 'Create your first group to organize API keys.', createFirstGroup: 'Create your first group to organize API keys.',
creating: 'Creating...', creating: 'Creating...',
@@ -902,6 +915,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Your Claude Console API Key', apiKeyHint: 'Your Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: 'Leave default for official OpenAI API',
apiKeyHint: 'Your OpenAI API Key'
},
modelRestriction: 'Model Restriction (Optional)', modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist', modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping', modelMapping: 'Model Mapping',
@@ -1063,6 +1081,7 @@ export default {
modelPassthrough: 'Gemini Model Passthrough', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)' apiKeyHint: 'Your Gemini API Key (starts with AIza)'
}, },
// Re-Auth Modal // Re-Auth Modal
@@ -1172,9 +1191,9 @@ export default {
batchAdd: 'Quick Add', batchAdd: 'Quick Add',
batchInput: 'Proxy List', batchInput: 'Proxy List',
batchInputPlaceholder: batchInputPlaceholder:
"Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443", "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint: batchInputHint:
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port", "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
parsedCount: '{count} valid', parsedCount: '{count} valid',
invalidCount: '{count} invalid', invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate', duplicateCount: '{count} duplicate',

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: '新密码',
@@ -727,14 +733,15 @@ export default {
platform: '平台', platform: '平台',
rateMultiplier: '费率倍数', rateMultiplier: '费率倍数',
status: '状态', status: '状态',
exclusive: '专属分组',
nameLabel: '分组名称', nameLabel: '分组名称',
namePlaceholder: '请输入分组名称', namePlaceholder: '请输入分组名称',
descriptionLabel: '描述', descriptionLabel: '描述',
descriptionPlaceholder: '请输入描述(可选)', descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数', rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
exclusiveLabel: '独占模式', exclusiveLabel: '专属分组',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制', platformLabel: '平台限制',
platformPlaceholder: '选择平台(留空则不限制)', platformPlaceholder: '选择平台(留空则不限制)',
accountsLabel: '指定账号', accountsLabel: '指定账号',
@@ -747,8 +754,14 @@ export default {
yes: '是', yes: '是',
no: '否' no: '否'
}, },
exclusive: '独占', exclusive: '专属',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给特定用户',
exclusiveTooltip: {
title: '什么是专属分组?',
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
example: '使用场景:',
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
},
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
platforms: { platforms: {
all: '全部平台', all: '全部平台',
@@ -767,8 +780,8 @@ export default {
allPlatforms: '全部平台', allPlatforms: '全部平台',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
exclusiveFilter: '独占', exclusiveFilter: '专属',
nonExclusive: '非独占', nonExclusive: '公开',
public: '公开', public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号', rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号', accountsCount: '{count} 个账号',
@@ -1041,6 +1054,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key', apiKeyHint: '您的 Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: '留空使用官方 OpenAI API',
apiKeyHint: '您的 OpenAI API Key'
},
modelRestriction: '模型限制(可选)', modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单', modelWhitelist: '模型白名单',
modelMapping: '模型映射', modelMapping: '模型映射',
@@ -1184,7 +1202,8 @@ export default {
gemini: { gemini: {
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API不进行模型限制或映射。',
apiKeyHint: 'Your Gemini API Key以 AIza 开头)' baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key以 AIza 开头)'
}, },
// Re-Auth Modal // Re-Auth Modal
reAuthorizeAccount: '重新授权账号', reAuthorizeAccount: '重新授权账号',
@@ -1321,8 +1340,8 @@ export default {
batchAdd: '快捷添加', batchAdd: '快捷添加',
batchInput: '代理列表', batchInput: '代理列表',
batchInputPlaceholder: batchInputPlaceholder:
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443", "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口", batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
parsedCount: '有效 {count} 个', parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个', invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个', duplicateCount: '重复 {count} 个',

View File

@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => {
return return
} }
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
'/admin/groups',
'/admin/subscriptions',
'/admin/redeem',
'/subscriptions',
'/redeem'
]
if (restrictedPaths.some((path) => to.path.startsWith(path))) {
// 简易模式下访问受限页面,重定向到仪表板
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
}
// All checks passed, allow navigation // All checks passed, allow navigation
next() next()
}) })

View File

@@ -4,7 +4,7 @@
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed, readonly } from 'vue'
import { authAPI } from '@/api' import { authAPI } from '@/api'
import type { User, LoginRequest, RegisterRequest } from '@/types' import type { User, LoginRequest, RegisterRequest } from '@/types'
@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const token = ref<string | null>(null) const token = ref<string | null>(null)
const runMode = ref<'standard' | 'simple'>('standard')
let refreshIntervalId: ReturnType<typeof setInterval> | null = null let refreshIntervalId: ReturnType<typeof setInterval> | null = null
// ==================== Computed ==================== // ==================== Computed ====================
@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.role === 'admin' return user.value?.role === 'admin'
}) })
const isSimpleMode = computed(() => runMode.value === 'simple')
// ==================== Actions ==================== // ==================== Actions ====================
/** /**
@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userData } = response.user
user.value = userData
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userData
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userDataWithoutRunMode } = response.user
user.value = userDataWithoutRunMode
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userDataWithoutRunMode))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userDataWithoutRunMode
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => {
} }
try { try {
const updatedUser = await authAPI.getCurrentUser() const response = await authAPI.getCurrentUser()
user.value = updatedUser if (response.data.run_mode) {
runMode.value = response.data.run_mode
}
const { run_mode, ...userData } = response.data
user.value = userData
// Update localStorage // Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
return updatedUser return userData
} catch (error) { } catch (error) {
// If refresh fails with 401, clear auth state // If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) { if ((error as { status?: number }).status === 401) {
@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => {
// State // State
user, user,
token, token,
runMode: readonly(runMode),
// Computed // Computed
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isSimpleMode,
// Actions // Actions
login, login,

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

@@ -60,7 +60,11 @@ export interface PublicSettings {
export interface AuthResponse { export interface AuthResponse {
access_token: string access_token: string
token_type: string token_type: string
user: User user: User & { run_mode?: 'standard' | 'simple' }
}
export interface CurrentUserResponse extends User {
run_mode?: 'standard' | 'simple'
} }
// ==================== Subscription Types ==================== // ==================== Subscription Types ====================

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,13 +437,64 @@
@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 { useAuthStore } from '@/stores/auth'
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 type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
@@ -572,22 +523,34 @@ import { formatRelativeTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
// Table columns // Table columns
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => {
{ key: 'select', label: '', sortable: false }, const cols: Column[] = [
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true }, { key: 'select', label: '', sortable: false },
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false }, { key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false }, { key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true }, { key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true }, { key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }, { key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
{ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false }, { key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, ]
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, // 简易模式下不显示分组列
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } if (!authStore.isSimpleMode) {
]) cols.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
}
cols.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
)
return cols
})
// Filter options // Filter options
const platformOptions = computed(() => [ const platformOptions = computed(() => [
@@ -628,6 +591,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 +611,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 +675,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 +685,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 +739,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 +934,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

@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([]) const userTrend = ref<UserUsageTrendPoint[]>([])
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range // Date range
const granularity = ref<'day' | 'hour'>('day') const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
// Granularity options for Select component // Granularity options for Select component
const granularityOptions = computed(() => [ const granularityOptions = computed(() => [
@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
loadChartData() loadChartData()
} }
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardStats = async () => {
loading.value = true loading.value = true
@@ -649,7 +647,6 @@ const loadChartData = async () => {
onMounted(() => { onMounted(() => {
loadDashboardStats() loadDashboardStats()
initializeDateRange()
loadChartData() loadChartData()
}) })
</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
@@ -271,34 +272,66 @@
/> />
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div> </div>
<div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="createForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="createForm.is_exclusive = !createForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" /> <Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p> <p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
@@ -345,11 +378,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 +414,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" />
@@ -408,25 +454,61 @@
class="input" class="input"
/> />
</div> </div>
<div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="editForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="editForm.is_exclusive = !editForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label> <label class="input-label">{{ t('admin.groups.form.status') }}</label>
@@ -435,11 +517,7 @@
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select <Select
v-model="editForm.subscription_type" v-model="editForm.subscription_type"
@@ -490,11 +568,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 +604,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 +632,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 +702,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 +748,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 +783,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') },
@@ -733,9 +736,19 @@ const groupOptions = computed(() => {
] ]
}) })
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range state // Date range state
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
const filters = ref<AdminUsageQueryParams>({ const filters = ref<AdminUsageQueryParams>({
user_id: undefined, user_id: undefined,
@@ -749,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
end_date: undefined end_date: undefined
}) })
// Initialize default date range (last 7 days) // Initialize filters with date range
const initializeDateRange = () => { filters.value.start_date = startDate.value
const now = new Date() filters.value.end_date = endDate.value
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// User search with debounce // User search with debounce
const debounceSearchUsers = () => { const debounceSearchUsers = () => {
@@ -796,7 +800,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 +811,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 +868,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 +894,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 +972,40 @@ 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) // Use local timezone instead of UTC
} const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`
}) const startDateStr = `${startDateRange.getFullYear()}-${String(startDateRange.getMonth() + 1).padStart(2, '0')}-${String(startDateRange.getDate()).padStart(2, '0')}`
models.value = Array.from(uniqueModels).sort() const response = await adminAPI.dashboard.getModelStats({
start_date: startDateStr,
end_date: endDateStr
})
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 = () => {
@@ -985,8 +1026,15 @@ const resetFilters = () => {
} }
granularity.value = 'day' granularity.value = 'day'
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = formatLocalDate(weekAgo)
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
pagination.value.page = 1 pagination.value.page = 1
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -997,6 +1045,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'))
@@ -1070,8 +1124,8 @@ const hideTooltip = () => {
} }
onMounted(() => { onMounted(() => {
initializeDateRange()
loadFilterOptions() loadFilterOptions()
loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
loadChartData() loadChartData()
@@ -1083,5 +1137,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

@@ -10,7 +10,7 @@
<!-- Row 1: Core Stats --> <!-- Row 1: Core Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Balance --> <!-- Balance -->
<div class="card p-4"> <div v-if="!authStore.isSimpleMode" class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30"> <div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<svg <svg
@@ -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,23 +708,39 @@ 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[]>([])
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately (not in onMounted)
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range // Date range
const granularity = ref<'day' | 'hour'>('day') const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
// Granularity options for Select component // Granularity options for Select component
const granularityOptions = computed(() => [ const granularityOptions = computed(() => [
@@ -938,18 +973,6 @@ const onDateRangeChange = (range: {
loadChartData() loadChartData()
} }
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardStats = async () => {
loading.value = true loading.value = true
@@ -964,6 +987,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,19 +1005,19 @@ 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 {
// 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD见 backend usage handler 的校验逻辑)。 // Use local timezone instead of UTC
// 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。
// 注意toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径,
// 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone
const now = new Date() const now = new Date()
const endDate = now.toISOString().split('T')[0] const endDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const startDate = `${weekAgo.getFullYear()}-${String(weekAgo.getMonth() + 1).padStart(2, '0')}-${String(weekAgo.getDate()).padStart(2, '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) {
@@ -1003,16 +1027,27 @@ const loadRecentUsage = async () => {
} }
} }
onMounted(() => { onMounted(async () => {
loadDashboardStats() // Load critical data first
initializeDateRange() await loadDashboardStats()
loadChartData()
loadRecentUsage() // Force refresh subscription status when entering dashboard (bypass cache)
subscriptionStore.fetchActiveSubscriptions(true).catch((error) => {
console.error('Failed to refresh subscription status:', error)
})
// 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 [
@@ -464,9 +488,19 @@ const apiKeyOptions = computed(() => {
] ]
}) })
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range state // Date range state
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
const filters = ref<UsageQueryParams>({ const filters = ref<UsageQueryParams>({
api_key_id: undefined, api_key_id: undefined,
@@ -474,18 +508,9 @@ const filters = ref<UsageQueryParams>({
end_date: undefined end_date: undefined
}) })
// Initialize default date range (last 7 days) // Initialize filters with date range
const initializeDateRange = () => { filters.value.start_date = startDate.value
const now = new Date() filters.value.end_date = endDate.value
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// Handle date range change from DateRangePicker // Handle date range change from DateRangePicker
const onDateRangeChange = (range: { const onDateRangeChange = (range: {
@@ -498,7 +523,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 +557,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 +618,7 @@ const loadUsageStats = async () => {
} }
const applyFilters = () => { const applyFilters = () => {
pagination.value.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
} }
@@ -587,61 +630,135 @@ const resetFilters = () => {
end_date: undefined end_date: undefined
} }
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() const now = new Date()
pagination.value.page = 1 const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = formatLocalDate(weekAgo)
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
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
@@ -662,7 +779,6 @@ const hideTooltip = () => {
} }
onMounted(() => { onMounted(() => {
initializeDateRange()
loadApiKeys() loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()