mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Merge PR #37: Add Gemini OAuth and Messages Compat Support
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,6 +21,9 @@ coverage.html
|
||||
# 依赖(使用 go mod)
|
||||
vendor/
|
||||
|
||||
# Go 编译缓存
|
||||
backend/.gocache/
|
||||
|
||||
# ===================
|
||||
# Node.js / Vue 前端
|
||||
# ===================
|
||||
|
||||
@@ -69,6 +69,7 @@ func provideCleanup(
|
||||
emailQueue *service.EmailQueueService,
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -99,6 +100,10 @@ func provideCleanup(
|
||||
openaiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GeminiOAuthService", func() error {
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
@@ -80,17 +80,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient)
|
||||
openAIOAuthClient := repository.NewOpenAIOAuthClient()
|
||||
openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
|
||||
rateLimitService := service.NewRateLimitService(accountRepository, configConfig)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
||||
geminiTokenCache := repository.NewGeminiTokenCache(client)
|
||||
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, httpUpstream)
|
||||
concurrencyCache := repository.NewConcurrencyCache(client)
|
||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService)
|
||||
@@ -101,7 +107,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
systemHandler := handler.ProvideSystemHandler(updateService)
|
||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||
gatewayCache := repository.NewGatewayCache(client)
|
||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||
@@ -112,7 +118,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
identityCache := repository.NewIdentityCache(client)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
@@ -120,10 +127,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, configConfig)
|
||||
v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -153,6 +160,7 @@ func provideCleanup(
|
||||
emailQueue *service.EmailQueueService,
|
||||
oauth *service.OAuthService,
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -182,6 +190,10 @@ func provideCleanup(
|
||||
openaiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GeminiOAuthService", func() error {
|
||||
geminiOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"Redis", func() error {
|
||||
return rdb.Close()
|
||||
}},
|
||||
|
||||
@@ -18,6 +18,17 @@ type Config struct {
|
||||
Gateway GatewayConfig `mapstructure:"gateway"`
|
||||
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
|
||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||
}
|
||||
|
||||
type GeminiConfig struct {
|
||||
OAuth GeminiOAuthConfig `mapstructure:"oauth"`
|
||||
}
|
||||
|
||||
type GeminiOAuthConfig struct {
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes string `mapstructure:"scopes"`
|
||||
}
|
||||
|
||||
// TokenRefreshConfig OAuth token自动刷新配置
|
||||
@@ -211,9 +222,16 @@ func setDefaults() {
|
||||
// TokenRefresh
|
||||
viper.SetDefault("token_refresh.enabled", true)
|
||||
viper.SetDefault("token_refresh.check_interval_minutes", 5) // 每5分钟检查一次
|
||||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 1.5) // 提前1.5小时刷新
|
||||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 0.5) // 提前30分钟刷新(适配Google 1小时token)
|
||||
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
||||
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
||||
|
||||
// Gemini OAuth - configure via environment variables or config file
|
||||
// GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET
|
||||
// Default: uses Gemini CLI public credentials (set via environment)
|
||||
viper.SetDefault("gemini.oauth.client_id", "")
|
||||
viper.SetDefault("gemini.oauth.client_secret", "")
|
||||
viper.SetDefault("gemini.oauth.scopes", "")
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
|
||||
@@ -2,9 +2,11 @@ package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
@@ -30,6 +32,7 @@ type AccountHandler struct {
|
||||
adminService service.AdminService
|
||||
oauthService *service.OAuthService
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
geminiOAuthService *service.GeminiOAuthService
|
||||
rateLimitService *service.RateLimitService
|
||||
accountUsageService *service.AccountUsageService
|
||||
accountTestService *service.AccountTestService
|
||||
@@ -42,6 +45,7 @@ func NewAccountHandler(
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
@@ -52,6 +56,7 @@ func NewAccountHandler(
|
||||
adminService: adminService,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
geminiOAuthService: geminiOAuthService,
|
||||
rateLimitService: rateLimitService,
|
||||
accountUsageService: accountUsageService,
|
||||
accountTestService: accountTestService,
|
||||
@@ -345,6 +350,19 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
} else if account.Platform == service.PlatformGemini {
|
||||
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.InternalError(c, "Failed to refresh credentials: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
newCredentials = h.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use Anthropic/Claude OAuth service to refresh token
|
||||
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
@@ -362,10 +380,14 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
// Update token-related fields
|
||||
newCredentials["access_token"] = tokenInfo.AccessToken
|
||||
newCredentials["token_type"] = tokenInfo.TokenType
|
||||
newCredentials["expires_in"] = tokenInfo.ExpiresIn
|
||||
newCredentials["expires_at"] = tokenInfo.ExpiresAt
|
||||
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||
newCredentials["scope"] = tokenInfo.Scope
|
||||
newCredentials["expires_in"] = strconv.FormatInt(tokenInfo.ExpiresIn, 10)
|
||||
newCredentials["expires_at"] = strconv.FormatInt(tokenInfo.ExpiresAt, 10)
|
||||
if strings.TrimSpace(tokenInfo.RefreshToken) != "" {
|
||||
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||
}
|
||||
if strings.TrimSpace(tokenInfo.Scope) != "" {
|
||||
newCredentials["scope"] = tokenInfo.Scope
|
||||
}
|
||||
}
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
@@ -858,6 +880,44 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Gemini accounts
|
||||
if account.IsGemini() {
|
||||
// For OAuth accounts: return default Gemini models
|
||||
if account.IsOAuth() {
|
||||
response.Success(c, geminicli.DefaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
// For API Key accounts: return models based on model_mapping
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
response.Success(c, geminicli.DefaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
var models []geminicli.Model
|
||||
for requestedModel := range mapping {
|
||||
var found bool
|
||||
for _, dm := range geminicli.DefaultModels {
|
||||
if dm.ID == requestedModel {
|
||||
models = append(models, dm)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
models = append(models, geminicli.Model{
|
||||
ID: requestedModel,
|
||||
Type: "model",
|
||||
DisplayName: requestedModel,
|
||||
CreatedAt: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Claude/Anthropic accounts
|
||||
// For OAuth and Setup-Token accounts: return default models
|
||||
if account.IsOAuth() {
|
||||
|
||||
135
backend/internal/handler/admin/gemini_oauth_handler.go
Normal file
135
backend/internal/handler/admin/gemini_oauth_handler.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GeminiOAuthHandler struct {
|
||||
geminiOAuthService *service.GeminiOAuthService
|
||||
}
|
||||
|
||||
func NewGeminiOAuthHandler(geminiOAuthService *service.GeminiOAuthService) *GeminiOAuthHandler {
|
||||
return &GeminiOAuthHandler{geminiOAuthService: geminiOAuthService}
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/gemini/oauth/capabilities
|
||||
func (h *GeminiOAuthHandler) GetCapabilities(c *gin.Context) {
|
||||
cfg := h.geminiOAuthService.GetOAuthConfig()
|
||||
response.Success(c, cfg)
|
||||
}
|
||||
|
||||
type GeminiGenerateAuthURLRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
// OAuth 类型: "code_assist" (需要 project_id) 或 "ai_studio" (不需要 project_id)
|
||||
// 默认为 "code_assist" 以保持向后兼容
|
||||
OAuthType string `json:"oauth_type"`
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates Google OAuth authorization URL for Gemini.
|
||||
// POST /api/v1/admin/gemini/oauth/auth-url
|
||||
func (h *GeminiOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req GeminiGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 默认使用 code_assist 以保持向后兼容
|
||||
oauthType := strings.TrimSpace(req.OAuthType)
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
if oauthType != "code_assist" && oauthType != "ai_studio" {
|
||||
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'")
|
||||
return
|
||||
}
|
||||
|
||||
// Always pass the "hosted" callback URI; the OAuth service may override it depending on
|
||||
// oauth_type and whether the built-in Gemini CLI OAuth client is used.
|
||||
redirectURI := deriveGeminiRedirectURI(c)
|
||||
result, err := h.geminiOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, redirectURI, req.ProjectID, oauthType)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
// Treat missing/invalid OAuth client configuration as a user/config error.
|
||||
if strings.Contains(msg, "OAuth client not configured") || strings.Contains(msg, "requires your own OAuth Client") {
|
||||
response.BadRequest(c, "Failed to generate auth URL: "+msg)
|
||||
return
|
||||
}
|
||||
response.InternalError(c, "Failed to generate auth URL: "+msg)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
type GeminiExchangeCodeRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
State string `json:"state" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
// OAuth 类型: "code_assist" 或 "ai_studio",需要与 GenerateAuthURL 时的类型一致
|
||||
OAuthType string `json:"oauth_type"`
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges authorization code for tokens.
|
||||
// POST /api/v1/admin/gemini/oauth/exchange-code
|
||||
func (h *GeminiOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
var req GeminiExchangeCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 默认使用 code_assist 以保持向后兼容
|
||||
oauthType := strings.TrimSpace(req.OAuthType)
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
if oauthType != "code_assist" && oauthType != "ai_studio" {
|
||||
response.BadRequest(c, "Invalid oauth_type: must be 'code_assist' or 'ai_studio'")
|
||||
return
|
||||
}
|
||||
|
||||
tokenInfo, err := h.geminiOAuthService.ExchangeCode(c.Request.Context(), &service.GeminiExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
State: req.State,
|
||||
Code: req.Code,
|
||||
ProxyID: req.ProxyID,
|
||||
OAuthType: oauthType,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to exchange code: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
func deriveGeminiRedirectURI(c *gin.Context) string {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin != "" {
|
||||
return strings.TrimRight(origin, "/") + "/auth/callback"
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if xfProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); xfProto != "" {
|
||||
scheme = strings.TrimSpace(strings.Split(xfProto, ",")[0])
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(c.Request.Host)
|
||||
if xfHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); xfHost != "" {
|
||||
host = strings.TrimSpace(strings.Split(xfHost, ",")[0])
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s/auth/callback", scheme, host)
|
||||
}
|
||||
@@ -21,15 +21,23 @@ import (
|
||||
// GatewayHandler handles API gateway requests
|
||||
type GatewayHandler struct {
|
||||
gatewayService *service.GatewayService
|
||||
geminiCompatService *service.GeminiMessagesCompatService
|
||||
userService *service.UserService
|
||||
billingCacheService *service.BillingCacheService
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
}
|
||||
|
||||
// NewGatewayHandler creates a new GatewayHandler
|
||||
func NewGatewayHandler(gatewayService *service.GatewayService, userService *service.UserService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService) *GatewayHandler {
|
||||
func NewGatewayHandler(
|
||||
gatewayService *service.GatewayService,
|
||||
geminiCompatService *service.GeminiMessagesCompatService,
|
||||
userService *service.UserService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
billingCacheService *service.BillingCacheService,
|
||||
) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
geminiCompatService: geminiCompatService,
|
||||
userService: userService,
|
||||
billingCacheService: billingCacheService,
|
||||
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude),
|
||||
@@ -114,8 +122,18 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 计算粘性会话hash
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(body)
|
||||
|
||||
platform := ""
|
||||
if apiKey.Group != nil {
|
||||
platform = apiKey.Group.Platform
|
||||
}
|
||||
|
||||
// 选择支持该模型的账号
|
||||
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
|
||||
var account *service.Account
|
||||
if platform == service.PlatformGemini {
|
||||
account, err = h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
|
||||
} else {
|
||||
account, err = h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
|
||||
}
|
||||
if err != nil {
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
return
|
||||
@@ -143,7 +161,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 转发请求
|
||||
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
||||
var result *service.ForwardResult
|
||||
if platform == service.PlatformGemini {
|
||||
result, err = h.geminiCompatService.Forward(c.Request.Context(), c, account, body)
|
||||
} else {
|
||||
result, err = h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
||||
}
|
||||
if err != nil {
|
||||
// 错误响应已在Forward中处理,这里只记录日志
|
||||
log.Printf("Forward request failed: %v", err)
|
||||
|
||||
272
backend/internal/handler/gemini_v1beta_handler.go
Normal file
272
backend/internal/handler/gemini_v1beta_handler.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GeminiV1BetaListModels proxies:
|
||||
// GET /v1beta/models
|
||||
func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
apiKey, ok := middleware.GetApiKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return
|
||||
}
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models")
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if shouldFallbackGeminiModels(res) {
|
||||
c.JSON(http.StatusOK, gemini.FallbackModelsList())
|
||||
return
|
||||
}
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
// GeminiV1BetaGetModel proxies:
|
||||
// GET /v1beta/models/{model}
|
||||
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
apiKey, ok := middleware.GetApiKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return
|
||||
}
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
|
||||
modelName := strings.TrimSpace(c.Param("model"))
|
||||
if modelName == "" {
|
||||
googleError(c, http.StatusBadRequest, "Missing model in URL")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if shouldFallbackGeminiModels(res) {
|
||||
c.JSON(http.StatusOK, gemini.FallbackModel(modelName))
|
||||
return
|
||||
}
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
// GeminiV1BetaModels proxies Gemini native REST endpoints like:
|
||||
// POST /v1beta/models/{model}:generateContent
|
||||
// POST /v1beta/models/{model}:streamGenerateContent?alt=sse
|
||||
func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
apiKey, ok := middleware.GetApiKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return
|
||||
}
|
||||
authSubject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
googleError(c, http.StatusInternalServerError, "User context not found")
|
||||
return
|
||||
}
|
||||
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGemini {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return
|
||||
}
|
||||
|
||||
modelName, action, err := parseGeminiModelAction(strings.TrimPrefix(c.Param("modelAction"), "/"))
|
||||
if err != nil {
|
||||
googleError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
stream := action == "streamGenerateContent"
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadRequest, "Failed to read request body")
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
googleError(c, http.StatusBadRequest, "Request body is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Get subscription (may be nil)
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
|
||||
// For Gemini native API, do not send Claude-style ping frames.
|
||||
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone)
|
||||
|
||||
// 0) wait queue check
|
||||
maxWait := service.CalculateMaxWait(authSubject.Concurrency)
|
||||
canWait, err := geminiConcurrency.IncrementWaitCount(c.Request.Context(), authSubject.UserID, maxWait)
|
||||
if err != nil {
|
||||
log.Printf("Increment wait count failed: %v", err)
|
||||
} else if !canWait {
|
||||
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
|
||||
return
|
||||
}
|
||||
defer geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID)
|
||||
|
||||
// 1) user concurrency slot
|
||||
streamStarted := false
|
||||
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if userReleaseFunc != nil {
|
||||
defer userReleaseFunc()
|
||||
}
|
||||
|
||||
// 2) billing eligibility check (after wait)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil {
|
||||
googleError(c, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3) select account (sticky session based on request body)
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(body)
|
||||
account, err := h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, modelName)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 4) account concurrency slot
|
||||
accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if accountReleaseFunc != nil {
|
||||
defer accountReleaseFunc()
|
||||
}
|
||||
|
||||
// 5) forward (writes response to client)
|
||||
result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body)
|
||||
if err != nil {
|
||||
// ForwardNative already wrote the response
|
||||
log.Printf("Gemini native forward failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 6) record usage async
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
ApiKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
}); err != nil {
|
||||
log.Printf("Record usage failed: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func parseGeminiModelAction(rest string) (model string, action string, err error) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", "", &pathParseError{"missing path"}
|
||||
}
|
||||
|
||||
// Standard: {model}:{action}
|
||||
if i := strings.Index(rest, ":"); i > 0 && i < len(rest)-1 {
|
||||
return rest[:i], rest[i+1:], nil
|
||||
}
|
||||
|
||||
// Fallback: {model}/{action}
|
||||
if i := strings.Index(rest, "/"); i > 0 && i < len(rest)-1 {
|
||||
return rest[:i], rest[i+1:], nil
|
||||
}
|
||||
|
||||
return "", "", &pathParseError{"invalid model action path"}
|
||||
}
|
||||
|
||||
type pathParseError struct{ msg string }
|
||||
|
||||
func (e *pathParseError) Error() string { return e.msg }
|
||||
|
||||
func googleError(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
|
||||
if res == nil {
|
||||
googleError(c, http.StatusBadGateway, "Empty upstream response")
|
||||
return
|
||||
}
|
||||
for k, vv := range res.Headers {
|
||||
// Avoid overriding content-length and hop-by-hop headers.
|
||||
if strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Transfer-Encoding") || strings.EqualFold(k, "Connection") {
|
||||
continue
|
||||
}
|
||||
for _, v := range vv {
|
||||
c.Writer.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
contentType := res.Headers.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
c.Data(res.StatusCode, contentType, res.Body)
|
||||
}
|
||||
|
||||
func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
|
||||
if res == nil {
|
||||
return true
|
||||
}
|
||||
if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToLower(res.Headers.Get("Www-Authenticate")), "insufficient_scope") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(res.Body)), "insufficient authentication scopes") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(res.Body)), "access_token_scope_insufficient") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type AdminHandlers struct {
|
||||
Account *admin.AccountHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Setting *admin.SettingHandler
|
||||
|
||||
@@ -15,6 +15,7 @@ func ProvideAdminHandlers(
|
||||
accountHandler *admin.AccountHandler,
|
||||
oauthHandler *admin.OAuthHandler,
|
||||
openaiOAuthHandler *admin.OpenAIOAuthHandler,
|
||||
geminiOAuthHandler *admin.GeminiOAuthHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
settingHandler *admin.SettingHandler,
|
||||
@@ -29,6 +30,7 @@ func ProvideAdminHandlers(
|
||||
Account: accountHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Setting: settingHandler,
|
||||
@@ -95,6 +97,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewAccountHandler,
|
||||
admin.NewOAuthHandler,
|
||||
admin.NewOpenAIOAuthHandler,
|
||||
admin.NewGeminiOAuthHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewSettingHandler,
|
||||
|
||||
42
backend/internal/pkg/gemini/models.go
Normal file
42
backend/internal/pkg/gemini/models.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package gemini
|
||||
|
||||
// This package provides minimal fallback model metadata for Gemini native endpoints.
|
||||
// It is used when upstream model listing is unavailable (e.g. OAuth token missing AI Studio scopes).
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsListResponse struct {
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
|
||||
func DefaultModels() []Model {
|
||||
methods := []string{"generateContent", "streamGenerateContent"}
|
||||
return []Model{
|
||||
{Name: "models/gemini-3-pro-preview", 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-flash", SupportedGenerationMethods: methods},
|
||||
{Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods},
|
||||
}
|
||||
}
|
||||
|
||||
func FallbackModelsList() ModelsListResponse {
|
||||
return ModelsListResponse{Models: DefaultModels()}
|
||||
}
|
||||
|
||||
func FallbackModel(model string) Model {
|
||||
methods := []string{"generateContent", "streamGenerateContent"}
|
||||
if model == "" {
|
||||
return Model{Name: "models/unknown", SupportedGenerationMethods: methods}
|
||||
}
|
||||
if len(model) >= 7 && model[:7] == "models/" {
|
||||
return Model{Name: model, SupportedGenerationMethods: methods}
|
||||
}
|
||||
return Model{Name: "models/" + model, SupportedGenerationMethods: methods}
|
||||
}
|
||||
38
backend/internal/pkg/geminicli/codeassist_types.go
Normal file
38
backend/internal/pkg/geminicli/codeassist_types.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package geminicli
|
||||
|
||||
// LoadCodeAssistRequest matches done-hub's internal Code Assist call.
|
||||
type LoadCodeAssistRequest struct {
|
||||
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type LoadCodeAssistMetadata struct {
|
||||
IDEType string `json:"ideType"`
|
||||
Platform string `json:"platform"`
|
||||
PluginType string `json:"pluginType"`
|
||||
}
|
||||
|
||||
type LoadCodeAssistResponse struct {
|
||||
CurrentTier string `json:"currentTier,omitempty"`
|
||||
CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"`
|
||||
AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"`
|
||||
}
|
||||
|
||||
type AllowedTier struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"isDefault,omitempty"`
|
||||
}
|
||||
|
||||
type OnboardUserRequest struct {
|
||||
TierID string `json:"tierId"`
|
||||
Metadata LoadCodeAssistMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type OnboardUserResponse struct {
|
||||
Done bool `json:"done"`
|
||||
Response *OnboardUserResultData `json:"response,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type OnboardUserResultData struct {
|
||||
CloudAICompanionProject any `json:"cloudaicompanionProject,omitempty"`
|
||||
}
|
||||
42
backend/internal/pkg/geminicli/constants.go
Normal file
42
backend/internal/pkg/geminicli/constants.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package geminicli
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AIStudioBaseURL = "https://generativelanguage.googleapis.com"
|
||||
GeminiCliBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||
|
||||
AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
TokenURL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
// AIStudioOAuthRedirectURI is the default redirect URI used for AI Studio OAuth.
|
||||
// This matches the "copy/paste callback URL" flow used by OpenAI OAuth in this project.
|
||||
// Note: You still need to register this redirect URI in your Google OAuth client
|
||||
// unless you use an OAuth client type that permits localhost redirect URIs.
|
||||
AIStudioOAuthRedirectURI = "http://localhost:1455/auth/callback"
|
||||
|
||||
// DefaultScopes for Code Assist (includes cloud-platform for API access plus userinfo scopes)
|
||||
// Required by Google's Code Assist API.
|
||||
DefaultCodeAssistScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
|
||||
|
||||
// DefaultScopes for AI Studio (uses generativelanguage API with OAuth)
|
||||
// Reference: https://ai.google.dev/gemini-api/docs/oauth
|
||||
// For regular Google accounts, supports API calls to generativelanguage.googleapis.com
|
||||
// Note: Google Auth platform currently documents the OAuth scope as
|
||||
// https://www.googleapis.com/auth/generative-language.retriever (often with cloud-platform).
|
||||
DefaultAIStudioScopes = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"
|
||||
|
||||
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.
|
||||
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
|
||||
|
||||
// GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
|
||||
// They enable the "login without creating your own OAuth client" experience, but Google may
|
||||
// restrict which scopes are allowed for this client.
|
||||
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
GeminiCLIOAuthClientSecret = "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"
|
||||
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
// GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints.
|
||||
GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)"
|
||||
)
|
||||
21
backend/internal/pkg/geminicli/models.go
Normal file
21
backend/internal/pkg/geminicli/models.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package geminicli
|
||||
|
||||
// Model represents a selectable Gemini model for UI/testing purposes.
|
||||
// Keep JSON fields consistent with existing frontend expectations.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
|
||||
var DefaultModels = []Model{
|
||||
{ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""},
|
||||
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", 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: ""},
|
||||
}
|
||||
|
||||
// DefaultTestModel is the default model to preselect in test flows.
|
||||
const DefaultTestModel = "gemini-2.5-pro"
|
||||
243
backend/internal/pkg/geminicli/oauth.go
Normal file
243
backend/internal/pkg/geminicli/oauth.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package geminicli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OAuthConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes string
|
||||
}
|
||||
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type"` // "code_assist" 或 "ai_studio"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = session
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
return nil, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Stop() {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func GenerateState() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateSessionID() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars).
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64URLEncode(hash[:])
|
||||
}
|
||||
|
||||
func base64URLEncode(data []byte) string {
|
||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||
}
|
||||
|
||||
// EffectiveOAuthConfig returns the effective OAuth configuration.
|
||||
// oauthType: "code_assist" or "ai_studio" (defaults to "code_assist" if empty).
|
||||
//
|
||||
// If ClientID/ClientSecret is not provided, this falls back to the built-in Gemini CLI OAuth client.
|
||||
//
|
||||
// Note: The built-in Gemini CLI OAuth client is restricted and may reject some scopes (e.g.
|
||||
// https://www.googleapis.com/auth/generative-language), which will surface as
|
||||
// "restricted_client" / "Unregistered scope(s)" errors during browser authorization.
|
||||
func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error) {
|
||||
effective := OAuthConfig{
|
||||
ClientID: strings.TrimSpace(cfg.ClientID),
|
||||
ClientSecret: strings.TrimSpace(cfg.ClientSecret),
|
||||
Scopes: strings.TrimSpace(cfg.Scopes),
|
||||
}
|
||||
|
||||
// Normalize scopes: allow comma-separated input but send space-delimited scopes to Google.
|
||||
if effective.Scopes != "" {
|
||||
effective.Scopes = strings.Join(strings.Fields(strings.ReplaceAll(effective.Scopes, ",", " ")), " ")
|
||||
}
|
||||
|
||||
// Fall back to built-in Gemini CLI OAuth client when not configured.
|
||||
if effective.ClientID == "" && effective.ClientSecret == "" {
|
||||
effective.ClientID = GeminiCLIOAuthClientID
|
||||
effective.ClientSecret = GeminiCLIOAuthClientSecret
|
||||
} else if effective.ClientID == "" || effective.ClientSecret == "" {
|
||||
return OAuthConfig{}, fmt.Errorf("OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)")
|
||||
}
|
||||
|
||||
isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID &&
|
||||
effective.ClientSecret == GeminiCLIOAuthClientSecret
|
||||
|
||||
if effective.Scopes == "" {
|
||||
// Use different default scopes based on OAuth type
|
||||
if oauthType == "ai_studio" {
|
||||
// Built-in client can't request some AI Studio scopes (notably generative-language).
|
||||
if isBuiltinClient {
|
||||
effective.Scopes = DefaultCodeAssistScopes
|
||||
} else {
|
||||
effective.Scopes = DefaultAIStudioScopes
|
||||
}
|
||||
} else {
|
||||
// Default to Code Assist scopes
|
||||
effective.Scopes = DefaultCodeAssistScopes
|
||||
}
|
||||
} else if oauthType == "ai_studio" && isBuiltinClient {
|
||||
// If user overrides scopes while still using the built-in client, strip restricted scopes.
|
||||
parts := strings.Fields(effective.Scopes)
|
||||
filtered := make([]string, 0, len(parts))
|
||||
for _, s := range parts {
|
||||
if strings.Contains(s, "generative-language") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
effective.Scopes = DefaultCodeAssistScopes
|
||||
} else {
|
||||
effective.Scopes = strings.Join(filtered, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: normalize older AI Studio scope to the currently documented one.
|
||||
if oauthType == "ai_studio" && effective.Scopes != "" {
|
||||
parts := strings.Fields(effective.Scopes)
|
||||
for i := range parts {
|
||||
if parts[i] == "https://www.googleapis.com/auth/generative-language" {
|
||||
parts[i] = "https://www.googleapis.com/auth/generative-language.retriever"
|
||||
}
|
||||
}
|
||||
effective.Scopes = strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
return effective, nil
|
||||
}
|
||||
|
||||
func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI, projectID, oauthType string) (string, error) {
|
||||
effectiveCfg, err := EffectiveOAuthConfig(cfg, oauthType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
redirectURI = strings.TrimSpace(redirectURI)
|
||||
if redirectURI == "" {
|
||||
return "", fmt.Errorf("redirect_uri is required")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("response_type", "code")
|
||||
params.Set("client_id", effectiveCfg.ClientID)
|
||||
params.Set("redirect_uri", redirectURI)
|
||||
params.Set("scope", effectiveCfg.Scopes)
|
||||
params.Set("state", state)
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("access_type", "offline")
|
||||
params.Set("prompt", "consent")
|
||||
params.Set("include_granted_scopes", "true")
|
||||
if strings.TrimSpace(projectID) != "" {
|
||||
params.Set("project_id", strings.TrimSpace(projectID))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil
|
||||
}
|
||||
46
backend/internal/pkg/geminicli/sanitize.go
Normal file
46
backend/internal/pkg/geminicli/sanitize.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package geminicli
|
||||
|
||||
import "strings"
|
||||
|
||||
const maxLogBodyLen = 2048
|
||||
|
||||
func SanitizeBodyForLogs(body string) string {
|
||||
body = truncateBase64InMessage(body)
|
||||
if len(body) > maxLogBodyLen {
|
||||
body = body[:maxLogBodyLen] + "...[truncated]"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func truncateBase64InMessage(message string) string {
|
||||
const maxBase64Length = 50
|
||||
|
||||
result := message
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(result[offset:], ";base64,")
|
||||
if idx == -1 {
|
||||
break
|
||||
}
|
||||
actualIdx := offset + idx
|
||||
start := actualIdx + len(";base64,")
|
||||
|
||||
end := start
|
||||
for end < len(result) && isBase64Char(result[end]) {
|
||||
end++
|
||||
}
|
||||
|
||||
if end-start > maxBase64Length {
|
||||
result = result[:start+maxBase64Length] + "...[truncated]" + result[end:]
|
||||
offset = start + maxBase64Length + len("...[truncated]")
|
||||
continue
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isBase64Char(c byte) bool {
|
||||
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '='
|
||||
}
|
||||
9
backend/internal/pkg/geminicli/token_types.go
Normal file
9
backend/internal/pkg/geminicli/token_types.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package geminicli
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
24
backend/internal/pkg/googleapi/status.go
Normal file
24
backend/internal/pkg/googleapi/status.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package googleapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HTTPStatusToGoogleStatus maps HTTP status codes to Google-style error status strings.
|
||||
func HTTPStatusToGoogleStatus(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "INVALID_ARGUMENT"
|
||||
case http.StatusUnauthorized:
|
||||
return "UNAUTHENTICATED"
|
||||
case http.StatusForbidden:
|
||||
return "PERMISSION_DENIED"
|
||||
case http.StatusNotFound:
|
||||
return "NOT_FOUND"
|
||||
case http.StatusTooManyRequests:
|
||||
return "RESOURCE_EXHAUSTED"
|
||||
default:
|
||||
if status >= 500 {
|
||||
return "INTERNAL"
|
||||
}
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
117
backend/internal/repository/gemini_oauth_client.go
Normal file
117
backend/internal/repository/gemini_oauth_client.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
type geminiOAuthClient struct {
|
||||
tokenURL string
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewGeminiOAuthClient(cfg *config.Config) service.GeminiOAuthClient {
|
||||
return &geminiOAuthClient{
|
||||
tokenURL: geminicli.TokenURL,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *geminiOAuthClient) ExchangeCode(ctx context.Context, oauthType, code, codeVerifier, redirectURI, proxyURL string) (*geminicli.TokenResponse, error) {
|
||||
client := createGeminiReqClient(proxyURL)
|
||||
|
||||
// Use different OAuth clients based on oauthType:
|
||||
// - code_assist: always use built-in Gemini CLI OAuth client (public)
|
||||
// - ai_studio: requires a user-provided OAuth client
|
||||
oauthCfgInput := geminicli.OAuthConfig{
|
||||
ClientID: c.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfgInput.ClientID = ""
|
||||
oauthCfgInput.ClientSecret = ""
|
||||
}
|
||||
|
||||
oauthCfg, err := geminicli.EffectiveOAuthConfig(oauthCfgInput, oauthType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", oauthCfg.ClientID)
|
||||
formData.Set("client_secret", oauthCfg.ClientSecret)
|
||||
formData.Set("code", code)
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
|
||||
var tokenResp geminicli.TokenResponse
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, geminicli.SanitizeBodyForLogs(resp.String()))
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func (c *geminiOAuthClient) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*geminicli.TokenResponse, error) {
|
||||
client := createGeminiReqClient(proxyURL)
|
||||
|
||||
oauthCfgInput := geminicli.OAuthConfig{
|
||||
ClientID: c.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: c.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: c.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfgInput.ClientID = ""
|
||||
oauthCfgInput.ClientSecret = ""
|
||||
}
|
||||
|
||||
oauthCfg, err := geminicli.EffectiveOAuthConfig(oauthCfgInput, oauthType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "refresh_token")
|
||||
formData.Set("refresh_token", refreshToken)
|
||||
formData.Set("client_id", oauthCfg.ClientID)
|
||||
formData.Set("client_secret", oauthCfg.ClientSecret)
|
||||
|
||||
var tokenResp geminicli.TokenResponse
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, geminicli.SanitizeBodyForLogs(resp.String()))
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func createGeminiReqClient(proxyURL string) *req.Client {
|
||||
client := req.C().SetTimeout(60 * time.Second)
|
||||
if proxyURL != "" {
|
||||
client.SetProxyURL(proxyURL)
|
||||
}
|
||||
return client
|
||||
}
|
||||
44
backend/internal/repository/gemini_token_cache.go
Normal file
44
backend/internal/repository/gemini_token_cache.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiTokenKeyPrefix = "gemini:token:"
|
||||
geminiRefreshLockKeyPrefix = "gemini:refresh_lock:"
|
||||
)
|
||||
|
||||
type geminiTokenCache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewGeminiTokenCache(rdb *redis.Client) service.GeminiTokenCache {
|
||||
return &geminiTokenCache{rdb: rdb}
|
||||
}
|
||||
|
||||
func (c *geminiTokenCache) GetAccessToken(ctx context.Context, cacheKey string) (string, error) {
|
||||
key := fmt.Sprintf("%s%s", geminiTokenKeyPrefix, cacheKey)
|
||||
return c.rdb.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
func (c *geminiTokenCache) SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error {
|
||||
key := fmt.Sprintf("%s%s", geminiTokenKeyPrefix, cacheKey)
|
||||
return c.rdb.Set(ctx, key, token, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *geminiTokenCache) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
|
||||
key := fmt.Sprintf("%s%s", geminiRefreshLockKeyPrefix, cacheKey)
|
||||
return c.rdb.SetNX(ctx, key, 1, ttl).Result()
|
||||
}
|
||||
|
||||
func (c *geminiTokenCache) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
|
||||
key := fmt.Sprintf("%s%s", geminiRefreshLockKeyPrefix, cacheKey)
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
105
backend/internal/repository/geminicli_codeassist_client.go
Normal file
105
backend/internal/repository/geminicli_codeassist_client.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
type geminiCliCodeAssistClient struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewGeminiCliCodeAssistClient() service.GeminiCliCodeAssistClient {
|
||||
return &geminiCliCodeAssistClient{baseURL: geminicli.GeminiCliBaseURL}
|
||||
}
|
||||
|
||||
func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessToken, proxyURL string, reqBody *geminicli.LoadCodeAssistRequest) (*geminicli.LoadCodeAssistResponse, error) {
|
||||
if reqBody == nil {
|
||||
reqBody = defaultLoadCodeAssistRequest()
|
||||
}
|
||||
|
||||
var out geminicli.LoadCodeAssistResponse
|
||||
resp, err := createGeminiCliReqClient(proxyURL).R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&out).
|
||||
Post(c.baseURL + "/v1internal:loadCodeAssist")
|
||||
if err != nil {
|
||||
fmt.Printf("[CodeAssist] LoadCodeAssist request error: %v\n", err)
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
body := geminicli.SanitizeBodyForLogs(resp.String())
|
||||
fmt.Printf("[CodeAssist] LoadCodeAssist failed: status %d, body: %s\n", resp.StatusCode, body)
|
||||
return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, body)
|
||||
}
|
||||
fmt.Printf("[CodeAssist] LoadCodeAssist success: status %d, response: %+v\n", resp.StatusCode, out)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken, proxyURL string, reqBody *geminicli.OnboardUserRequest) (*geminicli.OnboardUserResponse, error) {
|
||||
if reqBody == nil {
|
||||
reqBody = defaultOnboardUserRequest()
|
||||
}
|
||||
|
||||
fmt.Printf("[CodeAssist] OnboardUser request body: %+v\n", reqBody)
|
||||
|
||||
var out geminicli.OnboardUserResponse
|
||||
resp, err := createGeminiCliReqClient(proxyURL).R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+accessToken).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("User-Agent", geminicli.GeminiCLIUserAgent).
|
||||
SetBody(reqBody).
|
||||
SetSuccessResult(&out).
|
||||
Post(c.baseURL + "/v1internal:onboardUser")
|
||||
if err != nil {
|
||||
fmt.Printf("[CodeAssist] OnboardUser request error: %v\n", err)
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
body := geminicli.SanitizeBodyForLogs(resp.String())
|
||||
fmt.Printf("[CodeAssist] OnboardUser failed: status %d, body: %s\n", resp.StatusCode, body)
|
||||
return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, body)
|
||||
}
|
||||
fmt.Printf("[CodeAssist] OnboardUser success: status %d, response: %+v\n", resp.StatusCode, out)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func createGeminiCliReqClient(proxyURL string) *req.Client {
|
||||
client := req.C().SetTimeout(30 * time.Second)
|
||||
if proxyURL != "" {
|
||||
client.SetProxyURL(proxyURL)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func defaultLoadCodeAssistRequest() *geminicli.LoadCodeAssistRequest {
|
||||
return &geminicli.LoadCodeAssistRequest{
|
||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||
IDEType: "ANTIGRAVITY",
|
||||
Platform: "PLATFORM_UNSPECIFIED",
|
||||
PluginType: "GEMINI",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultOnboardUserRequest() *geminicli.OnboardUserRequest {
|
||||
return &geminicli.OnboardUserRequest{
|
||||
TierID: "LEGACY",
|
||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||
IDEType: "ANTIGRAVITY",
|
||||
Platform: "PLATFORM_UNSPECIFIED",
|
||||
PluginType: "GEMINI",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -120,10 +120,9 @@ func (s *PricingServiceSuite) TestFetchHashText_WhitespaceOnly() {
|
||||
|
||||
func (s *PricingServiceSuite) TestFetchPricingJSON_ContextCancel() {
|
||||
started := make(chan struct{})
|
||||
block := make(chan struct{})
|
||||
s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-block
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
@@ -136,7 +135,6 @@ func (s *PricingServiceSuite) TestFetchPricingJSON_ContextCancel() {
|
||||
|
||||
<-started
|
||||
cancel()
|
||||
close(block)
|
||||
|
||||
err := <-done
|
||||
require.Error(s.T(), err)
|
||||
|
||||
@@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewIdentityCache,
|
||||
NewRedeemCache,
|
||||
NewUpdateCache,
|
||||
NewGeminiTokenCache,
|
||||
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
@@ -35,4 +36,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewClaudeOAuthClient,
|
||||
NewHTTPUpstream,
|
||||
NewOpenAIOAuthClient,
|
||||
NewGeminiOAuthClient,
|
||||
NewGeminiCliCodeAssistClient,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/wire"
|
||||
@@ -25,6 +26,8 @@ func ProvideRouter(
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
||||
apiKeyService *service.ApiKeyService,
|
||||
subscriptionService *service.SubscriptionService,
|
||||
) *gin.Engine {
|
||||
if cfg.Server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -33,7 +36,7 @@ func ProvideRouter(
|
||||
r := gin.New()
|
||||
r.Use(middleware2.Recovery())
|
||||
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth)
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService)
|
||||
}
|
||||
|
||||
// ProvideHTTPServer 提供 HTTP 服务器
|
||||
|
||||
@@ -35,9 +35,24 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti
|
||||
apiKeyString = c.GetHeader("x-api-key")
|
||||
}
|
||||
|
||||
// 如果两个header都没有API key
|
||||
// 如果x-api-key header中没有,尝试从x-goog-api-key header中提取(Gemini CLI兼容)
|
||||
if apiKeyString == "" {
|
||||
AbortWithError(c, 401, "API_KEY_REQUIRED", "API key is required in Authorization header (Bearer scheme) or x-api-key header")
|
||||
apiKeyString = c.GetHeader("x-goog-api-key")
|
||||
}
|
||||
|
||||
// 如果header中没有,尝试从query参数中提取(Google API key风格)
|
||||
if apiKeyString == "" {
|
||||
apiKeyString = c.Query("key")
|
||||
}
|
||||
|
||||
// 兼容常见别名
|
||||
if apiKeyString == "" {
|
||||
apiKeyString = c.Query("api_key")
|
||||
}
|
||||
|
||||
// 如果所有header都没有API key
|
||||
if apiKeyString == "" {
|
||||
AbortWithError(c, 401, "API_KEY_REQUIRED", "API key is required in Authorization header (Bearer scheme), x-api-key header, x-goog-api-key header, or key/api_key query parameter")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
121
backend/internal/server/middleware/api_key_auth_google.go
Normal file
121
backend/internal/server/middleware/api_key_auth_google.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiKeyAuthGoogle is a Google-style error wrapper for API key auth.
|
||||
func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService) gin.HandlerFunc {
|
||||
return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil)
|
||||
}
|
||||
|
||||
// ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors:
|
||||
// {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}}
|
||||
//
|
||||
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
|
||||
func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKeyString := extractAPIKeyFromRequest(c)
|
||||
if apiKeyString == "" {
|
||||
abortWithGoogleError(c, 401, "API key is required")
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
abortWithGoogleError(c, 401, "Invalid API key")
|
||||
return
|
||||
}
|
||||
abortWithGoogleError(c, 500, "Failed to validate API key")
|
||||
return
|
||||
}
|
||||
|
||||
if !apiKey.IsActive() {
|
||||
abortWithGoogleError(c, 401, "API key is disabled")
|
||||
return
|
||||
}
|
||||
if apiKey.User == nil {
|
||||
abortWithGoogleError(c, 401, "User associated with API key not found")
|
||||
return
|
||||
}
|
||||
if !apiKey.User.IsActive() {
|
||||
abortWithGoogleError(c, 401, "User account is not active")
|
||||
return
|
||||
}
|
||||
|
||||
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||
if isSubscriptionType && subscriptionService != nil {
|
||||
subscription, err := subscriptionService.GetActiveSubscription(
|
||||
c.Request.Context(),
|
||||
apiKey.User.ID,
|
||||
apiKey.Group.ID,
|
||||
)
|
||||
if err != nil {
|
||||
abortWithGoogleError(c, 403, "No active subscription found for this group")
|
||||
return
|
||||
}
|
||||
if err := subscriptionService.ValidateSubscription(c.Request.Context(), subscription); err != nil {
|
||||
abortWithGoogleError(c, 403, err.Error())
|
||||
return
|
||||
}
|
||||
_ = subscriptionService.CheckAndActivateWindow(c.Request.Context(), subscription)
|
||||
_ = subscriptionService.CheckAndResetWindows(c.Request.Context(), subscription)
|
||||
if err := subscriptionService.CheckUsageLimits(c.Request.Context(), subscription, apiKey.Group, 0); err != nil {
|
||||
abortWithGoogleError(c, 429, err.Error())
|
||||
return
|
||||
}
|
||||
c.Set(string(ContextKeySubscription), subscription)
|
||||
} else {
|
||||
if apiKey.User.Balance <= 0 {
|
||||
abortWithGoogleError(c, 403, "Insufficient account balance")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(string(ContextKeyApiKey), apiKey)
|
||||
c.Set(string(ContextKeyUser), apiKey.User)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractAPIKeyFromRequest(c *gin.Context) string {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && parts[0] == "Bearer" && strings.TrimSpace(parts[1]) != "" {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.GetHeader("x-api-key")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(c.GetHeader("x-goog-api-key")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("key")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("api_key")); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func abortWithGoogleError(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
"message": message,
|
||||
"status": googleapi.HTTPStatusToGoogleStatus(status),
|
||||
},
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/routes"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -16,6 +17,8 @@ func SetupRouter(
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
||||
apiKeyService *service.ApiKeyService,
|
||||
subscriptionService *service.SubscriptionService,
|
||||
) *gin.Engine {
|
||||
// 应用中间件
|
||||
r.Use(middleware2.Logger())
|
||||
@@ -27,7 +30,7 @@ func SetupRouter(
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth)
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -39,6 +42,8 @@ func registerRoutes(
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.ApiKeyAuthMiddleware,
|
||||
apiKeyService *service.ApiKeyService,
|
||||
subscriptionService *service.SubscriptionService,
|
||||
) {
|
||||
// 通用路由(健康检查、状态等)
|
||||
routes.RegisterCommonRoutes(r)
|
||||
@@ -50,5 +55,5 @@ func registerRoutes(
|
||||
routes.RegisterAuthRoutes(v1, h, jwtAuth)
|
||||
routes.RegisterUserRoutes(v1, h, jwtAuth)
|
||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ func RegisterAdminRoutes(
|
||||
// OpenAI OAuth
|
||||
registerOpenAIOAuthRoutes(admin, h)
|
||||
|
||||
// Gemini OAuth
|
||||
registerGeminiOAuthRoutes(admin, h)
|
||||
|
||||
// 代理管理
|
||||
registerProxyRoutes(admin, h)
|
||||
|
||||
@@ -136,6 +139,15 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
}
|
||||
}
|
||||
|
||||
func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
gemini := admin.Group("/gemini")
|
||||
{
|
||||
gemini.POST("/oauth/auth-url", h.Admin.GeminiOAuth.GenerateAuthURL)
|
||||
gemini.POST("/oauth/exchange-code", h.Admin.GeminiOAuth.ExchangeCode)
|
||||
gemini.GET("/oauth/capabilities", h.Admin.GeminiOAuth.GetCapabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
proxies := admin.Group("/proxies")
|
||||
{
|
||||
|
||||
@@ -3,15 +3,18 @@ package routes
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI 兼容)
|
||||
// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI/Gemini 兼容)
|
||||
func RegisterGatewayRoutes(
|
||||
r *gin.Engine,
|
||||
h *handler.Handlers,
|
||||
apiKeyAuth middleware.ApiKeyAuthMiddleware,
|
||||
apiKeyService *service.ApiKeyService,
|
||||
subscriptionService *service.SubscriptionService,
|
||||
) {
|
||||
// API网关(Claude API兼容)
|
||||
gateway := r.Group("/v1")
|
||||
@@ -25,6 +28,16 @@ func RegisterGatewayRoutes(
|
||||
gateway.POST("/responses", h.OpenAIGateway.Responses)
|
||||
}
|
||||
|
||||
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
|
||||
gemini := r.Group("/v1beta")
|
||||
gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService))
|
||||
{
|
||||
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
||||
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
||||
// Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment.
|
||||
gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
|
||||
}
|
||||
|
||||
// OpenAI Responses API(不带v1前缀的别名)
|
||||
r.POST("/responses", gin.HandlerFunc(apiKeyAuth), h.OpenAIGateway.Responses)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ func (a *Account) IsOAuth() bool {
|
||||
return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken
|
||||
}
|
||||
|
||||
func (a *Account) IsGemini() bool {
|
||||
return a.Platform == PlatformGemini
|
||||
}
|
||||
|
||||
func (a *Account) CanGetUsage() bool {
|
||||
return a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -42,19 +44,27 @@ type TestEvent struct {
|
||||
|
||||
// AccountTestService handles account testing operations
|
||||
type AccountTestService struct {
|
||||
accountRepo AccountRepository
|
||||
oauthService *OAuthService
|
||||
openaiOAuthService *OpenAIOAuthService
|
||||
httpUpstream HTTPUpstream
|
||||
accountRepo AccountRepository
|
||||
oauthService *OAuthService
|
||||
openaiOAuthService *OpenAIOAuthService
|
||||
geminiTokenProvider *GeminiTokenProvider
|
||||
httpUpstream HTTPUpstream
|
||||
}
|
||||
|
||||
// NewAccountTestService creates a new AccountTestService
|
||||
func NewAccountTestService(accountRepo AccountRepository, oauthService *OAuthService, openaiOAuthService *OpenAIOAuthService, httpUpstream HTTPUpstream) *AccountTestService {
|
||||
func NewAccountTestService(
|
||||
accountRepo AccountRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
geminiTokenProvider *GeminiTokenProvider,
|
||||
httpUpstream HTTPUpstream,
|
||||
) *AccountTestService {
|
||||
return &AccountTestService{
|
||||
accountRepo: accountRepo,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
httpUpstream: httpUpstream,
|
||||
accountRepo: accountRepo,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
geminiTokenProvider: geminiTokenProvider,
|
||||
httpUpstream: httpUpstream,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +137,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
return s.testOpenAIAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
if account.IsGemini() {
|
||||
return s.testGeminiAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
return s.testClaudeAccountConnection(c, account, modelID)
|
||||
}
|
||||
|
||||
@@ -372,6 +386,252 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
return s.processOpenAIStream(c, resp.Body)
|
||||
}
|
||||
|
||||
// testGeminiAccountConnection tests a Gemini account's connection
|
||||
func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Determine the model to use
|
||||
testModelID := modelID
|
||||
if testModelID == "" {
|
||||
testModelID = geminicli.DefaultTestModel
|
||||
}
|
||||
|
||||
// For API Key accounts with model mapping, map the model
|
||||
if account.Type == AccountTypeApiKey {
|
||||
mapping := account.GetModelMapping()
|
||||
if len(mapping) > 0 {
|
||||
if mappedModel, exists := mapping[testModelID]; exists {
|
||||
testModelID = mappedModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
|
||||
// Create test payload (Gemini format)
|
||||
payload := createGeminiTestPayload()
|
||||
|
||||
// Build request based on account type
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
switch account.Type {
|
||||
case AccountTypeApiKey:
|
||||
req, err = s.buildGeminiAPIKeyRequest(ctx, account, testModelID, payload)
|
||||
case AccountTypeOAuth:
|
||||
req, err = s.buildGeminiOAuthRequest(ctx, account, testModelID, payload)
|
||||
default:
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to build request: %s", err.Error()))
|
||||
}
|
||||
|
||||
// Send test_start event
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||
|
||||
// Get proxy and execute request
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
||||
}
|
||||
|
||||
// Process SSE stream
|
||||
return s.processGeminiStream(c, resp.Body)
|
||||
}
|
||||
|
||||
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
||||
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
||||
apiKey := account.GetCredential("api_key")
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("no API key available")
|
||||
}
|
||||
|
||||
baseURL := account.GetCredential("base_url")
|
||||
if baseURL == "" {
|
||||
baseURL = geminicli.AIStudioBaseURL
|
||||
}
|
||||
|
||||
// Use streamGenerateContent for real-time feedback
|
||||
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse",
|
||||
strings.TrimRight(baseURL, "/"), modelID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", apiKey)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// buildGeminiOAuthRequest builds request for Gemini OAuth accounts
|
||||
func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
||||
if s.geminiTokenProvider == nil {
|
||||
return nil, fmt.Errorf("gemini token provider not configured")
|
||||
}
|
||||
|
||||
// Get access token (auto-refreshes if needed)
|
||||
accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
if projectID == "" {
|
||||
// AI Studio OAuth mode (no project_id): call generativelanguage API directly with Bearer token.
|
||||
baseURL := account.GetCredential("base_url")
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = geminicli.AIStudioBaseURL
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(baseURL, "/"), modelID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Wrap payload in Code Assist format
|
||||
var inner map[string]any
|
||||
if err := json.Unmarshal(payload, &inner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wrapped := map[string]any{
|
||||
"model": modelID,
|
||||
"project": projectID,
|
||||
"request": inner,
|
||||
}
|
||||
wrappedBytes, _ := json.Marshal(wrapped)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", geminicli.GeminiCliBaseURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(wrappedBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// createGeminiTestPayload creates a minimal test payload for Gemini API
|
||||
func createGeminiTestPayload() []byte {
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"parts": []map[string]any{
|
||||
{"text": "hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]any{
|
||||
{"text": "You are a helpful AI assistant."},
|
||||
},
|
||||
},
|
||||
}
|
||||
bytes, _ := json.Marshal(payload)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// processGeminiStream processes SSE stream from Gemini API
|
||||
func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader) error {
|
||||
reader := bufio.NewReader(body)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read error: %s", err.Error()))
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimPrefix(line, "data: ")
|
||||
if jsonStr == "[DONE]" {
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Support two Gemini response formats:
|
||||
// - AI Studio: {"candidates": [...]}
|
||||
// - Gemini CLI: {"response": {"candidates": [...]}}
|
||||
if resp, ok := data["response"].(map[string]any); ok && resp != nil {
|
||||
data = resp
|
||||
}
|
||||
if candidates, ok := data["candidates"].([]any); ok && len(candidates) > 0 {
|
||||
if candidate, ok := candidates[0].(map[string]any); ok {
|
||||
// Check for completion
|
||||
if finishReason, ok := candidate["finishReason"].(string); ok && finishReason != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract content
|
||||
if content, ok := candidate["content"].(map[string]any); ok {
|
||||
if parts, ok := content["parts"].([]any); ok {
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]any); ok {
|
||||
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: text})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if errData, ok := data["error"].(map[string]any); ok {
|
||||
errorMsg := "Unknown error"
|
||||
if msg, ok := errData["message"].(string); ok {
|
||||
errorMsg = msg
|
||||
}
|
||||
return s.sendErrorAndEnd(c, errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createOpenAITestPayload creates a test payload for OpenAI Responses API
|
||||
func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
||||
payload := map[string]any{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ type CRSSyncService struct {
|
||||
proxyRepo ProxyRepository
|
||||
oauthService *OAuthService
|
||||
openaiOAuthService *OpenAIOAuthService
|
||||
geminiOAuthService *GeminiOAuthService
|
||||
}
|
||||
|
||||
func NewCRSSyncService(
|
||||
@@ -25,12 +27,14 @@ func NewCRSSyncService(
|
||||
proxyRepo ProxyRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
geminiOAuthService *GeminiOAuthService,
|
||||
) *CRSSyncService {
|
||||
return &CRSSyncService{
|
||||
accountRepo: accountRepo,
|
||||
proxyRepo: proxyRepo,
|
||||
oauthService: oauthService,
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
geminiOAuthService: geminiOAuthService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +79,8 @@ type crsExportResponse struct {
|
||||
ClaudeConsoleAccounts []crsConsoleAccount `json:"claudeConsoleAccounts"`
|
||||
OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"`
|
||||
OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"`
|
||||
GeminiOAuthAccounts []crsGeminiOAuthAccount `json:"geminiOAuthAccounts"`
|
||||
GeminiAPIKeyAccounts []crsGeminiAPIKeyAccount `json:"geminiApiKeyAccounts"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -147,6 +153,37 @@ type crsOpenAIOAuthAccount struct {
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
type crsGeminiOAuthAccount struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
AuthType string `json:"authType"` // oauth
|
||||
IsActive bool `json:"isActive"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Proxy *crsProxy `json:"proxy"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
type crsGeminiAPIKeyAccount struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
IsActive bool `json:"isActive"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Proxy *crsProxy `json:"proxy"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||
baseURL, err := normalizeBaseURL(input.BaseURL)
|
||||
if err != nil {
|
||||
@@ -174,7 +211,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
Items: make(
|
||||
[]SyncFromCRSItemResult,
|
||||
0,
|
||||
len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts),
|
||||
len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts)+len(exported.Data.GeminiOAuthAccounts)+len(exported.Data.GeminiAPIKeyAccounts),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -678,6 +715,225 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
// Gemini OAuth -> sub2api gemini oauth
|
||||
for _, src := range exported.Data.GeminiOAuthAccounts {
|
||||
item := SyncFromCRSItemResult{
|
||||
CRSAccountID: src.ID,
|
||||
Kind: src.Kind,
|
||||
Name: src.Name,
|
||||
}
|
||||
|
||||
refreshToken, _ := src.Credentials["refresh_token"].(string)
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
item.Action = "failed"
|
||||
item.Error = "missing refresh_token"
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name))
|
||||
if err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "proxy sync failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||
if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
credentials["token_type"] = "Bearer"
|
||||
}
|
||||
// Convert expires_at from RFC3339 to Unix seconds string (recommended to keep consistent with GetCredential())
|
||||
if expiresAtStr, ok := credentials["expires_at"].(string); ok && strings.TrimSpace(expiresAtStr) != "" {
|
||||
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
|
||||
credentials["expires_at"] = strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
}
|
||||
|
||||
extra := make(map[string]any)
|
||||
if src.Extra != nil {
|
||||
for k, v := range src.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
extra["crs_account_id"] = src.ID
|
||||
extra["crs_kind"] = src.Kind
|
||||
extra["crs_synced_at"] = now
|
||||
|
||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||
if err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "db lookup failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
ProxyID: proxyID,
|
||||
Concurrency: 3,
|
||||
Priority: clampPriority(src.Priority),
|
||||
Status: mapCRSStatus(src.IsActive, src.Status),
|
||||
Schedulable: src.Schedulable,
|
||||
}
|
||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "create failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil {
|
||||
account.Credentials = refreshedCreds
|
||||
_ = s.accountRepo.Update(ctx, account)
|
||||
}
|
||||
item.Action = "created"
|
||||
result.Created++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Extra = mergeMap(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = PlatformGemini
|
||||
existing.Type = AccountTypeOAuth
|
||||
existing.Credentials = mergeMap(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = 3
|
||||
existing.Priority = clampPriority(src.Priority)
|
||||
existing.Status = mapCRSStatus(src.IsActive, src.Status)
|
||||
existing.Schedulable = src.Schedulable
|
||||
|
||||
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "update failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil {
|
||||
existing.Credentials = refreshedCreds
|
||||
_ = s.accountRepo.Update(ctx, existing)
|
||||
}
|
||||
|
||||
item.Action = "updated"
|
||||
result.Updated++
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
// Gemini API Key -> sub2api gemini apikey
|
||||
for _, src := range exported.Data.GeminiAPIKeyAccounts {
|
||||
item := SyncFromCRSItemResult{
|
||||
CRSAccountID: src.ID,
|
||||
Kind: src.Kind,
|
||||
Name: src.Name,
|
||||
}
|
||||
|
||||
apiKey, _ := src.Credentials["api_key"].(string)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
item.Action = "failed"
|
||||
item.Error = "missing api_key"
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name))
|
||||
if err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "proxy sync failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||
if baseURL, ok := credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" {
|
||||
credentials["base_url"] = "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
|
||||
extra := make(map[string]any)
|
||||
if src.Extra != nil {
|
||||
for k, v := range src.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
extra["crs_account_id"] = src.ID
|
||||
extra["crs_kind"] = src.Kind
|
||||
extra["crs_synced_at"] = now
|
||||
|
||||
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||
if err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "db lookup failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeApiKey,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
ProxyID: proxyID,
|
||||
Concurrency: 3,
|
||||
Priority: clampPriority(src.Priority),
|
||||
Status: mapCRSStatus(src.IsActive, src.Status),
|
||||
Schedulable: src.Schedulable,
|
||||
}
|
||||
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "create failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
item.Action = "created"
|
||||
result.Created++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Extra = mergeMap(existing.Extra, extra)
|
||||
existing.Name = defaultName(src.Name, src.ID)
|
||||
existing.Platform = PlatformGemini
|
||||
existing.Type = AccountTypeApiKey
|
||||
existing.Credentials = mergeMap(existing.Credentials, credentials)
|
||||
if proxyID != nil {
|
||||
existing.ProxyID = proxyID
|
||||
}
|
||||
existing.Concurrency = 3
|
||||
existing.Priority = clampPriority(src.Priority)
|
||||
existing.Status = mapCRSStatus(src.IsActive, src.Status)
|
||||
existing.Schedulable = src.Schedulable
|
||||
|
||||
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||
item.Action = "failed"
|
||||
item.Error = "update failed: " + err.Error()
|
||||
result.Failed++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
|
||||
item.Action = "updated"
|
||||
result.Updated++
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -944,6 +1200,21 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account
|
||||
}
|
||||
}
|
||||
}
|
||||
case PlatformGemini:
|
||||
if s.geminiOAuthService == nil {
|
||||
return nil
|
||||
}
|
||||
tokenInfo, refreshErr := s.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if refreshErr != nil {
|
||||
err = refreshErr
|
||||
} else {
|
||||
newCredentials = s.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -320,8 +320,17 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
|
||||
selected = acc
|
||||
} else if acc.Priority == selected.Priority {
|
||||
// 优先级相同时,选最久未用的
|
||||
if acc.LastUsedAt == nil || (selected.LastUsedAt != nil && acc.LastUsedAt.Before(*selected.LastUsedAt)) {
|
||||
switch {
|
||||
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
||||
selected = acc
|
||||
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
||||
// keep selected (never used is preferred)
|
||||
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
||||
// keep selected (both never used)
|
||||
default:
|
||||
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
||||
selected = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2176
backend/internal/service/gemini_messages_compat_service.go
Normal file
2176
backend/internal/service/gemini_messages_compat_service.go
Normal file
File diff suppressed because it is too large
Load Diff
13
backend/internal/service/gemini_oauth.go
Normal file
13
backend/internal/service/gemini_oauth.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
// GeminiOAuthClient performs Google OAuth token exchange/refresh for Gemini integration.
|
||||
type GeminiOAuthClient interface {
|
||||
ExchangeCode(ctx context.Context, oauthType, code, codeVerifier, redirectURI, proxyURL string) (*geminicli.TokenResponse, error)
|
||||
RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*geminicli.TokenResponse, error)
|
||||
}
|
||||
555
backend/internal/service/gemini_oauth_service.go
Normal file
555
backend/internal/service/gemini_oauth_service.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
type GeminiOAuthService struct {
|
||||
sessionStore *geminicli.SessionStore
|
||||
proxyRepo ProxyRepository
|
||||
oauthClient GeminiOAuthClient
|
||||
codeAssist GeminiCliCodeAssistClient
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type GeminiOAuthCapabilities struct {
|
||||
AIStudioOAuthEnabled bool `json:"ai_studio_oauth_enabled"`
|
||||
RequiredRedirectURIs []string `json:"required_redirect_uris"`
|
||||
}
|
||||
|
||||
func NewGeminiOAuthService(
|
||||
proxyRepo ProxyRepository,
|
||||
oauthClient GeminiOAuthClient,
|
||||
codeAssist GeminiCliCodeAssistClient,
|
||||
cfg *config.Config,
|
||||
) *GeminiOAuthService {
|
||||
return &GeminiOAuthService{
|
||||
sessionStore: geminicli.NewSessionStore(),
|
||||
proxyRepo: proxyRepo,
|
||||
oauthClient: oauthClient,
|
||||
codeAssist: codeAssist,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities {
|
||||
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
|
||||
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
|
||||
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
|
||||
enabled := clientID != "" && clientSecret != "" &&
|
||||
(clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret)
|
||||
|
||||
return &GeminiOAuthCapabilities{
|
||||
AIStudioOAuthEnabled: enabled,
|
||||
RequiredRedirectURIs: []string{geminicli.AIStudioOAuthRedirectURI},
|
||||
}
|
||||
}
|
||||
|
||||
type GeminiAuthURLResult struct {
|
||||
AuthURL string `json:"auth_url"`
|
||||
SessionID string `json:"session_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, redirectURI, projectID, oauthType string) (*GeminiAuthURLResult, error) {
|
||||
state, err := geminicli.GenerateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate state: %w", err)
|
||||
}
|
||||
codeVerifier, err := geminicli.GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
codeChallenge := geminicli.GenerateCodeChallenge(codeVerifier)
|
||||
sessionID, err := geminicli.GenerateSessionID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if proxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth client selection:
|
||||
// - code_assist: always use built-in Gemini CLI OAuth client (public), regardless of configured client_id/secret.
|
||||
// - ai_studio: requires a user-provided OAuth client.
|
||||
oauthCfg := geminicli.OAuthConfig{
|
||||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||||
}
|
||||
if oauthType == "code_assist" {
|
||||
oauthCfg.ClientID = ""
|
||||
oauthCfg.ClientSecret = ""
|
||||
}
|
||||
|
||||
session := &geminicli.OAuthSession{
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
ProxyURL: proxyURL,
|
||||
RedirectURI: redirectURI,
|
||||
ProjectID: strings.TrimSpace(projectID),
|
||||
OAuthType: oauthType,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.sessionStore.Set(sessionID, session)
|
||||
|
||||
effectiveCfg, err := geminicli.EffectiveOAuthConfig(oauthCfg, oauthType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||||
|
||||
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
|
||||
if oauthType == "ai_studio" && isBuiltinClient {
|
||||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client (GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET). If you don't want to configure an OAuth client, please use an AI Studio API Key account instead")
|
||||
}
|
||||
|
||||
// Redirect URI strategy:
|
||||
// - code_assist: use Gemini CLI redirect URI (codeassist.google.com/authcode)
|
||||
// - ai_studio: use localhost callback for manual copy/paste flow
|
||||
if oauthType == "code_assist" {
|
||||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||||
} else {
|
||||
redirectURI = geminicli.AIStudioOAuthRedirectURI
|
||||
}
|
||||
session.RedirectURI = redirectURI
|
||||
s.sessionStore.Set(sessionID, session)
|
||||
|
||||
authURL, err := geminicli.BuildAuthorizationURL(effectiveCfg, state, codeChallenge, redirectURI, session.ProjectID, oauthType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GeminiAuthURLResult{
|
||||
AuthURL: authURL,
|
||||
SessionID: sessionID,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GeminiExchangeCodeInput struct {
|
||||
SessionID string
|
||||
State string
|
||||
Code string
|
||||
ProxyID *int64
|
||||
OAuthType string // "code_assist" 或 "ai_studio"
|
||||
}
|
||||
|
||||
type GeminiTokenInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
OAuthType string `json:"oauth_type,omitempty"` // "code_assist" 或 "ai_studio"
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExchangeCodeInput) (*GeminiTokenInfo, error) {
|
||||
session, ok := s.sessionStore.Get(input.SessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
if strings.TrimSpace(input.State) == "" || input.State != session.State {
|
||||
return nil, fmt.Errorf("invalid state")
|
||||
}
|
||||
|
||||
proxyURL := session.ProxyURL
|
||||
if input.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
redirectURI := session.RedirectURI
|
||||
|
||||
// Resolve oauth_type early (defaults to code_assist for backward compatibility).
|
||||
oauthType := session.OAuthType
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
|
||||
// If the session was created for AI Studio OAuth, ensure a custom OAuth client is configured.
|
||||
if oauthType == "ai_studio" {
|
||||
effectiveCfg, err := geminicli.EffectiveOAuthConfig(geminicli.OAuthConfig{
|
||||
ClientID: s.cfg.Gemini.OAuth.ClientID,
|
||||
ClientSecret: s.cfg.Gemini.OAuth.ClientSecret,
|
||||
Scopes: s.cfg.Gemini.OAuth.Scopes,
|
||||
}, "ai_studio")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID &&
|
||||
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
|
||||
if isBuiltinClient {
|
||||
return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// code_assist always uses the built-in client and its fixed redirect URI.
|
||||
if oauthType == "code_assist" {
|
||||
redirectURI = geminicli.GeminiCLIRedirectURI
|
||||
}
|
||||
|
||||
tokenResp, err := s.oauthClient.ExchangeCode(ctx, oauthType, input.Code, session.CodeVerifier, redirectURI, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
sessionProjectID := strings.TrimSpace(session.ProjectID)
|
||||
s.sessionStore.Delete(input.SessionID)
|
||||
|
||||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||||
|
||||
projectID := sessionProjectID
|
||||
|
||||
// 对于 code_assist 模式,project_id 是必需的
|
||||
// 对于 ai_studio 模式,project_id 是可选的(不影响使用 AI Studio API)
|
||||
if oauthType == "code_assist" {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
projectID, err = s.fetchProjectID(ctx, tokenResp.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
// 记录警告但不阻断流程,允许后续补充 project_id
|
||||
fmt.Printf("[GeminiOAuth] Warning: Failed to fetch project_id during token exchange: %v\n", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, fmt.Errorf("missing project_id for Code Assist OAuth: please fill Project ID (optional field) and regenerate the auth URL, or ensure your Google account has an ACTIVE GCP project")
|
||||
}
|
||||
}
|
||||
|
||||
return &GeminiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: tokenResp.Scope,
|
||||
ProjectID: projectID,
|
||||
OAuthType: oauthType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
tokenResp, err := s.oauthClient.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
||||
if err == nil {
|
||||
// 计算过期时间时减去 5 分钟安全时间窗口,考虑网络延迟和时钟偏差
|
||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
||||
return &GeminiTokenInfo{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: tokenResp.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if isNonRetryableGeminiOAuthError(err) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after retries: %w", lastErr)
|
||||
}
|
||||
|
||||
func isNonRetryableGeminiOAuthError(err error) bool {
|
||||
msg := err.Error()
|
||||
nonRetryable := []string{
|
||||
"invalid_grant",
|
||||
"invalid_client",
|
||||
"unauthorized_client",
|
||||
"access_denied",
|
||||
}
|
||||
for _, needle := range nonRetryable {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*GeminiTokenInfo, error) {
|
||||
if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth {
|
||||
return nil, fmt.Errorf("account is not a Gemini OAuth account")
|
||||
}
|
||||
|
||||
refreshToken := account.GetCredential("refresh_token")
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
return nil, fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
// Preserve oauth_type from the account (defaults to code_assist for backward compatibility).
|
||||
oauthType := strings.TrimSpace(account.GetCredential("oauth_type"))
|
||||
if oauthType == "" {
|
||||
oauthType = "code_assist"
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := s.RefreshToken(ctx, oauthType, refreshToken, proxyURL)
|
||||
// Backward compatibility:
|
||||
// Older versions could refresh Code Assist tokens using a user-provided OAuth client when configured.
|
||||
// If the refresh token was originally issued to that custom client, forcing the built-in client will
|
||||
// fail with "unauthorized_client". In that case, retry with the custom client (ai_studio path) when available.
|
||||
if err != nil && oauthType == "code_assist" && strings.Contains(err.Error(), "unauthorized_client") && s.GetOAuthConfig().AIStudioOAuthEnabled {
|
||||
if alt, altErr := s.RefreshToken(ctx, "ai_studio", refreshToken, proxyURL); altErr == nil {
|
||||
tokenInfo = alt
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Provide a more actionable error for common OAuth client mismatch issues.
|
||||
if strings.Contains(err.Error(), "unauthorized_client") {
|
||||
return nil, fmt.Errorf("%w (OAuth client mismatch: the refresh_token is bound to the OAuth client used during authorization; please re-authorize this account or restore the original GEMINI_OAUTH_CLIENT_ID/SECRET)", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenInfo.OAuthType = oauthType
|
||||
|
||||
// Preserve account's project_id when present.
|
||||
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
if existingProjectID != "" {
|
||||
tokenInfo.ProjectID = existingProjectID
|
||||
}
|
||||
|
||||
// For Code Assist, project_id is required. Auto-detect if missing.
|
||||
// For AI Studio OAuth, project_id is optional and should not block refresh.
|
||||
if oauthType == "code_assist" && strings.TrimSpace(tokenInfo.ProjectID) == "" {
|
||||
projectID, err := s.fetchProjectID(ctx, tokenInfo.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-detect project_id: %w", err)
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("failed to auto-detect project_id: empty result")
|
||||
}
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) BuildAccountCredentials(tokenInfo *GeminiTokenInfo) map[string]any {
|
||||
creds := map[string]any{
|
||||
"access_token": tokenInfo.AccessToken,
|
||||
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
||||
}
|
||||
if tokenInfo.RefreshToken != "" {
|
||||
creds["refresh_token"] = tokenInfo.RefreshToken
|
||||
}
|
||||
if tokenInfo.TokenType != "" {
|
||||
creds["token_type"] = tokenInfo.TokenType
|
||||
}
|
||||
if tokenInfo.Scope != "" {
|
||||
creds["scope"] = tokenInfo.Scope
|
||||
}
|
||||
if tokenInfo.ProjectID != "" {
|
||||
creds["project_id"] = tokenInfo.ProjectID
|
||||
}
|
||||
if tokenInfo.OAuthType != "" {
|
||||
creds["oauth_type"] = tokenInfo.OAuthType
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) Stop() {
|
||||
s.sessionStore.Stop()
|
||||
}
|
||||
|
||||
func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, proxyURL string) (string, error) {
|
||||
if s.codeAssist == nil {
|
||||
return "", errors.New("code assist client not configured")
|
||||
}
|
||||
|
||||
loadResp, loadErr := s.codeAssist.LoadCodeAssist(ctx, accessToken, proxyURL, nil)
|
||||
if loadErr == nil && loadResp != nil && strings.TrimSpace(loadResp.CloudAICompanionProject) != "" {
|
||||
return strings.TrimSpace(loadResp.CloudAICompanionProject), nil
|
||||
}
|
||||
|
||||
// Pick tier from allowedTiers; if no default tier is marked, pick the first non-empty tier ID.
|
||||
tierID := "LEGACY"
|
||||
if loadResp != nil {
|
||||
for _, tier := range loadResp.AllowedTiers {
|
||||
if tier.IsDefault && strings.TrimSpace(tier.ID) != "" {
|
||||
tierID = strings.TrimSpace(tier.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(tierID) == "" || tierID == "LEGACY" {
|
||||
for _, tier := range loadResp.AllowedTiers {
|
||||
if strings.TrimSpace(tier.ID) != "" {
|
||||
tierID = strings.TrimSpace(tier.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req := &geminicli.OnboardUserRequest{
|
||||
TierID: tierID,
|
||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||
IDEType: "ANTIGRAVITY",
|
||||
Platform: "PLATFORM_UNSPECIFIED",
|
||||
PluginType: "GEMINI",
|
||||
},
|
||||
}
|
||||
|
||||
maxAttempts := 5
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
resp, err := s.codeAssist.OnboardUser(ctx, accessToken, proxyURL, req)
|
||||
if err != nil {
|
||||
// If Code Assist onboarding fails (e.g. INVALID_ARGUMENT), fallback to Cloud Resource Manager projects.
|
||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||
return strings.TrimSpace(fallback), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if resp.Done {
|
||||
if resp.Response != nil && resp.Response.CloudAICompanionProject != nil {
|
||||
switch v := resp.Response.CloudAICompanionProject.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v), nil
|
||||
case map[string]any:
|
||||
if id, ok := v["id"].(string); ok {
|
||||
return strings.TrimSpace(id), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||
return strings.TrimSpace(fallback), nil
|
||||
}
|
||||
return "", errors.New("onboardUser completed but no project_id returned")
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL)
|
||||
if fbErr == nil && strings.TrimSpace(fallback) != "" {
|
||||
return strings.TrimSpace(fallback), nil
|
||||
}
|
||||
if loadErr != nil {
|
||||
return "", fmt.Errorf("loadCodeAssist failed (%v) and onboardUser timeout after %d attempts", loadErr, maxAttempts)
|
||||
}
|
||||
return "", fmt.Errorf("onboardUser timeout after %d attempts", maxAttempts)
|
||||
}
|
||||
|
||||
type googleCloudProject struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
DisplayName string `json:"name"`
|
||||
LifecycleState string `json:"lifecycleState"`
|
||||
}
|
||||
|
||||
type googleCloudProjectsResponse struct {
|
||||
Projects []googleCloudProject `json:"projects"`
|
||||
}
|
||||
|
||||
func fetchProjectIDFromResourceManager(ctx context.Context, accessToken, proxyURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create resource manager request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("User-Agent", geminicli.GeminiCLIUserAgent)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
if strings.TrimSpace(proxyURL) != "" {
|
||||
if proxyURLParsed, err := url.Parse(strings.TrimSpace(proxyURL)); err == nil {
|
||||
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURLParsed)}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resource manager request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read resource manager response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("resource manager HTTP %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var projectsResp googleCloudProjectsResponse
|
||||
if err := json.Unmarshal(bodyBytes, &projectsResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse resource manager response: %w", err)
|
||||
}
|
||||
|
||||
active := make([]googleCloudProject, 0, len(projectsResp.Projects))
|
||||
for _, p := range projectsResp.Projects {
|
||||
if p.LifecycleState == "ACTIVE" && strings.TrimSpace(p.ProjectID) != "" {
|
||||
active = append(active, p)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return "", errors.New("no ACTIVE projects found from resource manager")
|
||||
}
|
||||
|
||||
// Prefer likely companion projects first.
|
||||
for _, p := range active {
|
||||
id := strings.ToLower(strings.TrimSpace(p.ProjectID))
|
||||
name := strings.ToLower(strings.TrimSpace(p.DisplayName))
|
||||
if strings.Contains(id, "cloud-ai-companion") || strings.Contains(name, "cloud ai companion") || strings.Contains(name, "code assist") {
|
||||
return strings.TrimSpace(p.ProjectID), nil
|
||||
}
|
||||
}
|
||||
// Then prefer "default".
|
||||
for _, p := range active {
|
||||
id := strings.ToLower(strings.TrimSpace(p.ProjectID))
|
||||
name := strings.ToLower(strings.TrimSpace(p.DisplayName))
|
||||
if strings.Contains(id, "default") || strings.Contains(name, "default") {
|
||||
return strings.TrimSpace(p.ProjectID), nil
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(active[0].ProjectID), nil
|
||||
}
|
||||
16
backend/internal/service/gemini_token_cache.go
Normal file
16
backend/internal/service/gemini_token_cache.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GeminiTokenCache stores short-lived access tokens and coordinates refresh to avoid stampedes.
|
||||
type GeminiTokenCache interface {
|
||||
// cacheKey should be stable for the token scope; for GeminiCli OAuth we primarily use project_id.
|
||||
GetAccessToken(ctx context.Context, cacheKey string) (string, error)
|
||||
SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error
|
||||
|
||||
AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error)
|
||||
ReleaseRefreshLock(ctx context.Context, cacheKey string) error
|
||||
}
|
||||
171
backend/internal/service/gemini_token_provider.go
Normal file
171
backend/internal/service/gemini_token_provider.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiTokenRefreshSkew = 3 * time.Minute
|
||||
geminiTokenCacheSkew = 5 * time.Minute
|
||||
)
|
||||
|
||||
type GeminiTokenProvider struct {
|
||||
accountRepo AccountRepository
|
||||
tokenCache GeminiTokenCache
|
||||
geminiOAuthService *GeminiOAuthService
|
||||
}
|
||||
|
||||
func NewGeminiTokenProvider(
|
||||
accountRepo AccountRepository,
|
||||
tokenCache GeminiTokenCache,
|
||||
geminiOAuthService *GeminiOAuthService,
|
||||
) *GeminiTokenProvider {
|
||||
return &GeminiTokenProvider{
|
||||
accountRepo: accountRepo,
|
||||
tokenCache: tokenCache,
|
||||
geminiOAuthService: geminiOAuthService,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) {
|
||||
if account == nil {
|
||||
return "", errors.New("account is nil")
|
||||
}
|
||||
if account.Platform != PlatformGemini || account.Type != AccountTypeOAuth {
|
||||
return "", errors.New("not a gemini oauth account")
|
||||
}
|
||||
|
||||
cacheKey := geminiTokenCacheKey(account)
|
||||
|
||||
// 1) Try cache first.
|
||||
if p.tokenCache != nil {
|
||||
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Refresh if needed (pre-expiry skew).
|
||||
expiresAt := parseExpiresAt(account)
|
||||
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew
|
||||
if needsRefresh && p.tokenCache != nil {
|
||||
locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
|
||||
if err == nil && locked {
|
||||
defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
|
||||
|
||||
// Re-check after lock (another worker may have refreshed).
|
||||
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
fresh, err := p.accountRepo.GetByID(ctx, account.ID)
|
||||
if err == nil && fresh != nil {
|
||||
account = fresh
|
||||
}
|
||||
expiresAt = parseExpiresAt(account)
|
||||
if expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew {
|
||||
if p.geminiOAuthService == nil {
|
||||
return "", errors.New("gemini oauth service not configured")
|
||||
}
|
||||
tokenInfo, err := p.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newCredentials := p.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
account.Credentials = newCredentials
|
||||
_ = p.accountRepo.Update(ctx, account)
|
||||
expiresAt = parseExpiresAt(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accessToken := account.GetCredential("access_token")
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return "", errors.New("access_token not found in credentials")
|
||||
}
|
||||
|
||||
// project_id is optional now:
|
||||
// - If present: will use Code Assist API (requires project_id)
|
||||
// - If absent: will use AI Studio API with OAuth token (like regular API key mode)
|
||||
// Auto-detect project_id only if explicitly enabled via a credential flag
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
autoDetectProjectID := account.GetCredential("auto_detect_project_id") == "true"
|
||||
|
||||
if projectID == "" && autoDetectProjectID {
|
||||
if p.geminiOAuthService == nil {
|
||||
return accessToken, nil // Fallback to AI Studio API mode
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil && p.geminiOAuthService.proxyRepo != nil {
|
||||
if proxy, err := p.geminiOAuthService.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
detected, err := p.geminiOAuthService.fetchProjectID(ctx, accessToken, proxyURL)
|
||||
if err != nil {
|
||||
log.Printf("[GeminiTokenProvider] Auto-detect project_id failed: %v, fallback to AI Studio API mode", err)
|
||||
return accessToken, nil
|
||||
}
|
||||
detected = strings.TrimSpace(detected)
|
||||
if detected != "" {
|
||||
if account.Credentials == nil {
|
||||
account.Credentials = make(map[string]any)
|
||||
}
|
||||
account.Credentials["project_id"] = detected
|
||||
_ = p.accountRepo.Update(ctx, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Populate cache with TTL.
|
||||
if p.tokenCache != nil {
|
||||
ttl := 30 * time.Minute
|
||||
if expiresAt != nil {
|
||||
until := time.Until(*expiresAt)
|
||||
switch {
|
||||
case until > geminiTokenCacheSkew:
|
||||
ttl = until - geminiTokenCacheSkew
|
||||
case until > 0:
|
||||
ttl = until
|
||||
default:
|
||||
ttl = time.Minute
|
||||
}
|
||||
}
|
||||
_ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
func geminiTokenCacheKey(account *Account) string {
|
||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
if projectID != "" {
|
||||
return projectID
|
||||
}
|
||||
return "account:" + strconv.FormatInt(account.ID, 10)
|
||||
}
|
||||
|
||||
func parseExpiresAt(account *Account) *time.Time {
|
||||
raw := strings.TrimSpace(account.GetCredential("expires_at"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if unixSec, err := strconv.ParseInt(raw, 10, 64); err == nil && unixSec > 0 {
|
||||
t := time.Unix(unixSec, 0)
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
backend/internal/service/gemini_token_refresher.go
Normal file
51
backend/internal/service/gemini_token_refresher.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeminiTokenRefresher struct {
|
||||
geminiOAuthService *GeminiOAuthService
|
||||
}
|
||||
|
||||
func NewGeminiTokenRefresher(geminiOAuthService *GeminiOAuthService) *GeminiTokenRefresher {
|
||||
return &GeminiTokenRefresher{geminiOAuthService: geminiOAuthService}
|
||||
}
|
||||
|
||||
func (r *GeminiTokenRefresher) CanRefresh(account *Account) bool {
|
||||
return account.Platform == PlatformGemini && account.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
func (r *GeminiTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
||||
if !r.CanRefresh(account) {
|
||||
return false
|
||||
}
|
||||
expiresAtStr := account.GetCredential("expires_at")
|
||||
if expiresAtStr == "" {
|
||||
return false
|
||||
}
|
||||
expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expiryTime := time.Unix(expiresAt, 0)
|
||||
return time.Until(expiryTime) < refreshWindow
|
||||
}
|
||||
|
||||
func (r *GeminiTokenRefresher) Refresh(ctx context.Context, account *Account) (map[string]any, error) {
|
||||
tokenInfo, err := r.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newCredentials := r.geminiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
for k, v := range account.Credentials {
|
||||
if _, exists := newCredentials[k]; !exists {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return newCredentials, nil
|
||||
}
|
||||
13
backend/internal/service/geminicli_codeassist.go
Normal file
13
backend/internal/service/geminicli_codeassist.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
// GeminiCliCodeAssistClient calls GeminiCli internal Code Assist endpoints.
|
||||
type GeminiCliCodeAssistClient interface {
|
||||
LoadCodeAssist(ctx context.Context, accessToken, proxyURL string, req *geminicli.LoadCodeAssistRequest) (*geminicli.LoadCodeAssistResponse, error)
|
||||
OnboardUser(ctx context.Context, accessToken, proxyURL string, req *geminicli.OnboardUserRequest) (*geminicli.OnboardUserResponse, error)
|
||||
}
|
||||
@@ -170,9 +170,18 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI
|
||||
if acc.Priority < selected.Priority {
|
||||
selected = acc
|
||||
} else if acc.Priority == selected.Priority {
|
||||
// Same priority, select least recently used
|
||||
if acc.LastUsedAt == nil || (selected.LastUsedAt != nil && acc.LastUsedAt.Before(*selected.LastUsedAt)) {
|
||||
switch {
|
||||
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
||||
selected = acc
|
||||
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
||||
// keep selected (never used is preferred)
|
||||
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
||||
// keep selected (both never used)
|
||||
default:
|
||||
// Same priority, select least recently used
|
||||
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
||||
selected = acc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,27 +393,32 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
|
||||
return nil
|
||||
}
|
||||
|
||||
// 标准化模型名称
|
||||
modelLower := strings.ToLower(modelName)
|
||||
// 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀)
|
||||
modelLower := strings.ToLower(strings.TrimSpace(modelName))
|
||||
lookupCandidates := s.buildModelLookupCandidates(modelLower)
|
||||
|
||||
// 1. 精确匹配
|
||||
if pricing, ok := s.pricingData[modelLower]; ok {
|
||||
return pricing
|
||||
}
|
||||
if pricing, ok := s.pricingData[modelName]; ok {
|
||||
return pricing
|
||||
for _, candidate := range lookupCandidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if pricing, ok := s.pricingData[candidate]; ok {
|
||||
return pricing
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理常见的模型名称变体
|
||||
// claude-opus-4-5-20251101 -> claude-opus-4.5-20251101
|
||||
normalized := strings.ReplaceAll(modelLower, "-4-5-", "-4.5-")
|
||||
if pricing, ok := s.pricingData[normalized]; ok {
|
||||
return pricing
|
||||
for _, candidate := range lookupCandidates {
|
||||
normalized := strings.ReplaceAll(candidate, "-4-5-", "-4.5-")
|
||||
if pricing, ok := s.pricingData[normalized]; ok {
|
||||
return pricing
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试模糊匹配(去掉版本号后缀)
|
||||
// claude-opus-4-5-20251101 -> claude-opus-4.5
|
||||
baseName := s.extractBaseName(modelLower)
|
||||
baseName := s.extractBaseName(lookupCandidates[0])
|
||||
for key, pricing := range s.pricingData {
|
||||
keyBase := s.extractBaseName(strings.ToLower(key))
|
||||
if keyBase == baseName {
|
||||
@@ -422,18 +427,77 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
|
||||
}
|
||||
|
||||
// 4. 基于模型系列匹配(Claude)
|
||||
if pricing := s.matchByModelFamily(modelLower); pricing != nil {
|
||||
if pricing := s.matchByModelFamily(lookupCandidates[0]); pricing != nil {
|
||||
return pricing
|
||||
}
|
||||
|
||||
// 5. OpenAI 模型回退策略
|
||||
if strings.HasPrefix(modelLower, "gpt-") {
|
||||
return s.matchOpenAIModel(modelLower)
|
||||
if strings.HasPrefix(lookupCandidates[0], "gpt-") {
|
||||
return s.matchOpenAIModel(lookupCandidates[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
|
||||
// Prefer canonical model name first (this also improves billing compatibility with "models/xxx").
|
||||
candidates := []string{
|
||||
normalizeModelNameForPricing(modelLower),
|
||||
modelLower,
|
||||
}
|
||||
candidates = append(candidates,
|
||||
strings.TrimPrefix(modelLower, "models/"),
|
||||
lastSegment(modelLower),
|
||||
lastSegment(strings.TrimPrefix(modelLower, "models/")),
|
||||
)
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{modelLower}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeModelNameForPricing(model string) string {
|
||||
// Common Gemini/VertexAI forms:
|
||||
// - models/gemini-2.0-flash-exp
|
||||
// - publishers/google/models/gemini-1.5-pro
|
||||
// - projects/.../locations/.../publishers/google/models/gemini-1.5-pro
|
||||
model = strings.TrimSpace(model)
|
||||
model = strings.TrimLeft(model, "/")
|
||||
model = strings.TrimPrefix(model, "models/")
|
||||
model = strings.TrimPrefix(model, "publishers/google/models/")
|
||||
|
||||
if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 {
|
||||
model = model[idx+len("/publishers/google/models/"):]
|
||||
}
|
||||
if idx := strings.LastIndex(model, "/models/"); idx != -1 {
|
||||
model = model[idx+len("/models/"):]
|
||||
}
|
||||
|
||||
model = strings.TrimLeft(model, "/")
|
||||
return model
|
||||
}
|
||||
|
||||
func lastSegment(model string) string {
|
||||
if idx := strings.LastIndex(model, "/"); idx != -1 {
|
||||
return model[idx+1:]
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// extractBaseName 提取基础模型名称(去掉日期版本号)
|
||||
func (s *PricingService) extractBaseName(model string) string {
|
||||
// 移除日期后缀 (如 -20251101, -20241022)
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewTokenRefreshService(
|
||||
accountRepo AccountRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
geminiOAuthService *GeminiOAuthService,
|
||||
cfg *config.Config,
|
||||
) *TokenRefreshService {
|
||||
s := &TokenRefreshService{
|
||||
@@ -38,6 +39,7 @@ func NewTokenRefreshService(
|
||||
s.refreshers = []TokenRefresher{
|
||||
NewClaudeTokenRefresher(oauthService),
|
||||
NewOpenAITokenRefresher(openaiOAuthService),
|
||||
NewGeminiTokenRefresher(geminiOAuthService),
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
@@ -36,9 +36,10 @@ func ProvideTokenRefreshService(
|
||||
accountRepo AccountRepository,
|
||||
oauthService *OAuthService,
|
||||
openaiOAuthService *OpenAIOAuthService,
|
||||
geminiOAuthService *GeminiOAuthService,
|
||||
cfg *config.Config,
|
||||
) *TokenRefreshService {
|
||||
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, cfg)
|
||||
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, cfg)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
@@ -63,6 +64,9 @@ var ProviderSet = wire.NewSet(
|
||||
NewOpenAIGatewayService,
|
||||
NewOAuthService,
|
||||
NewOpenAIOAuthService,
|
||||
NewGeminiOAuthService,
|
||||
NewGeminiTokenProvider,
|
||||
NewGeminiMessagesCompatService,
|
||||
NewRateLimitService,
|
||||
NewAccountUsageService,
|
||||
NewAccountTestService,
|
||||
|
||||
@@ -27,6 +27,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/v1/") ||
|
||||
strings.HasPrefix(path, "/v1beta/") ||
|
||||
strings.HasPrefix(path, "/setup/") ||
|
||||
path == "/health" {
|
||||
c.Next()
|
||||
|
||||
@@ -53,3 +53,32 @@ ADMIN_PASSWORD=
|
||||
# Leave empty to auto-generate (recommended)
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRE_HOUR=24
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gemini OAuth (OPTIONAL, required only for Gemini OAuth accounts)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sub2API supports TWO Gemini OAuth modes:
|
||||
#
|
||||
# 1. Code Assist OAuth (需要 GCP project_id)
|
||||
# - Uses: cloudcode-pa.googleapis.com (Code Assist API)
|
||||
# - Auto scopes: cloud-platform + userinfo.email + userinfo.profile
|
||||
# - OAuth Client: Can use built-in Gemini CLI client (留空即可)
|
||||
# - Requires: Google Cloud Platform project with Code Assist enabled
|
||||
#
|
||||
# 2. AI Studio OAuth (不需要 project_id)
|
||||
# - Uses: generativelanguage.googleapis.com (AI Studio API)
|
||||
# - Default scopes: generative-language
|
||||
# - OAuth Client: Requires your own OAuth 2.0 Client (内置 Gemini CLI client 不能申请 generative-language scope)
|
||||
# - Requires: Create OAuth 2.0 Client in GCP Console + OAuth consent screen
|
||||
# - Setup Guide: https://ai.google.dev/gemini-api/docs/oauth
|
||||
# - ⚠️ IMPORTANT: OAuth Client 必须发布为正式版本 (Production)
|
||||
# Testing 模式限制: 只能添加 100 个测试用户, refresh token 7 天后过期
|
||||
# 发布步骤: GCP Console → OAuth consent screen → PUBLISH APP
|
||||
#
|
||||
# Configuration:
|
||||
# Leave empty to use the built-in Gemini CLI OAuth client (Code Assist OAuth only).
|
||||
# To enable AI Studio OAuth, set your own OAuth client ID/secret here.
|
||||
GEMINI_OAUTH_CLIENT_ID=
|
||||
GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# Optional; leave empty to auto-select scopes based on oauth_type
|
||||
GEMINI_OAUTH_SCOPES=
|
||||
|
||||
@@ -96,11 +96,107 @@ docker-compose down -v
|
||||
| `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password |
|
||||
| `JWT_SECRET` | No | *(auto-generated)* | JWT secret |
|
||||
| `TZ` | No | `Asia/Shanghai` | Timezone |
|
||||
| `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. |
|
||||
| `GEMINI_OAUTH_CLIENT_SECRET` | No | *(builtin)* | Google OAuth client secret (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. |
|
||||
| `GEMINI_OAUTH_SCOPES` | No | *(default)* | OAuth scopes (Gemini OAuth) |
|
||||
|
||||
See `.env.example` for all available options.
|
||||
|
||||
---
|
||||
|
||||
## Gemini OAuth Configuration
|
||||
|
||||
Sub2API supports three methods to connect to Gemini:
|
||||
|
||||
### Method 1: Code Assist OAuth (Recommended for GCP Users)
|
||||
|
||||
**No configuration needed** - always uses the built-in Gemini CLI OAuth client (public).
|
||||
|
||||
1. Leave `GEMINI_OAUTH_CLIENT_ID` and `GEMINI_OAUTH_CLIENT_SECRET` empty
|
||||
2. In the Admin UI, create a Gemini OAuth account and select **"Code Assist"** type
|
||||
3. Complete the OAuth flow in your browser
|
||||
|
||||
> Note: Even if you configure `GEMINI_OAUTH_CLIENT_ID` / `GEMINI_OAUTH_CLIENT_SECRET` for AI Studio OAuth,
|
||||
> Code Assist OAuth will still use the built-in Gemini CLI client.
|
||||
|
||||
**Requirements:**
|
||||
- Google account with access to Google Cloud Platform
|
||||
- A GCP project (auto-detected or manually specified)
|
||||
|
||||
**How to get Project ID (if auto-detection fails):**
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Click the project dropdown at the top of the page
|
||||
3. Copy the Project ID (not the project name) from the list
|
||||
4. Common formats: `my-project-123456` or `cloud-ai-companion-xxxxx`
|
||||
|
||||
### Method 2: AI Studio OAuth (For Regular Google Accounts)
|
||||
|
||||
Requires your own OAuth client credentials.
|
||||
|
||||
**Step 1: Create OAuth Client in Google Cloud Console**
|
||||
|
||||
1. Go to [Google Cloud Console - Credentials](https://console.cloud.google.com/apis/credentials)
|
||||
2. Create a new project or select an existing one
|
||||
3. **Enable the Generative Language API:**
|
||||
- Go to "APIs & Services" → "Library"
|
||||
- Search for "Generative Language API"
|
||||
- Click "Enable"
|
||||
4. **Configure OAuth Consent Screen** (if not done):
|
||||
- Go to "APIs & Services" → "OAuth consent screen"
|
||||
- Choose "External" user type
|
||||
- Fill in app name, user support email, developer contact
|
||||
- Add scopes: `https://www.googleapis.com/auth/generative-language.retriever` (and optionally `https://www.googleapis.com/auth/cloud-platform`)
|
||||
- Add test users (your Google account email)
|
||||
5. **Create OAuth 2.0 credentials:**
|
||||
- Go to "APIs & Services" → "Credentials"
|
||||
- Click "Create Credentials" → "OAuth client ID"
|
||||
- Application type: **Web application** (or **Desktop app**)
|
||||
- Name: e.g., "Sub2API Gemini"
|
||||
- Authorized redirect URIs: Add `http://localhost:1455/auth/callback`
|
||||
6. Copy the **Client ID** and **Client Secret**
|
||||
7. **⚠️ Publish to Production (IMPORTANT):**
|
||||
- Go to "APIs & Services" → "OAuth consent screen"
|
||||
- Click "PUBLISH APP" to move from Testing to Production
|
||||
- **Testing mode limitations:**
|
||||
- Only manually added test users can authenticate (max 100 users)
|
||||
- Refresh tokens expire after 7 days
|
||||
- Users must be re-added periodically
|
||||
- **Production mode:** Any Google user can authenticate, tokens don't expire
|
||||
- Note: For sensitive scopes, Google may require verification (demo video, privacy policy)
|
||||
|
||||
**Step 2: Configure Environment Variables**
|
||||
|
||||
```bash
|
||||
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||
```
|
||||
|
||||
**Step 3: Create Account in Admin UI**
|
||||
|
||||
1. Create a Gemini OAuth account and select **"AI Studio"** type
|
||||
2. Complete the OAuth flow
|
||||
- After consent, your browser will be redirected to `http://localhost:1455/auth/callback?code=...&state=...`
|
||||
- Copy the full callback URL (recommended) or just the `code` and paste it back into the Admin UI
|
||||
|
||||
### Method 3: API Key (Simplest)
|
||||
|
||||
1. Go to [Google AI Studio](https://aistudio.google.com/app/apikey)
|
||||
2. Click "Create API key"
|
||||
3. In Admin UI, create a Gemini **API Key** account
|
||||
4. Paste your API key (starts with `AIza...`)
|
||||
|
||||
### Comparison Table
|
||||
|
||||
| Feature | Code Assist OAuth | AI Studio OAuth | API Key |
|
||||
|---------|-------------------|-----------------|---------|
|
||||
| Setup Complexity | Easy (no config) | Medium (OAuth client) | Easy |
|
||||
| GCP Project Required | Yes | No | No |
|
||||
| Custom OAuth Client | No (built-in) | Yes (required) | N/A |
|
||||
| Rate Limits | GCP quota | Standard | Standard |
|
||||
| Best For | GCP developers | Regular users needing OAuth | Quick testing |
|
||||
|
||||
---
|
||||
|
||||
## Binary Installation
|
||||
|
||||
For production servers using systemd.
|
||||
|
||||
@@ -87,3 +87,23 @@ pricing:
|
||||
update_interval_hours: 24
|
||||
# Hash check interval in minutes
|
||||
hash_check_interval_minutes: 10
|
||||
|
||||
# =============================================================================
|
||||
# Gemini OAuth (Required for Gemini accounts)
|
||||
# =============================================================================
|
||||
# Sub2API supports TWO Gemini OAuth modes:
|
||||
#
|
||||
# 1. Code Assist OAuth (需要 GCP project_id)
|
||||
# - Uses: cloudcode-pa.googleapis.com (Code Assist API)
|
||||
#
|
||||
# 2. AI Studio OAuth (不需要 project_id)
|
||||
# - Uses: generativelanguage.googleapis.com (AI Studio API)
|
||||
#
|
||||
# Default: Uses Gemini CLI's public OAuth credentials (same as Google's official CLI tool)
|
||||
gemini:
|
||||
oauth:
|
||||
# Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio)
|
||||
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
client_secret: "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"
|
||||
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
|
||||
scopes: ""
|
||||
|
||||
@@ -78,6 +78,13 @@ services:
|
||||
# Common values: Asia/Shanghai, America/New_York, Europe/London, UTC
|
||||
# =======================================================================
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
|
||||
# =======================================================================
|
||||
# Gemini OAuth Configuration (for Gemini accounts)
|
||||
# =======================================================================
|
||||
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
|
||||
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
|
||||
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -122,6 +122,10 @@ declare -A MSG_ZH=(
|
||||
["removing_user"]="正在移除用户..."
|
||||
["config_not_removed"]="配置目录未被移除"
|
||||
["remove_manually"]="如不再需要,请手动删除"
|
||||
["removing_install_lock"]="正在移除安装锁文件..."
|
||||
["install_lock_removed"]="安装锁文件已移除,重新安装时将进入设置向导"
|
||||
["purge_prompt"]="是否同时删除配置目录?这将清除所有配置和数据 [y/N]: "
|
||||
["removing_config_dir"]="正在移除配置目录..."
|
||||
["uninstall_complete"]="Sub2API 已卸载"
|
||||
|
||||
# Help
|
||||
@@ -243,6 +247,10 @@ declare -A MSG_EN=(
|
||||
["removing_user"]="Removing user..."
|
||||
["config_not_removed"]="Config directory was NOT removed."
|
||||
["remove_manually"]="Remove it manually if you no longer need it."
|
||||
["removing_install_lock"]="Removing install lock file..."
|
||||
["install_lock_removed"]="Install lock removed. Setup wizard will appear on next install."
|
||||
["purge_prompt"]="Also remove config directory? This will delete all config and data [y/N]: "
|
||||
["removing_config_dir"]="Removing config directory..."
|
||||
["uninstall_complete"]="Sub2API has been uninstalled"
|
||||
|
||||
# Help
|
||||
@@ -926,8 +934,31 @@ uninstall() {
|
||||
print_info "$(msg 'removing_user')"
|
||||
userdel "$SERVICE_USER" 2>/dev/null || true
|
||||
|
||||
print_warning "$(msg 'config_not_removed'): $CONFIG_DIR"
|
||||
print_warning "$(msg 'remove_manually')"
|
||||
# Remove install lock file (.installed) to allow fresh setup on reinstall
|
||||
print_info "$(msg 'removing_install_lock')"
|
||||
rm -f "$CONFIG_DIR/.installed" 2>/dev/null || true
|
||||
rm -f "$INSTALL_DIR/.installed" 2>/dev/null || true
|
||||
print_success "$(msg 'install_lock_removed')"
|
||||
|
||||
# Ask about config directory removal (interactive mode only)
|
||||
local remove_config=false
|
||||
if [ "${PURGE:-}" = "true" ]; then
|
||||
remove_config=true
|
||||
elif is_interactive; then
|
||||
read -p "$(msg 'purge_prompt')" -n 1 -r < /dev/tty
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
remove_config=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$remove_config" = true ]; then
|
||||
print_info "$(msg 'removing_config_dir')"
|
||||
rm -rf "$CONFIG_DIR"
|
||||
else
|
||||
print_warning "$(msg 'config_not_removed'): $CONFIG_DIR"
|
||||
print_warning "$(msg 'remove_manually')"
|
||||
fi
|
||||
|
||||
print_success "$(msg 'uninstall_complete')"
|
||||
}
|
||||
@@ -944,6 +975,10 @@ main() {
|
||||
FORCE_YES="true"
|
||||
shift
|
||||
;;
|
||||
--purge)
|
||||
PURGE="true"
|
||||
shift
|
||||
;;
|
||||
-v|--version)
|
||||
if [ -n "${2:-}" ] && [[ ! "$2" =~ ^- ]]; then
|
||||
target_version="$2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
1147
frontend/package-lock.json
generated
1147
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,17 +26,25 @@ function updateFavicon(logoUrl: string) {
|
||||
}
|
||||
|
||||
// Watch for site settings changes and update favicon/title
|
||||
watch(() => appStore.siteLogo, (newLogo) => {
|
||||
if (newLogo) {
|
||||
updateFavicon(newLogo)
|
||||
}
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
() => appStore.siteLogo,
|
||||
(newLogo) => {
|
||||
if (newLogo) {
|
||||
updateFavicon(newLogo)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(() => appStore.siteName, (newName) => {
|
||||
if (newName) {
|
||||
document.title = `${newName} - AI API Gateway`
|
||||
}
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
() => appStore.siteName,
|
||||
(newName) => {
|
||||
if (newName) {
|
||||
document.title = `${newName} - AI API Gateway`
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Check if setup is needed
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
AccountUsageInfo,
|
||||
WindowStats,
|
||||
ClaudeModel,
|
||||
AccountUsageStatsResponse,
|
||||
AccountUsageStatsResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -36,8 +36,8 @@ export async function list(
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
...filters
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export async function refreshCredentials(id: number): Promise<Account> {
|
||||
*/
|
||||
export async function getStats(id: number, days: number = 30): Promise<AccountUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AccountUsageStatsResponse>(`/admin/accounts/${id}/stats`, {
|
||||
params: { days },
|
||||
params: { days }
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export async function bulkUpdate(
|
||||
results: Array<{ account_id: number; success: boolean; error?: string }>
|
||||
}>('/admin/accounts/bulk-update', {
|
||||
account_ids: accountIds,
|
||||
...updates,
|
||||
...updates
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export async function getTodayStats(id: number): Promise<WindowStats> {
|
||||
*/
|
||||
export async function setSchedulable(id: number, schedulable: boolean): Promise<Account> {
|
||||
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/schedulable`, {
|
||||
schedulable,
|
||||
schedulable
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -335,7 +335,7 @@ export const accountsAPI = {
|
||||
batchCreate,
|
||||
batchUpdateCredentials,
|
||||
bulkUpdate,
|
||||
syncFromCrs,
|
||||
syncFromCrs
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
* Provides system-wide statistics and metrics
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { DashboardStats, TrendDataPoint, ModelStat, ApiKeyUsageTrendPoint, UserUsageTrendPoint } from '@/types';
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
DashboardStats,
|
||||
TrendDataPoint,
|
||||
ModelStat,
|
||||
ApiKeyUsageTrendPoint,
|
||||
UserUsageTrendPoint
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get dashboard statistics
|
||||
* @returns Dashboard statistics including users, keys, accounts, and token usage
|
||||
*/
|
||||
export async function getStats(): Promise<DashboardStats> {
|
||||
const { data } = await apiClient.get<DashboardStats>('/admin/dashboard/stats');
|
||||
return data;
|
||||
const { data } = await apiClient.get<DashboardStats>('/admin/dashboard/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,33 +26,33 @@ export async function getStats(): Promise<DashboardStats> {
|
||||
* @returns Real-time system metrics
|
||||
*/
|
||||
export async function getRealtimeMetrics(): Promise<{
|
||||
active_requests: number;
|
||||
requests_per_minute: number;
|
||||
average_response_time: number;
|
||||
error_rate: number;
|
||||
active_requests: number
|
||||
requests_per_minute: number
|
||||
average_response_time: number
|
||||
error_rate: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
active_requests: number;
|
||||
requests_per_minute: number;
|
||||
average_response_time: number;
|
||||
error_rate: number;
|
||||
}>('/admin/dashboard/realtime');
|
||||
return data;
|
||||
active_requests: number
|
||||
requests_per_minute: number
|
||||
average_response_time: number
|
||||
error_rate: number
|
||||
}>('/admin/dashboard/realtime')
|
||||
return data
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
granularity?: 'day' | 'hour';
|
||||
user_id?: number;
|
||||
api_key_id?: number;
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
granularity?: 'day' | 'hour'
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
trend: TrendDataPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
trend: TrendDataPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,21 +61,21 @@ export interface TrendResponse {
|
||||
* @returns Usage trend data
|
||||
*/
|
||||
export async function getUsageTrend(params?: TrendParams): Promise<TrendResponse> {
|
||||
const { data } = await apiClient.get<TrendResponse>('/admin/dashboard/trend', { params });
|
||||
return data;
|
||||
const { data } = await apiClient.get<TrendResponse>('/admin/dashboard/trend', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ModelStatsParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
user_id?: number;
|
||||
api_key_id?: number;
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
models: ModelStat[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
models: ModelStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,19 +84,19 @@ export interface ModelStatsResponse {
|
||||
* @returns Model usage statistics
|
||||
*/
|
||||
export async function getModelStats(params?: ModelStatsParams): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params });
|
||||
return data;
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/admin/dashboard/models', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendParams extends TrendParams {
|
||||
limit?: number;
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ApiKeyTrendResponse {
|
||||
trend: ApiKeyUsageTrendPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
trend: ApiKeyUsageTrendPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,20 +104,24 @@ export interface ApiKeyTrendResponse {
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns API key usage trend data
|
||||
*/
|
||||
export async function getApiKeyUsageTrend(params?: ApiKeyTrendParams): Promise<ApiKeyTrendResponse> {
|
||||
const { data } = await apiClient.get<ApiKeyTrendResponse>('/admin/dashboard/api-keys-trend', { params });
|
||||
return data;
|
||||
export async function getApiKeyUsageTrend(
|
||||
params?: ApiKeyTrendParams
|
||||
): Promise<ApiKeyTrendResponse> {
|
||||
const { data } = await apiClient.get<ApiKeyTrendResponse>('/admin/dashboard/api-keys-trend', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UserTrendParams extends TrendParams {
|
||||
limit?: number;
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface UserTrendResponse {
|
||||
trend: UserUsageTrendPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
trend: UserUsageTrendPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,18 +130,20 @@ export interface UserTrendResponse {
|
||||
* @returns User usage trend data
|
||||
*/
|
||||
export async function getUserUsageTrend(params?: UserTrendParams): Promise<UserTrendResponse> {
|
||||
const { data } = await apiClient.get<UserTrendResponse>('/admin/dashboard/users-trend', { params });
|
||||
return data;
|
||||
const { data } = await apiClient.get<UserTrendResponse>('/admin/dashboard/users-trend', {
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchUserUsageStats {
|
||||
user_id: number;
|
||||
today_actual_cost: number;
|
||||
total_actual_cost: number;
|
||||
user_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchUsersUsageResponse {
|
||||
stats: Record<string, BatchUserUsageStats>;
|
||||
stats: Record<string, BatchUserUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,19 +153,19 @@ export interface BatchUsersUsageResponse {
|
||||
*/
|
||||
export async function getBatchUsersUsage(userIds: number[]): Promise<BatchUsersUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchUsersUsageResponse>('/admin/dashboard/users-usage', {
|
||||
user_ids: userIds,
|
||||
});
|
||||
return data;
|
||||
user_ids: userIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchApiKeyUsageStats {
|
||||
api_key_id: number;
|
||||
today_actual_cost: number;
|
||||
total_actual_cost: number;
|
||||
api_key_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchApiKeysUsageResponse {
|
||||
stats: Record<string, BatchApiKeyUsageStats>;
|
||||
stats: Record<string, BatchApiKeyUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,11 +173,16 @@ export interface BatchApiKeysUsageResponse {
|
||||
* @param apiKeyIds - Array of API key IDs
|
||||
* @returns Usage stats map keyed by API key ID
|
||||
*/
|
||||
export async function getBatchApiKeysUsage(apiKeyIds: number[]): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>('/admin/dashboard/api-keys-usage', {
|
||||
api_key_ids: apiKeyIds,
|
||||
});
|
||||
return data;
|
||||
export async function getBatchApiKeysUsage(
|
||||
apiKeyIds: number[]
|
||||
): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
|
||||
'/admin/dashboard/api-keys-usage',
|
||||
{
|
||||
api_key_ids: apiKeyIds
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const dashboardAPI = {
|
||||
@@ -176,7 +193,7 @@ export const dashboardAPI = {
|
||||
getApiKeyUsageTrend,
|
||||
getUserUsageTrend,
|
||||
getBatchUsersUsage,
|
||||
getBatchApiKeysUsage,
|
||||
};
|
||||
getBatchApiKeysUsage
|
||||
}
|
||||
|
||||
export default dashboardAPI;
|
||||
export default dashboardAPI
|
||||
|
||||
58
frontend/src/api/admin/gemini.ts
Normal file
58
frontend/src/api/admin/gemini.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Admin Gemini API endpoints
|
||||
* Handles Gemini OAuth flows for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface GeminiAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface GeminiOAuthCapabilities {
|
||||
ai_studio_oauth_enabled: boolean
|
||||
required_redirect_uris: string[]
|
||||
}
|
||||
|
||||
export interface GeminiAuthUrlRequest {
|
||||
proxy_id?: number
|
||||
project_id?: string
|
||||
oauth_type?: 'code_assist' | 'ai_studio'
|
||||
}
|
||||
|
||||
export interface GeminiExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
proxy_id?: number
|
||||
oauth_type?: 'code_assist' | 'ai_studio'
|
||||
}
|
||||
|
||||
export type GeminiTokenInfo = Record<string, unknown>
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GeminiAuthUrlRequest
|
||||
): Promise<GeminiAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<GeminiAuthUrlResponse>(
|
||||
'/admin/gemini/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise<GeminiTokenInfo> {
|
||||
const { data } = await apiClient.post<GeminiTokenInfo>(
|
||||
'/admin/gemini/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<GeminiOAuthCapabilities> {
|
||||
const { data } = await apiClient.get<GeminiOAuthCapabilities>('/admin/gemini/oauth/capabilities')
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, getCapabilities }
|
||||
@@ -3,14 +3,14 @@
|
||||
* Handles API key group management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Group,
|
||||
GroupPlatform,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all groups with pagination
|
||||
@@ -23,19 +23,19 @@ export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
platform?: GroupPlatform;
|
||||
status?: 'active' | 'inactive';
|
||||
is_exclusive?: boolean;
|
||||
platform?: GroupPlatform
|
||||
status?: 'active' | 'inactive'
|
||||
is_exclusive?: boolean
|
||||
}
|
||||
): Promise<PaginatedResponse<Group>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
...filters
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,8 +46,8 @@ export async function list(
|
||||
export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/admin/groups/all', {
|
||||
params: platform ? { platform } : undefined
|
||||
});
|
||||
return data;
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
* @returns List of groups for the specified platform
|
||||
*/
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
return getAll(platform);
|
||||
return getAll(platform)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +65,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
* @returns Group details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Group> {
|
||||
const { data } = await apiClient.get<Group>(`/admin/groups/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<Group>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +75,8 @@ export async function getById(id: number): Promise<Group> {
|
||||
* @returns Created group
|
||||
*/
|
||||
export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.post<Group>('/admin/groups', groupData);
|
||||
return data;
|
||||
const { data } = await apiClient.post<Group>('/admin/groups', groupData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,8 +86,8 @@ export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.put<Group>(`/admin/groups/${id}`, updates);
|
||||
return data;
|
||||
const { data } = await apiClient.put<Group>(`/admin/groups/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +96,8 @@ export async function update(id: number, updates: UpdateGroupRequest): Promise<G
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,11 +106,8 @@ export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
* @param status - New status
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<Group> {
|
||||
return update(id, { status });
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Group> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,18 +116,18 @@ export async function toggleStatus(
|
||||
* @returns Group usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_api_keys: number;
|
||||
active_api_keys: number;
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_api_keys: number;
|
||||
active_api_keys: number;
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
}>(`/admin/groups/${id}/stats`);
|
||||
return data;
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
}>(`/admin/groups/${id}/stats`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,13 +142,10 @@ export async function getGroupApiKeys(
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(
|
||||
`/admin/groups/${id}/api-keys`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
}
|
||||
);
|
||||
return data;
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/groups/${id}/api-keys`, {
|
||||
params: { page, page_size: pageSize }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
@@ -164,7 +158,7 @@ export const groupsAPI = {
|
||||
delete: deleteGroup,
|
||||
toggleStatus,
|
||||
getStats,
|
||||
getGroupApiKeys,
|
||||
};
|
||||
getGroupApiKeys
|
||||
}
|
||||
|
||||
export default groupsAPI;
|
||||
export default groupsAPI
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
* Centralized exports for all admin API modules
|
||||
*/
|
||||
|
||||
import dashboardAPI from './dashboard';
|
||||
import usersAPI from './users';
|
||||
import groupsAPI from './groups';
|
||||
import accountsAPI from './accounts';
|
||||
import proxiesAPI from './proxies';
|
||||
import redeemAPI from './redeem';
|
||||
import settingsAPI from './settings';
|
||||
import systemAPI from './system';
|
||||
import subscriptionsAPI from './subscriptions';
|
||||
import usageAPI from './usage';
|
||||
import dashboardAPI from './dashboard'
|
||||
import usersAPI from './users'
|
||||
import groupsAPI from './groups'
|
||||
import accountsAPI from './accounts'
|
||||
import proxiesAPI from './proxies'
|
||||
import redeemAPI from './redeem'
|
||||
import settingsAPI from './settings'
|
||||
import systemAPI from './system'
|
||||
import subscriptionsAPI from './subscriptions'
|
||||
import usageAPI from './usage'
|
||||
import geminiAPI from './gemini'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@@ -28,8 +29,21 @@ export const adminAPI = {
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
usage: usageAPI,
|
||||
};
|
||||
gemini: geminiAPI
|
||||
}
|
||||
|
||||
export { dashboardAPI, usersAPI, groupsAPI, accountsAPI, proxiesAPI, redeemAPI, settingsAPI, systemAPI, subscriptionsAPI, usageAPI };
|
||||
export {
|
||||
dashboardAPI,
|
||||
usersAPI,
|
||||
groupsAPI,
|
||||
accountsAPI,
|
||||
proxiesAPI,
|
||||
redeemAPI,
|
||||
settingsAPI,
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
usageAPI,
|
||||
geminiAPI
|
||||
}
|
||||
|
||||
export default adminAPI;
|
||||
export default adminAPI
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
* Handles proxy server management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
Proxy,
|
||||
CreateProxyRequest,
|
||||
UpdateProxyRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
import { apiClient } from '../client'
|
||||
import type { Proxy, CreateProxyRequest, UpdateProxyRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all proxies with pagination
|
||||
@@ -22,19 +17,19 @@ export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
protocol?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
search?: string;
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
search?: string
|
||||
}
|
||||
): Promise<PaginatedResponse<Proxy>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Proxy>>('/admin/proxies', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
...filters
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,8 +37,8 @@ export async function list(
|
||||
* @returns List of all active proxies
|
||||
*/
|
||||
export async function getAll(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all');
|
||||
return data;
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,9 +47,9 @@ export async function getAll(): Promise<Proxy[]> {
|
||||
*/
|
||||
export async function getAllWithCount(): Promise<Proxy[]> {
|
||||
const { data } = await apiClient.get<Proxy[]>('/admin/proxies/all', {
|
||||
params: { with_count: 'true' },
|
||||
});
|
||||
return data;
|
||||
params: { with_count: 'true' }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +58,8 @@ export async function getAllWithCount(): Promise<Proxy[]> {
|
||||
* @returns Proxy details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Proxy> {
|
||||
const { data } = await apiClient.get<Proxy>(`/admin/proxies/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<Proxy>(`/admin/proxies/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,8 +68,8 @@ export async function getById(id: number): Promise<Proxy> {
|
||||
* @returns Created proxy
|
||||
*/
|
||||
export async function create(proxyData: CreateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.post<Proxy>('/admin/proxies', proxyData);
|
||||
return data;
|
||||
const { data } = await apiClient.post<Proxy>('/admin/proxies', proxyData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,8 +79,8 @@ export async function create(proxyData: CreateProxyRequest): Promise<Proxy> {
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateProxyRequest): Promise<Proxy> {
|
||||
const { data } = await apiClient.put<Proxy>(`/admin/proxies/${id}`, updates);
|
||||
return data;
|
||||
const { data } = await apiClient.put<Proxy>(`/admin/proxies/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,8 +89,8 @@ export async function update(id: number, updates: UpdateProxyRequest): Promise<P
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteProxy(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/proxies/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,11 +99,8 @@ export async function deleteProxy(id: number): Promise<{ message: string }> {
|
||||
* @param status - New status
|
||||
* @returns Updated proxy
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<Proxy> {
|
||||
return update(id, { status });
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Proxy> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,24 +109,24 @@ export async function toggleStatus(
|
||||
* @returns Test result with IP info
|
||||
*/
|
||||
export async function testProxy(id: number): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
ip_address?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms?: number
|
||||
ip_address?: string
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
ip_address?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
}>(`/admin/proxies/${id}/test`);
|
||||
return data;
|
||||
success: boolean
|
||||
message: string
|
||||
latency_ms?: number
|
||||
ip_address?: string
|
||||
city?: string
|
||||
region?: string
|
||||
country?: string
|
||||
}>(`/admin/proxies/${id}/test`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,20 +135,20 @@ export async function testProxy(id: number): Promise<{
|
||||
* @returns Proxy usage statistics
|
||||
*/
|
||||
export async function getStats(id: number): Promise<{
|
||||
total_accounts: number;
|
||||
active_accounts: number;
|
||||
total_requests: number;
|
||||
success_rate: number;
|
||||
average_latency: number;
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
total_requests: number
|
||||
success_rate: number
|
||||
average_latency: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_accounts: number;
|
||||
active_accounts: number;
|
||||
total_requests: number;
|
||||
success_rate: number;
|
||||
average_latency: number;
|
||||
}>(`/admin/proxies/${id}/stats`);
|
||||
return data;
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
total_requests: number
|
||||
success_rate: number
|
||||
average_latency: number
|
||||
}>(`/admin/proxies/${id}/stats`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +157,8 @@ export async function getStats(id: number): Promise<{
|
||||
* @returns List of accounts using the proxy
|
||||
*/
|
||||
export async function getProxyAccounts(id: number): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(
|
||||
`/admin/proxies/${id}/accounts`
|
||||
);
|
||||
return data;
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/proxies/${id}/accounts`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,21 +166,23 @@ export async function getProxyAccounts(id: number): Promise<PaginatedResponse<an
|
||||
* @param proxies - Array of proxy data to create
|
||||
* @returns Creation result with count of created and skipped
|
||||
*/
|
||||
export async function batchCreate(proxies: Array<{
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>): Promise<{
|
||||
created: number;
|
||||
skipped: number;
|
||||
export async function batchCreate(
|
||||
proxies: Array<{
|
||||
protocol: string
|
||||
host: string
|
||||
port: number
|
||||
username?: string
|
||||
password?: string
|
||||
}>
|
||||
): Promise<{
|
||||
created: number
|
||||
skipped: number
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
created: number;
|
||||
skipped: number;
|
||||
}>('/admin/proxies/batch', { proxies });
|
||||
return data;
|
||||
created: number
|
||||
skipped: number
|
||||
}>('/admin/proxies/batch', { proxies })
|
||||
return data
|
||||
}
|
||||
|
||||
export const proxiesAPI = {
|
||||
@@ -205,7 +197,7 @@ export const proxiesAPI = {
|
||||
testProxy,
|
||||
getStats,
|
||||
getProxyAccounts,
|
||||
batchCreate,
|
||||
};
|
||||
batchCreate
|
||||
}
|
||||
|
||||
export default proxiesAPI;
|
||||
export default proxiesAPI
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
* Handles redeem code generation and management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
RedeemCode,
|
||||
GenerateRedeemCodesRequest,
|
||||
RedeemCodeType,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all redeem codes with pagination
|
||||
@@ -22,19 +22,19 @@ export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
type?: RedeemCodeType;
|
||||
status?: 'active' | 'used' | 'expired' | 'unused';
|
||||
search?: string;
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
||||
search?: string
|
||||
}
|
||||
): Promise<PaginatedResponse<RedeemCode>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<RedeemCode>>('/admin/redeem-codes', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
...filters
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +43,8 @@ export async function list(
|
||||
* @returns Redeem code details
|
||||
*/
|
||||
export async function getById(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.get<RedeemCode>(`/admin/redeem-codes/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<RedeemCode>(`/admin/redeem-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,19 +66,19 @@ export async function generate(
|
||||
const payload: GenerateRedeemCodesRequest = {
|
||||
count,
|
||||
type,
|
||||
value,
|
||||
};
|
||||
value
|
||||
}
|
||||
|
||||
// 订阅类型专用字段
|
||||
if (type === 'subscription') {
|
||||
payload.group_id = groupId;
|
||||
payload.group_id = groupId
|
||||
if (validityDays && validityDays > 0) {
|
||||
payload.validity_days = validityDays;
|
||||
payload.validity_days = validityDays
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload);
|
||||
return data;
|
||||
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,8 +87,8 @@ export async function generate(
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/redeem-codes/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,14 +97,14 @@ export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted: number;
|
||||
message: string;
|
||||
deleted: number
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted: number;
|
||||
message: string;
|
||||
}>('/admin/redeem-codes/batch-delete', { ids });
|
||||
return data;
|
||||
deleted: number
|
||||
message: string
|
||||
}>('/admin/redeem-codes/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +113,8 @@ export async function batchDelete(ids: number[]): Promise<{
|
||||
* @returns Updated redeem code
|
||||
*/
|
||||
export async function expire(id: number): Promise<RedeemCode> {
|
||||
const { data } = await apiClient.post<RedeemCode>(`/admin/redeem-codes/${id}/expire`);
|
||||
return data;
|
||||
const { data } = await apiClient.post<RedeemCode>(`/admin/redeem-codes/${id}/expire`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,22 +122,22 @@ export async function expire(id: number): Promise<RedeemCode> {
|
||||
* @returns Statistics about redeem codes
|
||||
*/
|
||||
export async function getStats(): Promise<{
|
||||
total_codes: number;
|
||||
active_codes: number;
|
||||
used_codes: number;
|
||||
expired_codes: number;
|
||||
total_value_distributed: number;
|
||||
by_type: Record<RedeemCodeType, number>;
|
||||
total_codes: number
|
||||
active_codes: number
|
||||
used_codes: number
|
||||
expired_codes: number
|
||||
total_value_distributed: number
|
||||
by_type: Record<RedeemCodeType, number>
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_codes: number;
|
||||
active_codes: number;
|
||||
used_codes: number;
|
||||
expired_codes: number;
|
||||
total_value_distributed: number;
|
||||
by_type: Record<RedeemCodeType, number>;
|
||||
}>('/admin/redeem-codes/stats');
|
||||
return data;
|
||||
total_codes: number
|
||||
active_codes: number
|
||||
used_codes: number
|
||||
expired_codes: number
|
||||
total_value_distributed: number
|
||||
by_type: Record<RedeemCodeType, number>
|
||||
}>('/admin/redeem-codes/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,14 +146,14 @@ export async function getStats(): Promise<{
|
||||
* @returns CSV data as blob
|
||||
*/
|
||||
export async function exportCodes(filters?: {
|
||||
type?: RedeemCodeType;
|
||||
status?: 'active' | 'used' | 'expired';
|
||||
type?: RedeemCodeType
|
||||
status?: 'active' | 'used' | 'expired'
|
||||
}): Promise<Blob> {
|
||||
const response = await apiClient.get('/admin/redeem-codes/export', {
|
||||
params: filters,
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
responseType: 'blob'
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const redeemAPI = {
|
||||
@@ -164,7 +164,7 @@ export const redeemAPI = {
|
||||
batchDelete,
|
||||
expire,
|
||||
getStats,
|
||||
exportCodes,
|
||||
};
|
||||
exportCodes
|
||||
}
|
||||
|
||||
export default redeemAPI;
|
||||
export default redeemAPI
|
||||
|
||||
@@ -3,37 +3,37 @@
|
||||
* Handles system settings management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* System settings interface
|
||||
*/
|
||||
export interface SystemSettings {
|
||||
// Registration settings
|
||||
registration_enabled: boolean;
|
||||
email_verify_enabled: boolean;
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
// Default settings
|
||||
default_balance: number;
|
||||
default_concurrency: number;
|
||||
default_balance: number
|
||||
default_concurrency: number
|
||||
// OEM settings
|
||||
site_name: string;
|
||||
site_logo: string;
|
||||
site_subtitle: string;
|
||||
api_base_url: string;
|
||||
contact_info: string;
|
||||
doc_url: string;
|
||||
site_name: string
|
||||
site_logo: string
|
||||
site_subtitle: string
|
||||
api_base_url: string
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
// SMTP settings
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_email: string;
|
||||
smtp_from_name: string;
|
||||
smtp_use_tls: boolean;
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_username: string
|
||||
smtp_password: string
|
||||
smtp_from_email: string
|
||||
smtp_from_name: string
|
||||
smtp_use_tls: boolean
|
||||
// Cloudflare Turnstile settings
|
||||
turnstile_enabled: boolean;
|
||||
turnstile_site_key: string;
|
||||
turnstile_secret_key: string;
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
turnstile_secret_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,8 @@ export interface SystemSettings {
|
||||
* @returns System settings
|
||||
*/
|
||||
export async function getSettings(): Promise<SystemSettings> {
|
||||
const { data } = await apiClient.get<SystemSettings>('/admin/settings');
|
||||
return data;
|
||||
const { data } = await apiClient.get<SystemSettings>('/admin/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,19 +51,19 @@ export async function getSettings(): Promise<SystemSettings> {
|
||||
* @returns Updated settings
|
||||
*/
|
||||
export async function updateSettings(settings: Partial<SystemSettings>): Promise<SystemSettings> {
|
||||
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings);
|
||||
return data;
|
||||
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SMTP connection request
|
||||
*/
|
||||
export interface TestSmtpRequest {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_use_tls: boolean;
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_username: string
|
||||
smtp_password: string
|
||||
smtp_use_tls: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,22 +72,22 @@ export interface TestSmtpRequest {
|
||||
* @returns Test result message
|
||||
*/
|
||||
export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config);
|
||||
return data;
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email request
|
||||
*/
|
||||
export interface SendTestEmailRequest {
|
||||
email: string;
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_email: string;
|
||||
smtp_from_name: string;
|
||||
smtp_use_tls: boolean;
|
||||
email: string
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_username: string
|
||||
smtp_password: string
|
||||
smtp_from_email: string
|
||||
smtp_from_name: string
|
||||
smtp_use_tls: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,16 +96,19 @@ export interface SendTestEmailRequest {
|
||||
* @returns Test result message
|
||||
*/
|
||||
export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/settings/send-test-email', request);
|
||||
return data;
|
||||
const { data } = await apiClient.post<{ message: string }>(
|
||||
'/admin/settings/send-test-email',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin API Key status response
|
||||
*/
|
||||
export interface AdminApiKeyStatus {
|
||||
exists: boolean;
|
||||
masked_key: string;
|
||||
exists: boolean
|
||||
masked_key: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +116,8 @@ export interface AdminApiKeyStatus {
|
||||
* @returns Status indicating if key exists and masked version
|
||||
*/
|
||||
export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
|
||||
const { data } = await apiClient.get<AdminApiKeyStatus>('/admin/settings/admin-api-key');
|
||||
return data;
|
||||
const { data } = await apiClient.get<AdminApiKeyStatus>('/admin/settings/admin-api-key')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,8 +125,8 @@ export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
|
||||
* @returns The new full API key (only shown once)
|
||||
*/
|
||||
export async function regenerateAdminApiKey(): Promise<{ key: string }> {
|
||||
const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate');
|
||||
return data;
|
||||
const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,8 +134,8 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> {
|
||||
* @returns Success message
|
||||
*/
|
||||
export async function deleteAdminApiKey(): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key');
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key')
|
||||
return data
|
||||
}
|
||||
|
||||
export const settingsAPI = {
|
||||
@@ -142,7 +145,7 @@ export const settingsAPI = {
|
||||
sendTestEmail,
|
||||
getAdminApiKey,
|
||||
regenerateAdminApiKey,
|
||||
deleteAdminApiKey,
|
||||
};
|
||||
deleteAdminApiKey
|
||||
}
|
||||
|
||||
export default settingsAPI;
|
||||
export default settingsAPI
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
* Handles user subscription management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
UserSubscription,
|
||||
SubscriptionProgress,
|
||||
AssignSubscriptionRequest,
|
||||
BulkAssignSubscriptionRequest,
|
||||
ExtendSubscriptionRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* List all subscriptions with pagination
|
||||
@@ -24,19 +24,22 @@ export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: 'active' | 'expired' | 'revoked';
|
||||
user_id?: number;
|
||||
group_id?: number;
|
||||
status?: 'active' | 'expired' | 'revoked'
|
||||
user_id?: number
|
||||
group_id?: number
|
||||
}
|
||||
): Promise<PaginatedResponse<UserSubscription>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>('/admin/subscriptions', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
'/admin/subscriptions',
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters
|
||||
}
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,8 +48,8 @@ export async function list(
|
||||
* @returns Subscription details
|
||||
*/
|
||||
export async function getById(id: number): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.get<UserSubscription>(`/admin/subscriptions/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<UserSubscription>(`/admin/subscriptions/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +58,8 @@ export async function getById(id: number): Promise<UserSubscription> {
|
||||
* @returns Subscription progress with usage stats
|
||||
*/
|
||||
export async function getProgress(id: number): Promise<SubscriptionProgress> {
|
||||
const { data } = await apiClient.get<SubscriptionProgress>(`/admin/subscriptions/${id}/progress`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<SubscriptionProgress>(`/admin/subscriptions/${id}/progress`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +68,8 @@ export async function getProgress(id: number): Promise<SubscriptionProgress> {
|
||||
* @returns Created subscription
|
||||
*/
|
||||
export async function assign(request: AssignSubscriptionRequest): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>('/admin/subscriptions/assign', request);
|
||||
return data;
|
||||
const { data } = await apiClient.post<UserSubscription>('/admin/subscriptions/assign', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +77,14 @@ export async function assign(request: AssignSubscriptionRequest): Promise<UserSu
|
||||
* @param request - Bulk assignment request
|
||||
* @returns Created subscriptions
|
||||
*/
|
||||
export async function bulkAssign(request: BulkAssignSubscriptionRequest): Promise<UserSubscription[]> {
|
||||
const { data } = await apiClient.post<UserSubscription[]>('/admin/subscriptions/bulk-assign', request);
|
||||
return data;
|
||||
export async function bulkAssign(
|
||||
request: BulkAssignSubscriptionRequest
|
||||
): Promise<UserSubscription[]> {
|
||||
const { data } = await apiClient.post<UserSubscription[]>(
|
||||
'/admin/subscriptions/bulk-assign',
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,9 +93,15 @@ export async function bulkAssign(request: BulkAssignSubscriptionRequest): Promis
|
||||
* @param request - Extension request with days
|
||||
* @returns Updated subscription
|
||||
*/
|
||||
export async function extend(id: number, request: ExtendSubscriptionRequest): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(`/admin/subscriptions/${id}/extend`, request);
|
||||
return data;
|
||||
export async function extend(
|
||||
id: number,
|
||||
request: ExtendSubscriptionRequest
|
||||
): Promise<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(
|
||||
`/admin/subscriptions/${id}/extend`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,8 +110,8 @@ export async function extend(id: number, request: ExtendSubscriptionRequest): Pr
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function revoke(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +129,10 @@ export async function listByGroup(
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/groups/${groupId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
params: { page, page_size: pageSize }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,10 +150,10 @@ export async function listByUser(
|
||||
const { data } = await apiClient.get<PaginatedResponse<UserSubscription>>(
|
||||
`/admin/users/${userId}/subscriptions`,
|
||||
{
|
||||
params: { page, page_size: pageSize },
|
||||
params: { page, page_size: pageSize }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const subscriptionsAPI = {
|
||||
@@ -151,7 +165,7 @@ export const subscriptionsAPI = {
|
||||
extend,
|
||||
revoke,
|
||||
listByGroup,
|
||||
listByUser,
|
||||
};
|
||||
listByUser
|
||||
}
|
||||
|
||||
export default subscriptionsAPI;
|
||||
export default subscriptionsAPI
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
* System API endpoints for admin operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface ReleaseInfo {
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
release_info?: ReleaseInfo;
|
||||
cached: boolean;
|
||||
warning?: string;
|
||||
build_type: string; // "source" for manual builds, "release" for CI builds
|
||||
current_version: string
|
||||
latest_version: string
|
||||
has_update: boolean
|
||||
release_info?: ReleaseInfo
|
||||
cached: boolean
|
||||
warning?: string
|
||||
build_type: string // "source" for manual builds, "release" for CI builds
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version
|
||||
*/
|
||||
export async function getVersion(): Promise<{ version: string }> {
|
||||
const { data } = await apiClient.get<{ version: string }>('/admin/system/version');
|
||||
return data;
|
||||
const { data } = await apiClient.get<{ version: string }>('/admin/system/version')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,14 +35,14 @@ export async function getVersion(): Promise<{ version: string }> {
|
||||
*/
|
||||
export async function checkUpdates(force = false): Promise<VersionInfo> {
|
||||
const { data } = await apiClient.get<VersionInfo>('/admin/system/check-updates', {
|
||||
params: force ? { force: 'true' } : undefined,
|
||||
});
|
||||
return data;
|
||||
params: force ? { force: 'true' } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
message: string;
|
||||
need_restart: boolean;
|
||||
message: string
|
||||
need_restart: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,24 +50,24 @@ export interface UpdateResult {
|
||||
* Downloads and applies the latest version
|
||||
*/
|
||||
export async function performUpdate(): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/update');
|
||||
return data;
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/update')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to previous version
|
||||
*/
|
||||
export async function rollback(): Promise<UpdateResult> {
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/rollback');
|
||||
return data;
|
||||
const { data } = await apiClient.post<UpdateResult>('/admin/system/rollback')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the service
|
||||
*/
|
||||
export async function restartService(): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/system/restart');
|
||||
return data;
|
||||
const { data } = await apiClient.post<{ message: string }>('/admin/system/restart')
|
||||
return data
|
||||
}
|
||||
|
||||
export const systemAPI = {
|
||||
@@ -75,7 +75,7 @@ export const systemAPI = {
|
||||
checkUpdates,
|
||||
performUpdate,
|
||||
rollback,
|
||||
restartService,
|
||||
};
|
||||
restartService
|
||||
}
|
||||
|
||||
export default systemAPI;
|
||||
export default systemAPI
|
||||
|
||||
@@ -3,39 +3,35 @@
|
||||
* Handles admin-level usage logs and statistics retrieval
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
UsageLog,
|
||||
UsageQueryParams,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
import { apiClient } from '../client'
|
||||
import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface AdminUsageStatsResponse {
|
||||
total_requests: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
total_actual_cost: number;
|
||||
average_duration_ms: number;
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_cache_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
average_duration_ms: number
|
||||
}
|
||||
|
||||
export interface SimpleUser {
|
||||
id: number;
|
||||
email: string;
|
||||
id: number
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface SimpleApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
user_id: number;
|
||||
id: number
|
||||
name: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number;
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
// ==================== API Functions ====================
|
||||
@@ -47,9 +43,9 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
*/
|
||||
export async function list(params: AdminUsageQueryParams): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,16 +54,16 @@ export async function list(params: AdminUsageQueryParams): Promise<PaginatedResp
|
||||
* @returns Usage statistics
|
||||
*/
|
||||
export async function getStats(params: {
|
||||
user_id?: number;
|
||||
api_key_id?: number;
|
||||
period?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
period?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<AdminUsageStatsResponse> {
|
||||
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,9 +73,9 @@ export async function getStats(params: {
|
||||
*/
|
||||
export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
|
||||
const { data } = await apiClient.get<SimpleUser[]>('/admin/usage/search-users', {
|
||||
params: { q: keyword },
|
||||
});
|
||||
return data;
|
||||
params: { q: keyword }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,24 +85,24 @@ export async function searchUsers(keyword: string): Promise<SimpleUser[]> {
|
||||
* @returns List of matching API keys (max 30)
|
||||
*/
|
||||
export async function searchApiKeys(userId?: number, keyword?: string): Promise<SimpleApiKey[]> {
|
||||
const params: Record<string, unknown> = {};
|
||||
const params: Record<string, unknown> = {}
|
||||
if (userId !== undefined) {
|
||||
params.user_id = userId;
|
||||
params.user_id = userId
|
||||
}
|
||||
if (keyword) {
|
||||
params.q = keyword;
|
||||
params.q = keyword
|
||||
}
|
||||
const { data } = await apiClient.get<SimpleApiKey[]>('/admin/usage/search-api-keys', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const adminUsageAPI = {
|
||||
list,
|
||||
getStats,
|
||||
searchUsers,
|
||||
searchApiKeys,
|
||||
};
|
||||
searchApiKeys
|
||||
}
|
||||
|
||||
export default adminUsageAPI;
|
||||
export default adminUsageAPI
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Handles user management for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { User, UpdateUserRequest, PaginatedResponse } from '@/types';
|
||||
import { apiClient } from '../client'
|
||||
import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
@@ -17,19 +17,19 @@ export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: 'active' | 'disabled';
|
||||
role?: 'admin' | 'user';
|
||||
search?: string;
|
||||
status?: 'active' | 'disabled'
|
||||
role?: 'admin' | 'user'
|
||||
search?: string
|
||||
}
|
||||
): Promise<PaginatedResponse<User>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
...filters
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +38,8 @@ export async function list(
|
||||
* @returns User details
|
||||
*/
|
||||
export async function getById(id: number): Promise<User> {
|
||||
const { data } = await apiClient.get<User>(`/admin/users/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<User>(`/admin/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +48,14 @@ export async function getById(id: number): Promise<User> {
|
||||
* @returns Created user
|
||||
*/
|
||||
export async function create(userData: {
|
||||
email: string;
|
||||
password: string;
|
||||
balance?: number;
|
||||
concurrency?: number;
|
||||
allowed_groups?: number[] | null;
|
||||
email: string
|
||||
password: string
|
||||
balance?: number
|
||||
concurrency?: number
|
||||
allowed_groups?: number[] | null
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/admin/users', userData);
|
||||
return data;
|
||||
const { data } = await apiClient.post<User>('/admin/users', userData)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +65,8 @@ export async function create(userData: {
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<User> {
|
||||
const { data } = await apiClient.put<User>(`/admin/users/${id}`, updates);
|
||||
return data;
|
||||
const { data } = await apiClient.put<User>(`/admin/users/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +75,8 @@ export async function update(id: number, updates: UpdateUserRequest): Promise<Us
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteUser(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +96,9 @@ export async function updateBalance(
|
||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || '',
|
||||
});
|
||||
return data;
|
||||
notes: notes || ''
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ export async function updateBalance(
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<User> {
|
||||
return update(id, { concurrency });
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +118,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<User> {
|
||||
return update(id, { status });
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +127,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
|
||||
* @returns List of user's API keys
|
||||
*/
|
||||
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<any>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/users/${id}/api-keys`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/users/${id}/api-keys`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,18 +141,18 @@ export async function getUserUsageStats(
|
||||
id: number,
|
||||
period: string = 'month'
|
||||
): Promise<{
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
total_tokens: number;
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
total_tokens: number
|
||||
}> {
|
||||
const { data } = await apiClient.get<{
|
||||
total_requests: number;
|
||||
total_cost: number;
|
||||
total_tokens: number;
|
||||
total_requests: number
|
||||
total_cost: number
|
||||
total_tokens: number
|
||||
}>(`/admin/users/${id}/usage`, {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
params: { period }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
@@ -165,7 +165,7 @@ export const usersAPI = {
|
||||
updateConcurrency,
|
||||
toggleStatus,
|
||||
getUserApiKeys,
|
||||
getUserUsageStats,
|
||||
};
|
||||
getUserUsageStats
|
||||
}
|
||||
|
||||
export default usersAPI;
|
||||
export default usersAPI
|
||||
|
||||
@@ -3,29 +3,37 @@
|
||||
* Handles user login, registration, and logout operations
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { LoginRequest, RegisterRequest, AuthResponse, User, SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings } from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
AuthResponse,
|
||||
User,
|
||||
SendVerifyCodeRequest,
|
||||
SendVerifyCodeResponse,
|
||||
PublicSettings
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Store authentication token in localStorage
|
||||
*/
|
||||
export function setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication token from localStorage
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token');
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication token from localStorage
|
||||
*/
|
||||
export function clearAuthToken(): void {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,13 +42,13 @@ export function clearAuthToken(): void {
|
||||
* @returns Authentication response with token and user data
|
||||
*/
|
||||
export async function login(credentials: LoginRequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials);
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials)
|
||||
|
||||
// Store token and user data
|
||||
setAuthToken(data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
setAuthToken(data.access_token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,13 +57,13 @@ export async function login(credentials: LoginRequest): Promise<AuthResponse> {
|
||||
* @returns Authentication response with token and user data
|
||||
*/
|
||||
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData);
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData)
|
||||
|
||||
// Store token and user data
|
||||
setAuthToken(data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
setAuthToken(data.access_token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +71,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse>
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/auth/me');
|
||||
return data;
|
||||
const { data } = await apiClient.get<User>('/auth/me')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +80,7 @@ export async function getCurrentUser(): Promise<User> {
|
||||
* Clears authentication token and user data from localStorage
|
||||
*/
|
||||
export function logout(): void {
|
||||
clearAuthToken();
|
||||
clearAuthToken()
|
||||
// Optionally redirect to login page
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
@@ -82,7 +90,7 @@ export function logout(): void {
|
||||
* @returns True if user has valid token
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return getAuthToken() !== null;
|
||||
return getAuthToken() !== null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,8 +98,8 @@ export function isAuthenticated(): boolean {
|
||||
* @returns Public settings including registration and Turnstile config
|
||||
*/
|
||||
export async function getPublicSettings(): Promise<PublicSettings> {
|
||||
const { data } = await apiClient.get<PublicSettings>('/settings/public');
|
||||
return data;
|
||||
const { data } = await apiClient.get<PublicSettings>('/settings/public')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,9 +107,11 @@ export async function getPublicSettings(): Promise<PublicSettings> {
|
||||
* @param request - Email and optional Turnstile token
|
||||
* @returns Response with countdown seconds
|
||||
*/
|
||||
export async function sendVerifyCode(request: SendVerifyCodeRequest): Promise<SendVerifyCodeResponse> {
|
||||
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request);
|
||||
return data;
|
||||
export async function sendVerifyCode(
|
||||
request: SendVerifyCodeRequest
|
||||
): Promise<SendVerifyCodeResponse> {
|
||||
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
@@ -114,7 +124,7 @@ export const authAPI = {
|
||||
getAuthToken,
|
||||
clearAuthToken,
|
||||
getPublicSettings,
|
||||
sendVerifyCode,
|
||||
};
|
||||
sendVerifyCode
|
||||
}
|
||||
|
||||
export default authAPI;
|
||||
export default authAPI
|
||||
|
||||
@@ -3,70 +3,70 @@
|
||||
* Base client with interceptors for authentication and error handling
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { ApiResponse } from '@/types';
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
// ==================== Axios Instance Configuration ====================
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Request Interceptor ====================
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Attach token from localStorage
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
// ==================== Response Interceptor ====================
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Unwrap standard API response format { code, message, data }
|
||||
const apiResponse = response.data as ApiResponse<unknown>;
|
||||
const apiResponse = response.data as ApiResponse<unknown>
|
||||
if (apiResponse && typeof apiResponse === 'object' && 'code' in apiResponse) {
|
||||
if (apiResponse.code === 0) {
|
||||
// Success - return the data portion
|
||||
response.data = apiResponse.data;
|
||||
response.data = apiResponse.data
|
||||
} else {
|
||||
// API error
|
||||
return Promise.reject({
|
||||
status: response.status,
|
||||
code: apiResponse.code,
|
||||
message: apiResponse.message || 'Unknown error',
|
||||
});
|
||||
message: apiResponse.message || 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
return response;
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
||||
// Handle common errors
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
const { status, data } = error.response
|
||||
|
||||
// 401: Unauthorized - clear token and redirect to login
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
// Only redirect if not already on login page
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,16 +74,16 @@ apiClient.interceptors.response.use(
|
||||
return Promise.reject({
|
||||
status,
|
||||
code: data?.code,
|
||||
message: data?.message || error.message,
|
||||
});
|
||||
message: data?.message || error.message
|
||||
})
|
||||
}
|
||||
|
||||
// Network error
|
||||
return Promise.reject({
|
||||
status: 0,
|
||||
message: 'Network error. Please check your connection.',
|
||||
});
|
||||
message: 'Network error. Please check your connection.'
|
||||
})
|
||||
}
|
||||
);
|
||||
)
|
||||
|
||||
export default apiClient;
|
||||
export default apiClient
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Handles group-related operations for regular users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { Group } from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type { Group } from '@/types'
|
||||
|
||||
/**
|
||||
* Get available groups that the current user can bind to API keys
|
||||
@@ -14,12 +14,12 @@ import type { Group } from '@/types';
|
||||
* @returns List of available groups
|
||||
*/
|
||||
export async function getAvailable(): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/groups/available');
|
||||
return data;
|
||||
const { data } = await apiClient.get<Group[]>('/groups/available')
|
||||
return data
|
||||
}
|
||||
|
||||
export const userGroupsAPI = {
|
||||
getAvailable,
|
||||
};
|
||||
getAvailable
|
||||
}
|
||||
|
||||
export default userGroupsAPI;
|
||||
export default userGroupsAPI
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
*/
|
||||
|
||||
// Re-export the HTTP client
|
||||
export { apiClient } from './client';
|
||||
export { apiClient } from './client'
|
||||
|
||||
// Auth API
|
||||
export { authAPI } from './auth';
|
||||
export { authAPI } from './auth'
|
||||
|
||||
// User APIs
|
||||
export { keysAPI } from './keys';
|
||||
export { usageAPI } from './usage';
|
||||
export { userAPI } from './user';
|
||||
export { redeemAPI, type RedeemHistoryItem } from './redeem';
|
||||
export { userGroupsAPI } from './groups';
|
||||
export { keysAPI } from './keys'
|
||||
export { usageAPI } from './usage'
|
||||
export { userAPI } from './user'
|
||||
export { redeemAPI, type RedeemHistoryItem } from './redeem'
|
||||
export { userGroupsAPI } from './groups'
|
||||
|
||||
// Admin APIs
|
||||
export { adminAPI } from './admin';
|
||||
export { adminAPI } from './admin'
|
||||
|
||||
// Default export
|
||||
export { default } from './client';
|
||||
export { default } from './client'
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
* Handles CRUD operations for user API keys
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
ApiKey,
|
||||
CreateApiKeyRequest,
|
||||
UpdateApiKeyRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all API keys for current user
|
||||
@@ -17,11 +12,14 @@ import type {
|
||||
* @param pageSize - Items per page (default: 10)
|
||||
* @returns Paginated list of API keys
|
||||
*/
|
||||
export async function list(page: number = 1, pageSize: number = 10): Promise<PaginatedResponse<ApiKey>> {
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResponse<ApiKey>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<ApiKey>>('/keys', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
return data;
|
||||
params: { page, page_size: pageSize }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +28,8 @@ export async function list(page: number = 1, pageSize: number = 10): Promise<Pag
|
||||
* @returns API key details
|
||||
*/
|
||||
export async function getById(id: number): Promise<ApiKey> {
|
||||
const { data } = await apiClient.get<ApiKey>(`/keys/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<ApiKey>(`/keys/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,17 +39,21 @@ export async function getById(id: number): Promise<ApiKey> {
|
||||
* @param customKey - Optional custom key value
|
||||
* @returns Created API key
|
||||
*/
|
||||
export async function create(name: string, groupId?: number | null, customKey?: string): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name };
|
||||
export async function create(
|
||||
name: string,
|
||||
groupId?: number | null,
|
||||
customKey?: string
|
||||
): Promise<ApiKey> {
|
||||
const payload: CreateApiKeyRequest = { name }
|
||||
if (groupId !== undefined) {
|
||||
payload.group_id = groupId;
|
||||
payload.group_id = groupId
|
||||
}
|
||||
if (customKey) {
|
||||
payload.custom_key = customKey;
|
||||
payload.custom_key = customKey
|
||||
}
|
||||
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload);
|
||||
return data;
|
||||
const { data } = await apiClient.post<ApiKey>('/keys', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,8 +63,8 @@ export async function create(name: string, groupId?: number | null, customKey?:
|
||||
* @returns Updated API key
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateApiKeyRequest): Promise<ApiKey> {
|
||||
const { data } = await apiClient.put<ApiKey>(`/keys/${id}`, updates);
|
||||
return data;
|
||||
const { data } = await apiClient.put<ApiKey>(`/keys/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,8 +73,8 @@ export async function update(id: number, updates: UpdateApiKeyRequest): Promise<
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function deleteKey(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/keys/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,11 +83,8 @@ export async function deleteKey(id: number): Promise<{ message: string }> {
|
||||
* @param status - New status
|
||||
* @returns Updated API key
|
||||
*/
|
||||
export async function toggleStatus(
|
||||
id: number,
|
||||
status: 'active' | 'inactive'
|
||||
): Promise<ApiKey> {
|
||||
return update(id, { status });
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<ApiKey> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
export const keysAPI = {
|
||||
@@ -94,7 +93,7 @@ export const keysAPI = {
|
||||
create,
|
||||
update,
|
||||
delete: deleteKey,
|
||||
toggleStatus,
|
||||
};
|
||||
toggleStatus
|
||||
}
|
||||
|
||||
export default keysAPI;
|
||||
export default keysAPI
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
* Handles redeem code redemption for users
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { RedeemCodeRequest } from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type { RedeemCodeRequest } from '@/types'
|
||||
|
||||
export interface RedeemHistoryItem {
|
||||
id: number;
|
||||
code: string;
|
||||
type: string;
|
||||
value: number;
|
||||
status: string;
|
||||
used_at: string;
|
||||
created_at: string;
|
||||
id: number
|
||||
code: string
|
||||
type: string
|
||||
value: number
|
||||
status: string
|
||||
used_at: string
|
||||
created_at: string
|
||||
// 订阅类型专用字段
|
||||
group_id?: number;
|
||||
validity_days?: number;
|
||||
group_id?: number
|
||||
validity_days?: number
|
||||
group?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,23 +29,23 @@ export interface RedeemHistoryItem {
|
||||
* @returns Redemption result with updated balance or concurrency
|
||||
*/
|
||||
export async function redeem(code: string): Promise<{
|
||||
message: string;
|
||||
type: string;
|
||||
value: number;
|
||||
new_balance?: number;
|
||||
new_concurrency?: number;
|
||||
message: string
|
||||
type: string
|
||||
value: number
|
||||
new_balance?: number
|
||||
new_concurrency?: number
|
||||
}> {
|
||||
const payload: RedeemCodeRequest = { code };
|
||||
const payload: RedeemCodeRequest = { code }
|
||||
|
||||
const { data } = await apiClient.post<{
|
||||
message: string;
|
||||
type: string;
|
||||
value: number;
|
||||
new_balance?: number;
|
||||
new_concurrency?: number;
|
||||
}>('/redeem', payload);
|
||||
message: string
|
||||
type: string
|
||||
value: number
|
||||
new_balance?: number
|
||||
new_concurrency?: number
|
||||
}>('/redeem', payload)
|
||||
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,13 +53,13 @@ export async function redeem(code: string): Promise<{
|
||||
* @returns List of redeemed codes
|
||||
*/
|
||||
export async function getHistory(): Promise<RedeemHistoryItem[]> {
|
||||
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history');
|
||||
return data;
|
||||
const { data } = await apiClient.get<RedeemHistoryItem[]>('/redeem/history')
|
||||
return data
|
||||
}
|
||||
|
||||
export const redeemAPI = {
|
||||
redeem,
|
||||
getHistory,
|
||||
};
|
||||
getHistory
|
||||
}
|
||||
|
||||
export default redeemAPI;
|
||||
export default redeemAPI
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
/**
|
||||
* Setup API endpoints
|
||||
*/
|
||||
import axios from 'axios';
|
||||
import axios from 'axios'
|
||||
|
||||
// Create a separate client for setup endpoints (not under /api/v1)
|
||||
const setupClient = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean;
|
||||
step: string;
|
||||
needs_setup: boolean
|
||||
step: string
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
dbname: string;
|
||||
sslmode: string;
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
dbname: string
|
||||
sslmode: string
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
db: number;
|
||||
host: string
|
||||
port: number
|
||||
password: string
|
||||
db: number
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
mode: string;
|
||||
host: string
|
||||
port: number
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface InstallRequest {
|
||||
database: DatabaseConfig;
|
||||
redis: RedisConfig;
|
||||
admin: AdminConfig;
|
||||
server: ServerConfig;
|
||||
database: DatabaseConfig
|
||||
redis: RedisConfig
|
||||
admin: AdminConfig
|
||||
server: ServerConfig
|
||||
}
|
||||
|
||||
export interface InstallResponse {
|
||||
message: string;
|
||||
restart: boolean;
|
||||
message: string
|
||||
restart: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get setup status
|
||||
*/
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
const response = await setupClient.get('/setup/status');
|
||||
return response.data.data;
|
||||
const response = await setupClient.get('/setup/status')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
*/
|
||||
export async function testDatabase(config: DatabaseConfig): Promise<void> {
|
||||
await setupClient.post('/setup/test-db', config);
|
||||
await setupClient.post('/setup/test-db', config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Redis connection
|
||||
*/
|
||||
export async function testRedis(config: RedisConfig): Promise<void> {
|
||||
await setupClient.post('/setup/test-redis', config);
|
||||
await setupClient.post('/setup/test-redis', config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform installation
|
||||
*/
|
||||
export async function install(config: InstallRequest): Promise<InstallResponse> {
|
||||
const response = await setupClient.post('/setup/install', config);
|
||||
return response.data.data;
|
||||
const response = await setupClient.post('/setup/install', config)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
@@ -3,64 +3,68 @@
|
||||
* API for regular users to view their own subscriptions and progress
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { UserSubscription, SubscriptionProgress } from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type { UserSubscription, SubscriptionProgress } from '@/types'
|
||||
|
||||
/**
|
||||
* Subscription summary for user dashboard
|
||||
*/
|
||||
export interface SubscriptionSummary {
|
||||
active_count: number;
|
||||
active_count: number
|
||||
subscriptions: Array<{
|
||||
id: number;
|
||||
group_name: string;
|
||||
status: string;
|
||||
daily_progress: number | null;
|
||||
weekly_progress: number | null;
|
||||
monthly_progress: number | null;
|
||||
expires_at: string | null;
|
||||
days_remaining: number | null;
|
||||
}>;
|
||||
id: number
|
||||
group_name: string
|
||||
status: string
|
||||
daily_progress: number | null
|
||||
weekly_progress: number | null
|
||||
monthly_progress: number | null
|
||||
expires_at: string | null
|
||||
days_remaining: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of current user's subscriptions
|
||||
*/
|
||||
export async function getMySubscriptions(): Promise<UserSubscription[]> {
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions');
|
||||
return response.data;
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's active subscriptions
|
||||
*/
|
||||
export async function getActiveSubscriptions(): Promise<UserSubscription[]> {
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions/active');
|
||||
return response.data;
|
||||
const response = await apiClient.get<UserSubscription[]>('/subscriptions/active')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for all user's active subscriptions
|
||||
*/
|
||||
export async function getSubscriptionsProgress(): Promise<SubscriptionProgress[]> {
|
||||
const response = await apiClient.get<SubscriptionProgress[]>('/subscriptions/progress');
|
||||
return response.data;
|
||||
const response = await apiClient.get<SubscriptionProgress[]>('/subscriptions/progress')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription summary for dashboard display
|
||||
*/
|
||||
export async function getSubscriptionSummary(): Promise<SubscriptionSummary> {
|
||||
const response = await apiClient.get<SubscriptionSummary>('/subscriptions/summary');
|
||||
return response.data;
|
||||
const response = await apiClient.get<SubscriptionSummary>('/subscriptions/summary')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress for a specific subscription
|
||||
*/
|
||||
export async function getSubscriptionProgress(subscriptionId: number): Promise<SubscriptionProgress> {
|
||||
const response = await apiClient.get<SubscriptionProgress>(`/subscriptions/${subscriptionId}/progress`);
|
||||
return response.data;
|
||||
export async function getSubscriptionProgress(
|
||||
subscriptionId: number
|
||||
): Promise<SubscriptionProgress> {
|
||||
const response = await apiClient.get<SubscriptionProgress>(
|
||||
`/subscriptions/${subscriptionId}/progress`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -68,5 +72,5 @@ export default {
|
||||
getActiveSubscriptions,
|
||||
getSubscriptionsProgress,
|
||||
getSubscriptionSummary,
|
||||
getSubscriptionProgress,
|
||||
};
|
||||
getSubscriptionProgress
|
||||
}
|
||||
|
||||
@@ -3,59 +3,59 @@
|
||||
* Handles usage logs and statistics retrieval
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
UsageLog,
|
||||
UsageQueryParams,
|
||||
UsageStatsResponse,
|
||||
PaginatedResponse,
|
||||
TrendDataPoint,
|
||||
ModelStat,
|
||||
} from '@/types';
|
||||
ModelStat
|
||||
} from '@/types'
|
||||
|
||||
// ==================== Dashboard Types ====================
|
||||
|
||||
export interface UserDashboardStats {
|
||||
total_api_keys: number;
|
||||
active_api_keys: number;
|
||||
total_requests: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_creation_tokens: number;
|
||||
total_cache_read_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number; // 标准计费
|
||||
total_actual_cost: number; // 实际扣除
|
||||
today_requests: number;
|
||||
today_input_tokens: number;
|
||||
today_output_tokens: number;
|
||||
today_cache_creation_tokens: number;
|
||||
today_cache_read_tokens: number;
|
||||
today_tokens: number;
|
||||
today_cost: number; // 今日标准计费
|
||||
today_actual_cost: number; // 今日实际扣除
|
||||
average_duration_ms: number;
|
||||
rpm: number; // 近5分钟平均每分钟请求数
|
||||
tpm: number; // 近5分钟平均每分钟Token数
|
||||
total_api_keys: number
|
||||
active_api_keys: number
|
||||
total_requests: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_cache_creation_tokens: number
|
||||
total_cache_read_tokens: number
|
||||
total_tokens: number
|
||||
total_cost: number // 标准计费
|
||||
total_actual_cost: number // 实际扣除
|
||||
today_requests: number
|
||||
today_input_tokens: number
|
||||
today_output_tokens: number
|
||||
today_cache_creation_tokens: number
|
||||
today_cache_read_tokens: number
|
||||
today_tokens: number
|
||||
today_cost: number // 今日标准计费
|
||||
today_actual_cost: number // 今日实际扣除
|
||||
average_duration_ms: number
|
||||
rpm: number // 近5分钟平均每分钟请求数
|
||||
tpm: number // 近5分钟平均每分钟Token数
|
||||
}
|
||||
|
||||
export interface TrendParams {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
granularity?: 'day' | 'hour';
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
granularity?: 'day' | 'hour'
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
trend: TrendDataPoint[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
granularity: string;
|
||||
trend: TrendDataPoint[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
granularity: string
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
models: ModelStat[];
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
models: ModelStat[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,17 +72,17 @@ export async function list(
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const params: UsageQueryParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
page_size: pageSize
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId;
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,9 +92,9 @@ export async function list(
|
||||
*/
|
||||
export async function query(params: UsageQueryParams): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,16 +107,16 @@ export async function getStats(
|
||||
period: string = 'today',
|
||||
apiKeyId?: number
|
||||
): Promise<UsageStatsResponse> {
|
||||
const params: Record<string, unknown> = { period };
|
||||
const params: Record<string, unknown> = { period }
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId;
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,17 +133,17 @@ export async function getStatsByDateRange(
|
||||
): Promise<UsageStatsResponse> {
|
||||
const params: Record<string, unknown> = {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
};
|
||||
end_date: endDate
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId;
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UsageStatsResponse>('/usage/stats', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,17 +162,17 @@ export async function getByDateRange(
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
};
|
||||
page_size: 100
|
||||
}
|
||||
|
||||
if (apiKeyId !== undefined) {
|
||||
params.api_key_id = apiKeyId;
|
||||
params.api_key_id = apiKeyId
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
params
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,8 +181,8 @@ export async function getByDateRange(
|
||||
* @returns Usage log details
|
||||
*/
|
||||
export async function getById(id: number): Promise<UsageLog> {
|
||||
const { data } = await apiClient.get<UsageLog>(`/usage/${id}`);
|
||||
return data;
|
||||
const { data } = await apiClient.get<UsageLog>(`/usage/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
// ==================== Dashboard API ====================
|
||||
@@ -192,8 +192,8 @@ export async function getById(id: number): Promise<UsageLog> {
|
||||
* @returns Dashboard statistics for current user
|
||||
*/
|
||||
export async function getDashboardStats(): Promise<UserDashboardStats> {
|
||||
const { data } = await apiClient.get<UserDashboardStats>('/usage/dashboard/stats');
|
||||
return data;
|
||||
const { data } = await apiClient.get<UserDashboardStats>('/usage/dashboard/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,8 +202,8 @@ export async function getDashboardStats(): Promise<UserDashboardStats> {
|
||||
* @returns Usage trend data for current user
|
||||
*/
|
||||
export async function getDashboardTrend(params?: TrendParams): Promise<TrendResponse> {
|
||||
const { data } = await apiClient.get<TrendResponse>('/usage/dashboard/trend', { params });
|
||||
return data;
|
||||
const { data } = await apiClient.get<TrendResponse>('/usage/dashboard/trend', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,19 +211,22 @@ export async function getDashboardTrend(params?: TrendParams): Promise<TrendResp
|
||||
* @param params - Query parameters for filtering
|
||||
* @returns Model usage statistics for current user
|
||||
*/
|
||||
export async function getDashboardModels(params?: { start_date?: string; end_date?: string }): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params });
|
||||
return data;
|
||||
export async function getDashboardModels(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<ModelStatsResponse> {
|
||||
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface BatchApiKeyUsageStats {
|
||||
api_key_id: number;
|
||||
today_actual_cost: number;
|
||||
total_actual_cost: number;
|
||||
api_key_id: number
|
||||
today_actual_cost: number
|
||||
total_actual_cost: number
|
||||
}
|
||||
|
||||
export interface BatchApiKeysUsageResponse {
|
||||
stats: Record<string, BatchApiKeyUsageStats>;
|
||||
stats: Record<string, BatchApiKeyUsageStats>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,11 +234,16 @@ export interface BatchApiKeysUsageResponse {
|
||||
* @param apiKeyIds - Array of API key IDs
|
||||
* @returns Usage stats map keyed by API key ID
|
||||
*/
|
||||
export async function getDashboardApiKeysUsage(apiKeyIds: number[]): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>('/usage/dashboard/api-keys-usage', {
|
||||
api_key_ids: apiKeyIds,
|
||||
});
|
||||
return data;
|
||||
export async function getDashboardApiKeysUsage(
|
||||
apiKeyIds: number[]
|
||||
): Promise<BatchApiKeysUsageResponse> {
|
||||
const { data } = await apiClient.post<BatchApiKeysUsageResponse>(
|
||||
'/usage/dashboard/api-keys-usage',
|
||||
{
|
||||
api_key_ids: apiKeyIds
|
||||
}
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const usageAPI = {
|
||||
@@ -249,7 +257,7 @@ export const usageAPI = {
|
||||
getDashboardStats,
|
||||
getDashboardTrend,
|
||||
getDashboardModels,
|
||||
getDashboardApiKeysUsage,
|
||||
};
|
||||
getDashboardApiKeysUsage
|
||||
}
|
||||
|
||||
export default usageAPI;
|
||||
export default usageAPI
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
* Handles user profile management and password changes
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { User, ChangePasswordRequest } from '@/types';
|
||||
import { apiClient } from './client'
|
||||
import type { User, ChangePasswordRequest } from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
* @returns User profile data
|
||||
*/
|
||||
export async function getProfile(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>('/user/profile');
|
||||
return data;
|
||||
const { data } = await apiClient.get<User>('/user/profile')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,11 +21,11 @@ export async function getProfile(): Promise<User> {
|
||||
* @returns Updated user profile data
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string;
|
||||
wechat?: string;
|
||||
username?: string
|
||||
wechat?: string
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile);
|
||||
return data;
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,17 +39,17 @@ export async function changePassword(
|
||||
): Promise<{ message: string }> {
|
||||
const payload: ChangePasswordRequest = {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
};
|
||||
new_password: newPassword
|
||||
}
|
||||
|
||||
const { data } = await apiClient.put<{ message: string }>('/user/password', payload);
|
||||
return data;
|
||||
const { data } = await apiClient.put<{ message: string }>('/user/password', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
};
|
||||
changePassword
|
||||
}
|
||||
|
||||
export default userAPI;
|
||||
export default userAPI
|
||||
|
||||
@@ -5,158 +5,164 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
'error-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
sitekey: string
|
||||
callback: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
}
|
||||
|
||||
interface TurnstileAPI {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
remove: (widgetId?: string) => void;
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileAPI;
|
||||
onTurnstileLoad?: () => void;
|
||||
turnstile?: TurnstileAPI
|
||||
onTurnstileLoad?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
siteKey: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
}>(), {
|
||||
theme: 'auto',
|
||||
size: 'flexible',
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
siteKey: string
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
}>(),
|
||||
{
|
||||
theme: 'auto',
|
||||
size: 'flexible'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'verify', token: string): void;
|
||||
(e: 'expire'): void;
|
||||
(e: 'error'): void;
|
||||
}>();
|
||||
(e: 'verify', token: string): void
|
||||
(e: 'expire'): void
|
||||
(e: 'error'): void
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const widgetId = ref<string | null>(null);
|
||||
const scriptLoaded = ref(false);
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<string | null>(null)
|
||||
const scriptLoaded = ref(false)
|
||||
|
||||
const loadScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.turnstile) {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
return;
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if script is already loading
|
||||
const existingScript = document.querySelector('script[src*="turnstile"]');
|
||||
const existingScript = document.querySelector('script[src*="turnstile"]')
|
||||
if (existingScript) {
|
||||
window.onTurnstileLoad = () => {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
};
|
||||
return;
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
window.onTurnstileLoad = () => {
|
||||
scriptLoaded.value = true;
|
||||
resolve();
|
||||
};
|
||||
scriptLoaded.value = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load Turnstile script'));
|
||||
};
|
||||
reject(new Error('Failed to load Turnstile script'))
|
||||
}
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
const renderWidget = () => {
|
||||
if (!window.turnstile || !containerRef.value || !props.siteKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// Remove existing widget if any
|
||||
if (widgetId.value) {
|
||||
try {
|
||||
window.turnstile.remove(widgetId.value);
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} catch {
|
||||
// Ignore errors when removing
|
||||
}
|
||||
widgetId.value = null;
|
||||
widgetId.value = null
|
||||
}
|
||||
|
||||
// Clear container
|
||||
containerRef.value.innerHTML = '';
|
||||
containerRef.value.innerHTML = ''
|
||||
|
||||
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||
sitekey: props.siteKey,
|
||||
callback: (token: string) => {
|
||||
emit('verify', token);
|
||||
emit('verify', token)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
emit('expire');
|
||||
emit('expire')
|
||||
},
|
||||
'error-callback': () => {
|
||||
emit('error');
|
||||
emit('error')
|
||||
},
|
||||
theme: props.theme,
|
||||
size: props.size,
|
||||
});
|
||||
};
|
||||
size: props.size
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (window.turnstile && widgetId.value) {
|
||||
window.turnstile.reset(widgetId.value);
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Expose reset method to parent
|
||||
defineExpose({ reset });
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.siteKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await loadScript();
|
||||
renderWidget();
|
||||
await loadScript()
|
||||
renderWidget()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Turnstile:', error);
|
||||
emit('error');
|
||||
console.error('Failed to initialize Turnstile:', error)
|
||||
emit('error')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (window.turnstile && widgetId.value) {
|
||||
try {
|
||||
window.turnstile.remove(widgetId.value);
|
||||
window.turnstile.remove(widgetId.value)
|
||||
} catch {
|
||||
// Ignore errors when removing
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Re-render when siteKey changes
|
||||
watch(() => props.siteKey, (newKey) => {
|
||||
if (newKey && scriptLoaded.value) {
|
||||
renderWidget();
|
||||
watch(
|
||||
() => props.siteKey,
|
||||
(newKey) => {
|
||||
if (newKey && scriptLoaded.value) {
|
||||
renderWidget()
|
||||
}
|
||||
}
|
||||
});
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('admin.accounts.usageStatistics')"
|
||||
size="2xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Modal :show="show" :title="t('admin.accounts.usageStatistics')" size="2xl" @close="handleClose">
|
||||
<div class="space-y-6">
|
||||
<!-- Account Info Header -->
|
||||
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 rounded-xl border border-primary-200 dark:border-primary-700/50">
|
||||
<div
|
||||
v-if="account"
|
||||
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" 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" />
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" 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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -23,7 +28,7 @@
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'px-2.5 py-1 text-xs font-semibold rounded-full',
|
||||
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
@@ -42,62 +47,140 @@
|
||||
<!-- Row 1: Main Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- 30-Day Total Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-emerald-50 to-white dark:from-emerald-900/10 dark:to-dark-700 border-emerald-200 dark:border-emerald-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.total_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.total_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500">({{ t('admin.accounts.stats.standardCost') }}: ${{ formatCost(stats.summary.total_standard_cost) }})</span>
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
formatCost(stats.summary.total_standard_cost)
|
||||
}})</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Total Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-blue-50 to-white dark:from-blue-900/10 dark:to-dark-700 border-blue-200 dark:border-blue-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div
|
||||
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.total_requests) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.totalCalls') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(stats.summary.total_requests) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.totalCalls') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Cost -->
|
||||
<div class="card p-4 bg-gradient-to-br from-amber-50 to-white dark:from-amber-900/10 dark:to-dark-700 border-amber-200 dark:border-amber-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyCost') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<div
|
||||
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">${{ formatCost(stats.summary.avg_daily_cost) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.basedOnActualDays', { days: stats.summary.actual_days_used }) }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.avg_daily_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('admin.accounts.stats.basedOnActualDays', {
|
||||
days: stats.summary.actual_days_used
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Requests -->
|
||||
<div class="card p-4 bg-gradient-to-br from-purple-50 to-white dark:from-purple-900/10 dark:to-dark-700 border-purple-200 dark:border-purple-800/30">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgDailyRequests') }}</span>
|
||||
<div class="p-1.5 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||
<div
|
||||
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.stats.avgDailyUsage') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.avgDailyUsage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,78 +188,148 @@
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Today Overview -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-4 h-4 text-cyan-600 dark:text-cyan-400" 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" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
|
||||
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>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.todayOverview') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.todayOverview')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Cost Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-orange-100 dark:bg-orange-900/30">
|
||||
<svg class="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestCostDay') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestCostDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_cost_day?.label || '-' }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_cost_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400">${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.highest_cost_day?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.highest_cost_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Request Day -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-4 h-4 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.highestRequestDay') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestRequestDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.date') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.highest_request_day?.label || '-' }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_request_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.requests') }}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{ formatNumber(stats.summary.highest_request_day?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
|
||||
formatNumber(stats.summary.highest_request_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.cost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,70 +339,134 @@
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Accumulated Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-teal-100 dark:bg-teal-900/30">
|
||||
<svg class="w-4 h-4 text-teal-600 dark:text-teal-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-teal-600 dark:text-teal-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.accumulatedTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.accumulatedTokens')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.totalTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.total_tokens) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.total_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.dailyAvgTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(Math.round(stats.summary.avg_daily_tokens)) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.dailyAvgTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(Math.round(stats.summary.avg_daily_tokens))
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-4 h-4 text-rose-600 dark:text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-rose-600 dark:text-rose-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.performance') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.performance')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.avgResponseTime') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatDuration(stats.summary.avg_duration_ms) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgResponseTime')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatDuration(stats.summary.avg_duration_ms)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.daysActive') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.daysActive')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="p-1.5 rounded-lg bg-lime-100 dark:bg-lime-900/30">
|
||||
<svg class="w-4 h-4 text-lime-600 dark:text-lime-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-lime-600 dark:text-lime-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.accounts.stats.recentActivity') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.recentActivity')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayRequests') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatNumber(stats.summary.today?.requests || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayRequests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayTokens') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ formatTokens(stats.summary.today?.tokens || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.stats.todayCost') }}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">${{ formatCost(stats.summary.today?.cost || 0) }}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayCost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,26 +474,36 @@
|
||||
|
||||
<!-- Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.accounts.stats.usageTrend') }}</h3>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.stats.usageTrend') }}
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Distribution -->
|
||||
<ModelDistributionChart
|
||||
:model-stats="stats.models"
|
||||
:loading="false"
|
||||
/>
|
||||
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
|
||||
</template>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" />
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
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>
|
||||
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
||||
</div>
|
||||
@@ -286,7 +513,7 @@
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
@@ -349,7 +576,7 @@ const isDarkMode = computed(() => {
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
|
||||
}))
|
||||
|
||||
// Line chart data
|
||||
@@ -357,27 +584,27 @@ const trendChartData = computed(() => {
|
||||
if (!stats.value?.history?.length) return null
|
||||
|
||||
return {
|
||||
labels: stats.value.history.map(h => h.label),
|
||||
labels: stats.value.history.map((h) => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map(h => h.cost),
|
||||
data: stats.value.history.map((h) => h.cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y',
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map(h => h.requests),
|
||||
data: stats.value.history.map((h) => h.requests),
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
],
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -387,7 +614,7 @@ const lineChartOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
mode: 'index' as const
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -398,9 +625,9 @@ const lineChartOptions = computed(() => ({
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -411,81 +638,84 @@ const lineChartOptions = computed(() => ({
|
||||
return `${label}: $${formatCost(value)}`
|
||||
}
|
||||
return `${label}: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
},
|
||||
minRotation: 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value)),
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => formatNumber(Number(value)),
|
||||
callback: (value: string | number) => formatNumber(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.requests'),
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Load stats when modal opens
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
@@ -1,55 +1,86 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<span
|
||||
:class="[
|
||||
'badge text-xs',
|
||||
statusClass
|
||||
]"
|
||||
>
|
||||
<span :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<!-- Error Info Indicator -->
|
||||
<div v-if="hasError && account.error_message" class="relative group/error">
|
||||
<svg class="w-4 h-4 text-red-500 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||
<svg
|
||||
class="h-4 w-4 cursor-help text-red-500 transition-colors hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Tooltip - 向下显示 -->
|
||||
<div class="absolute top-full left-0 mt-1.5 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-xs rounded-lg shadow-xl opacity-0 invisible group-hover/error:opacity-100 group-hover/error:visible transition-all duration-200 z-[100] min-w-[200px] max-w-[300px]">
|
||||
<div class="text-gray-300 break-words whitespace-pre-wrap leading-relaxed">{{ account.error_message }}</div>
|
||||
<div
|
||||
class="invisible absolute left-0 top-full z-[100] mt-1.5 min-w-[200px] max-w-[300px] rounded-lg bg-gray-800 px-3 py-2 text-xs text-white opacity-0 shadow-xl transition-all duration-200 group-hover/error:visible group-hover/error:opacity-100 dark:bg-gray-900"
|
||||
>
|
||||
<div class="whitespace-pre-wrap break-words leading-relaxed text-gray-300">
|
||||
{{ account.error_message }}
|
||||
</div>
|
||||
<!-- 上方小三角 -->
|
||||
<div class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"></div>
|
||||
<div
|
||||
class="absolute bottom-full left-3 border-[6px] border-transparent border-b-gray-800 dark:border-b-gray-900"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="relative group">
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
429
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overload Indicator (529) -->
|
||||
<div v-if="isOverloaded" class="relative group">
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div v-if="isOverloaded" class="group relative">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
529
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
Overloaded until {{ formatTime(account.overload_until) }}
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,29 @@
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Account Info Card -->
|
||||
<div v-if="account" class="flex items-center justify-between p-3 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-dark-700 dark:to-dark-600 rounded-xl border border-gray-200 dark:border-dark-500">
|
||||
<div
|
||||
v-if="account"
|
||||
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
|
||||
<span class="px-1.5 py-0.5 bg-gray-200 dark:bg-dark-500 rounded text-[10px] font-medium uppercase">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
|
||||
>
|
||||
{{ account.type }}
|
||||
</span>
|
||||
<span>{{ t('admin.accounts.account') }}</span>
|
||||
@@ -26,7 +38,7 @@
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'px-2.5 py-1 text-xs font-semibold rounded-full',
|
||||
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
@@ -44,7 +56,7 @@
|
||||
<select
|
||||
v-model="selectedModelId"
|
||||
:disabled="loadingModels || status === 'connecting'"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-dark-500 bg-white dark:bg-dark-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
|
||||
>
|
||||
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
|
||||
<option v-for="model in availableModels" :key="model.id" :value="model.id">
|
||||
@@ -54,22 +66,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="relative group">
|
||||
<div class="group relative">
|
||||
<div
|
||||
ref="terminalRef"
|
||||
class="bg-gray-900 dark:bg-black rounded-xl p-4 min-h-[120px] max-h-[240px] overflow-y-auto font-mono text-sm border border-gray-700 dark:border-gray-800"
|
||||
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
|
||||
>
|
||||
<!-- Status Line -->
|
||||
<div v-if="status === 'idle'" class="text-gray-500 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="text-yellow-400 flex items-center gap-2">
|
||||
<svg class="animate-spin w-4 h-4" 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>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
<svg class="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>
|
||||
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
||||
</div>
|
||||
@@ -85,15 +113,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Result Status -->
|
||||
<div v-if="status === 'success'" class="text-green-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" 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" />
|
||||
<div
|
||||
v-if="status === 'success'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
|
||||
>
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'error'" class="text-red-400 mt-3 pt-3 border-t border-gray-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
||||
>
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
@@ -103,28 +147,43 @@
|
||||
<button
|
||||
v-if="outputLines.length > 0"
|
||||
@click="copyOutput"
|
||||
class="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-white bg-gray-800/80 hover:bg-gray-700 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
|
||||
:title="t('admin.accounts.copyOutput')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test Info -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 px-1">
|
||||
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
@@ -135,7 +194,7 @@
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-dark-600 hover:bg-gray-200 dark:hover:bg-dark-500 rounded-lg transition-colors"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
:disabled="status === 'connecting'"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
@@ -144,29 +203,72 @@
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium rounded-lg transition-all flex items-center gap-2',
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
? 'bg-primary-400 text-white cursor-not-allowed'
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: status === 'error'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600'
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
]"
|
||||
>
|
||||
<svg v-if="status === 'connecting'" class="animate-spin h-4 w-4" 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
|
||||
v-if="status === 'connecting'"
|
||||
class="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>
|
||||
<svg v-else-if="status === 'idle'" class="w-4 h-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
|
||||
v-else-if="status === 'idle'"
|
||||
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>
|
||||
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<svg v-else 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{{ status === 'connecting' ? t('admin.accounts.testing') : status === 'idle' ? t('admin.accounts.startTest') : t('admin.accounts.retry') }}
|
||||
{{
|
||||
status === 'connecting'
|
||||
? t('admin.accounts.testing')
|
||||
: status === 'idle'
|
||||
? t('admin.accounts.startTest')
|
||||
: t('admin.accounts.retry')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -208,14 +310,17 @@ const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
closeEventSource()
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
closeEventSource()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
@@ -224,11 +329,18 @@ const loadAvailableModels = async () => {
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
try {
|
||||
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
// Default to first model (usually Sonnet)
|
||||
// Default selection by platform
|
||||
if (availableModels.value.length > 0) {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
const sonnetModel = availableModels.value.find(m => m.id.includes('sonnet'))
|
||||
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
|
||||
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load available models:', error)
|
||||
@@ -290,7 +402,7 @@ const startTest = async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
@@ -337,7 +449,13 @@ const startTest = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvent = (event: { type: string; text?: string; model?: string; success?: boolean; error?: string }) => {
|
||||
const handleEvent = (event: {
|
||||
type: string
|
||||
text?: string
|
||||
model?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
}) => {
|
||||
switch (event.type) {
|
||||
case 'test_start':
|
||||
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
|
||||
@@ -382,7 +500,7 @@ const handleEvent = (event: { type: string; text?: string; model?: string; succe
|
||||
}
|
||||
|
||||
const copyOutput = () => {
|
||||
const text = outputLines.value.map(l => l.text).join('\n')
|
||||
const text = outputLines.value.map((l) => l.text).join('\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-0.5">
|
||||
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
@@ -17,24 +17,28 @@
|
||||
<!-- Requests -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Req:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatNumber(stats.requests)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Tokens -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
formatTokens(stats.tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<!-- Cost -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span>
|
||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||
formatCurrency(stats.cost)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
<template>
|
||||
<div v-if="showUsageWindows">
|
||||
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
|
||||
<template v-if="account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')">
|
||||
<template
|
||||
v-if="
|
||||
account.platform === 'anthropic' &&
|
||||
(account.type === 'oauth' || account.type === 'setup-token')
|
||||
"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-1.5">
|
||||
<!-- OAuth: 3 rows, Setup Token: 1 row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<template v-if="account.type === 'oauth'">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
|
||||
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -61,9 +66,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||
@@ -97,9 +100,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Non-OAuth/Setup-Token accounts -->
|
||||
<div v-else class="text-xs text-gray-400">
|
||||
-
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
|
||||
// Show usage windows for OAuth and Setup Token accounts
|
||||
const showUsageWindows = computed(() =>
|
||||
props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
const showUsageWindows = computed(
|
||||
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
)
|
||||
|
||||
// OpenAI Codex usage computed properties
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
return extra && (
|
||||
return (
|
||||
extra &&
|
||||
// Check for new canonical fields first
|
||||
extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined
|
||||
(extra.codex_5h_used_percent !== undefined ||
|
||||
extra.codex_7d_used_percent !== undefined ||
|
||||
// Fallback to legacy fields
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes <= 360
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_primary_used_percent ?? null
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
return extra.codex_secondary_used_percent ?? null
|
||||
}
|
||||
|
||||
@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
|
||||
}
|
||||
|
||||
// Fallback: detect from legacy fields using window_minutes
|
||||
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_primary_window_minutes !== undefined &&
|
||||
extra.codex_primary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
}
|
||||
}
|
||||
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
|
||||
if (
|
||||
extra.codex_secondary_window_minutes !== undefined &&
|
||||
extra.codex_secondary_window_minutes >= 10000
|
||||
) {
|
||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
|
||||
<form class="space-y-5" @submit.prevent="handleSubmit">
|
||||
<!-- Info -->
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||
<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">
|
||||
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
|
||||
<input
|
||||
v-model="enableBaseUrl"
|
||||
@@ -31,7 +31,7 @@
|
||||
type="text"
|
||||
:disabled="!enableBaseUrl"
|
||||
class="input"
|
||||
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
|
||||
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
@@ -40,8 +40,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Model restriction -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
<input
|
||||
v-model="enableModelRestriction"
|
||||
@@ -50,21 +50,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableModelRestriction && 'pointer-events-none opacity-50'">
|
||||
<!-- Mode Toggle -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'whitelist'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1.5"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -84,12 +84,12 @@
|
||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
modelRestrictionMode === 'mapping'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="modelRestrictionMode = 'mapping'"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1.5"
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -107,10 +107,10 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Checkbox List -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="model in allModels"
|
||||
:key="model.value"
|
||||
@@ -158,10 +158,10 @@
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -178,7 +178,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
@@ -191,7 +191,7 @@
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400 flex-shrink-0"
|
||||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -211,10 +211,10 @@
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
@click="removeModelMapping(index)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -228,11 +228,11 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
@click="addModelMapping"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -264,11 +264,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Custom error codes -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.customErrorCodesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,10 +280,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="enableCustomErrorCodes" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||
<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">
|
||||
<svg
|
||||
class="w-4 h-4 inline mr-1"
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -308,8 +308,8 @@
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
selectedErrorCodes.includes(code.value)
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
|
||||
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
@click="toggleErrorCode(code.value)"
|
||||
>
|
||||
@@ -329,7 +329,7 @@
|
||||
@keyup.enter="addCustomErrorCode"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -345,7 +345,7 @@
|
||||
<span
|
||||
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||||
:key="code"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ code }}
|
||||
<button
|
||||
@@ -353,7 +353,7 @@
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
@click="removeErrorCode(code)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -371,13 +371,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Intercept warmup requests (Anthropic only) -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label class="input-label mb-0">{{
|
||||
t('admin.accounts.interceptWarmupRequests')
|
||||
}}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -392,14 +392,14 @@
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600',
|
||||
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0',
|
||||
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
@@ -407,8 +407,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Proxy -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
|
||||
<input
|
||||
v-model="enableProxy"
|
||||
@@ -416,15 +416,15 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableProxy && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableProxy && 'pointer-events-none opacity-50'">
|
||||
<ProxySelector v-model="proxyId" :proxies="proxies" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Concurrency & Priority -->
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
|
||||
<input
|
||||
v-model="enableConcurrency"
|
||||
@@ -438,11 +438,11 @@
|
||||
min="1"
|
||||
:disabled="!enableConcurrency"
|
||||
class="input"
|
||||
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
|
||||
<input
|
||||
v-model="enablePriority"
|
||||
@@ -456,14 +456,14 @@
|
||||
min="1"
|
||||
:disabled="!enablePriority"
|
||||
class="input"
|
||||
:class="!enablePriority && 'opacity-50 cursor-not-allowed'"
|
||||
:class="!enablePriority && 'cursor-not-allowed opacity-50'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('common.status') }}</label>
|
||||
<input
|
||||
v-model="enableStatus"
|
||||
@@ -471,14 +471,14 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableStatus && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableStatus && 'pointer-events-none opacity-50'">
|
||||
<Select v-model="status" :options="statusOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<label class="input-label mb-0">{{ t('nav.groups') }}</label>
|
||||
<input
|
||||
v-model="enableGroups"
|
||||
@@ -486,7 +486,7 @@
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div :class="!enableGroups && 'opacity-50 pointer-events-none'">
|
||||
<div :class="!enableGroups && 'pointer-events-none opacity-50'">
|
||||
<GroupSelector v-model="groupIds" :groups="groups" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,7 +499,7 @@
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -601,7 +601,7 @@ const allModels = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
]
|
||||
|
||||
// Preset mappings (combined Anthropic + OpenAI)
|
||||
@@ -610,48 +610,46 @@ const presetMappings = [
|
||||
label: 'Sonnet 4',
|
||||
from: 'claude-sonnet-4-20250514',
|
||||
to: 'claude-sonnet-4-20250514',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet 4.5',
|
||||
from: 'claude-sonnet-4-5-20250929',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400',
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus 4.5',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-opus-4-5-20251101',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus->Sonnet',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color:
|
||||
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2',
|
||||
from: 'gpt-5.2-2025-12-11',
|
||||
to: 'gpt-5.2-2025-12-11',
|
||||
color:
|
||||
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
|
||||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2 Codex',
|
||||
from: 'gpt-5.2-codex',
|
||||
to: 'gpt-5.2-codex',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Max->Codex',
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex',
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400',
|
||||
},
|
||||
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
|
||||
}
|
||||
]
|
||||
|
||||
// Common HTTP error codes
|
||||
@@ -662,12 +660,12 @@ const commonErrorCodes = [
|
||||
{ value: 500, label: 'Server Error' },
|
||||
{ value: 502, label: 'Bad Gateway' },
|
||||
{ value: 503, label: 'Unavailable' },
|
||||
{ value: 529, label: 'Overloaded' },
|
||||
{ value: 529, label: 'Overloaded' }
|
||||
]
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
// Model mapping helpers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,9 @@
|
||||
<template>
|
||||
<Modal
|
||||
:show="show"
|
||||
:title="t('admin.accounts.editAccount')"
|
||||
size="lg"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose">
|
||||
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<div>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<!-- API Key fields (only for apikey type) -->
|
||||
@@ -24,7 +14,13 @@
|
||||
v-model="editBaseUrl"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="account.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
|
||||
:placeholder="
|
||||
account.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: account.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
|
||||
</div>
|
||||
@@ -34,17 +30,23 @@
|
||||
v-model="editApiKey"
|
||||
type="password"
|
||||
class="input font-mono"
|
||||
:placeholder="account.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
|
||||
:placeholder="
|
||||
account.platform === 'openai'
|
||||
? 'sk-proj-...'
|
||||
: account.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Restriction Section -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<!-- Model Restriction Section (不适用于 Gemini) -->
|
||||
<div v-if="account.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
|
||||
<!-- Mode Toggle -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="modelRestrictionMode = 'whitelist'"
|
||||
@@ -55,8 +57,18 @@
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" 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
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
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>
|
||||
{{ t('admin.accounts.modelWhitelist') }}
|
||||
</button>
|
||||
@@ -70,8 +82,18 @@
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
<svg
|
||||
class="mr-1.5 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.modelMapping') }}
|
||||
</button>
|
||||
@@ -79,22 +101,36 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.selectAllowedModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Checkbox List -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<div class="mb-3 grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="model in commonModels"
|
||||
:key="model.value"
|
||||
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
|
||||
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
|
||||
:class="
|
||||
allowedModels.includes(model.value)
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -108,23 +144,35 @@
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
t('admin.accounts.supportsAllModels')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Mode -->
|
||||
<div v-else>
|
||||
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
|
||||
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
|
||||
<p class="text-xs text-purple-700 dark:text-purple-400">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.mapRequestModels') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model Mapping List -->
|
||||
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
@@ -136,8 +184,18 @@
|
||||
class="input flex-1"
|
||||
:placeholder="t('admin.accounts.requestModel')"
|
||||
/>
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="mapping.to"
|
||||
@@ -148,10 +206,15 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="removeModelMapping(index)"
|
||||
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="w-4 h-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 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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,10 +223,20 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="addModelMapping"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
|
||||
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<svg
|
||||
class="mr-1 inline 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 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.addMapping') }}
|
||||
</button>
|
||||
@@ -175,10 +248,7 @@
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addPresetMapping(preset.from, preset.to)"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1 text-xs transition-colors',
|
||||
preset.color
|
||||
]"
|
||||
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
@@ -187,11 +257,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Custom Error Codes Section -->
|
||||
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.customErrorCodesHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -211,10 +283,20 @@
|
||||
</div>
|
||||
|
||||
<div v-if="customErrorCodesEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-3">
|
||||
<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">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<svg
|
||||
class="mr-1 inline 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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||
</p>
|
||||
@@ -230,7 +312,7 @@
|
||||
:class="[
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
selectedErrorCodes.includes(code.value)
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
|
||||
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||
]"
|
||||
>
|
||||
@@ -249,13 +331,14 @@
|
||||
:placeholder="t('admin.accounts.enterErrorCode')"
|
||||
@keyup.enter="addCustomErrorCode"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="addCustomErrorCode"
|
||||
class="btn btn-secondary px-3"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
|
||||
<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 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -265,7 +348,7 @@
|
||||
<span
|
||||
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
|
||||
:key="code"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ code }}
|
||||
<button
|
||||
@@ -273,8 +356,13 @@
|
||||
@click="removeErrorCode(code)"
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
@@ -284,14 +372,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini 模型说明 -->
|
||||
<div v-if="account.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
{{ t('admin.accounts.gemini.modelPassthrough') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div v-if="account?.platform === 'anthropic'" class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
|
||||
<label class="input-label mb-0">{{
|
||||
t('admin.accounts.interceptWarmupRequests')
|
||||
}}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -313,69 +437,52 @@
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||
<ProxySelector
|
||||
v-model="form.proxy_id"
|
||||
:proxies="proxies"
|
||||
/>
|
||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
|
||||
<input
|
||||
v-model.number="form.concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
/>
|
||||
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
/>
|
||||
<input v-model.number="form.priority" type="number" min="1" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.status') }}</label>
|
||||
<Select
|
||||
v-model="form.status"
|
||||
:options="statusOptions"
|
||||
/>
|
||||
<Select v-model="form.status" :options="statusOptions" />
|
||||
</div>
|
||||
|
||||
<!-- Group Selection -->
|
||||
<GroupSelector
|
||||
v-model="form.group_ids"
|
||||
:groups="groups"
|
||||
:platform="account?.platform"
|
||||
/>
|
||||
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="handleClose"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<button @click="handleClose" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
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>
|
||||
<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.accounts.updating') : t('common.update') }}
|
||||
</button>
|
||||
@@ -452,39 +559,150 @@ const openaiModels = [
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
|
||||
]
|
||||
|
||||
// Common models for whitelist - Gemini
|
||||
const geminiModels = [
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
|
||||
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
|
||||
]
|
||||
|
||||
// Computed: current models based on platform
|
||||
const commonModels = computed(() => {
|
||||
return props.account?.platform === 'openai' ? openaiModels : anthropicModels
|
||||
if (props.account?.platform === 'openai') return openaiModels
|
||||
if (props.account?.platform === 'gemini') return geminiModels
|
||||
return anthropicModels
|
||||
})
|
||||
|
||||
// Preset mappings for quick add - Anthropic
|
||||
const anthropicPresetMappings = [
|
||||
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
{
|
||||
label: 'Sonnet 4',
|
||||
from: 'claude-sonnet-4-20250514',
|
||||
to: 'claude-sonnet-4-20250514',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Sonnet 4.5',
|
||||
from: 'claude-sonnet-4-5-20250929',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus 4.5',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-opus-4-5-20251101',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Haiku 3.5',
|
||||
from: 'claude-3-5-haiku-20241022',
|
||||
to: 'claude-3-5-haiku-20241022',
|
||||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||
},
|
||||
{
|
||||
label: 'Haiku 4.5',
|
||||
from: 'claude-haiku-4-5-20251001',
|
||||
to: 'claude-haiku-4-5-20251001',
|
||||
color:
|
||||
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
},
|
||||
{
|
||||
label: 'Opus->Sonnet',
|
||||
from: 'claude-opus-4-5-20251101',
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}
|
||||
]
|
||||
|
||||
// Preset mappings for quick add - OpenAI
|
||||
const openaiPresetMappings = [
|
||||
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: 'Codex Max', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex-max', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: 'Codex Mini', from: 'gpt-5.1-codex-mini', to: 'gpt-5.1-codex-mini', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
|
||||
{
|
||||
label: 'GPT-5.2',
|
||||
from: 'gpt-5.2-2025-12-11',
|
||||
to: 'gpt-5.2-2025-12-11',
|
||||
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.2 Codex',
|
||||
from: 'gpt-5.2-codex',
|
||||
to: 'gpt-5.2-codex',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'GPT-5.1 Codex',
|
||||
from: 'gpt-5.1-codex',
|
||||
to: 'gpt-5.1-codex',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
},
|
||||
{
|
||||
label: 'Codex Max',
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex-max',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: 'Codex Mini',
|
||||
from: 'gpt-5.1-codex-mini',
|
||||
to: 'gpt-5.1-codex-mini',
|
||||
color:
|
||||
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
},
|
||||
{
|
||||
label: 'Max->Codex',
|
||||
from: 'gpt-5.1-codex-max',
|
||||
to: 'gpt-5.1-codex',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}
|
||||
]
|
||||
|
||||
// Preset mappings for quick add - Gemini
|
||||
const geminiPresetMappings = [
|
||||
{
|
||||
label: 'Flash',
|
||||
from: 'gemini-2.0-flash',
|
||||
to: 'gemini-2.0-flash',
|
||||
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
label: 'Flash Lite',
|
||||
from: 'gemini-2.0-flash-lite',
|
||||
to: 'gemini-2.0-flash-lite',
|
||||
color:
|
||||
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||
},
|
||||
{
|
||||
label: '1.5 Pro',
|
||||
from: 'gemini-1.5-pro',
|
||||
to: 'gemini-1.5-pro',
|
||||
color:
|
||||
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
label: '1.5 Flash',
|
||||
from: 'gemini-1.5-flash',
|
||||
to: 'gemini-1.5-flash',
|
||||
color:
|
||||
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
}
|
||||
]
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => {
|
||||
return props.account?.platform === 'openai' ? openaiPresetMappings : anthropicPresetMappings
|
||||
if (props.account?.platform === 'openai') return openaiPresetMappings
|
||||
if (props.account?.platform === 'gemini') return geminiPresetMappings
|
||||
return anthropicPresetMappings
|
||||
})
|
||||
|
||||
// Computed: default base URL based on platform
|
||||
const defaultBaseUrl = computed(() => {
|
||||
return props.account?.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
return 'https://api.anthropic.com'
|
||||
})
|
||||
|
||||
// Common HTTP error codes for quick selection
|
||||
@@ -513,71 +731,85 @@ const statusOptions = computed(() => [
|
||||
])
|
||||
|
||||
// Watchers
|
||||
watch(() => props.account, (newAccount) => {
|
||||
if (newAccount) {
|
||||
form.name = newAccount.name
|
||||
form.proxy_id = newAccount.proxy_id
|
||||
form.concurrency = newAccount.concurrency
|
||||
form.priority = newAccount.priority
|
||||
form.status = newAccount.status as 'active' | 'inactive'
|
||||
form.group_ids = newAccount.group_ids || []
|
||||
watch(
|
||||
() => props.account,
|
||||
(newAccount) => {
|
||||
if (newAccount) {
|
||||
form.name = newAccount.name
|
||||
form.proxy_id = newAccount.proxy_id
|
||||
form.concurrency = newAccount.concurrency
|
||||
form.priority = newAccount.priority
|
||||
form.status = newAccount.status as 'active' | 'inactive'
|
||||
form.group_ids = newAccount.group_ids || []
|
||||
|
||||
// Load intercept warmup requests setting (applies to all account types)
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||
// Load intercept warmup requests setting (applies to all account types)
|
||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
editBaseUrl.value = credentials.base_url as string || platformDefaultUrl
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||||
|
||||
// Load model mappings and detect mode
|
||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
||||
if (existingMappings && typeof existingMappings === 'object') {
|
||||
const entries = Object.entries(existingMappings)
|
||||
// Load model mappings and detect mode
|
||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
||||
if (existingMappings && typeof existingMappings === 'object') {
|
||||
const entries = Object.entries(existingMappings)
|
||||
|
||||
// Detect if this is whitelist mode (all from === to) or mapping mode
|
||||
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
|
||||
// Detect if this is whitelist mode (all from === to) or mapping mode
|
||||
const isWhitelistMode = entries.length > 0 && entries.every(([from, to]) => from === to)
|
||||
|
||||
if (isWhitelistMode) {
|
||||
// Whitelist mode: populate allowedModels
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = entries.map(([from]) => from)
|
||||
modelMappings.value = []
|
||||
if (isWhitelistMode) {
|
||||
// Whitelist mode: populate allowedModels
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = entries.map(([from]) => from)
|
||||
modelMappings.value = []
|
||||
} else {
|
||||
// Mapping mode: populate modelMappings
|
||||
modelRestrictionMode.value = 'mapping'
|
||||
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
|
||||
allowedModels.value = []
|
||||
}
|
||||
} else {
|
||||
// Mapping mode: populate modelMappings
|
||||
modelRestrictionMode.value = 'mapping'
|
||||
modelMappings.value = entries.map(([from, to]) => ({ from, to }))
|
||||
// No mappings: default to whitelist mode with empty selection (allow all)
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
}
|
||||
|
||||
// Load custom error codes
|
||||
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
|
||||
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
|
||||
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
|
||||
selectedErrorCodes.value = [...existingErrorCodes]
|
||||
} else {
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
} else {
|
||||
// No mappings: default to whitelist mode with empty selection (allow all)
|
||||
const platformDefaultUrl =
|
||||
newAccount.platform === 'openai'
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = platformDefaultUrl
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
}
|
||||
|
||||
// Load custom error codes
|
||||
customErrorCodesEnabled.value = credentials.custom_error_codes_enabled === true
|
||||
const existingErrorCodes = credentials.custom_error_codes as number[] | undefined
|
||||
if (existingErrorCodes && Array.isArray(existingErrorCodes)) {
|
||||
selectedErrorCodes.value = [...existingErrorCodes]
|
||||
} else {
|
||||
customErrorCodesEnabled.value = false
|
||||
selectedErrorCodes.value = []
|
||||
}
|
||||
} else {
|
||||
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
|
||||
editBaseUrl.value = platformDefaultUrl
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
modelMappings.value = []
|
||||
allowedModels.value = []
|
||||
customErrorCodesEnabled.value = false
|
||||
selectedErrorCodes.value = []
|
||||
editApiKey.value = ''
|
||||
}
|
||||
editApiKey.value = ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
@@ -589,7 +821,7 @@ const removeModelMapping = (index: number) => {
|
||||
}
|
||||
|
||||
const addPresetMapping = (from: string, to: string) => {
|
||||
const exists = modelMappings.value.some(m => m.from === from)
|
||||
const exists = modelMappings.value.some((m) => m.from === from)
|
||||
if (exists) {
|
||||
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
|
||||
return
|
||||
@@ -666,7 +898,7 @@ const handleSubmit = async () => {
|
||||
|
||||
// For apikey type, handle credentials update
|
||||
if (props.account.type === 'apikey') {
|
||||
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
|
||||
const modelMapping = buildModelMappingObject()
|
||||
|
||||
@@ -707,7 +939,7 @@ const handleSubmit = async () => {
|
||||
updatePayload.credentials = newCredentials
|
||||
} else {
|
||||
// For oauth/setup-token types, only update intercept_warmup_requests if changed
|
||||
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
|
||||
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
|
||||
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||
|
||||
if (interceptWarmupRequests.value) {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30 p-6">
|
||||
<div
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30"
|
||||
>
|
||||
<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">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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
|
||||
class="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -22,7 +34,9 @@
|
||||
value="manual"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.manualAuth') }}</span>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t('admin.accounts.oauth.manualAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
@@ -31,23 +45,39 @@
|
||||
value="cookie"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.cookieAutoAuth') }}</span>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t('admin.accounts.oauth.cookieAutoAuth')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie Auto-Auth Form -->
|
||||
<div v-if="inputMethod === 'cookie'" class="space-y-4">
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
|
||||
</p>
|
||||
|
||||
<!-- sessionKey Input -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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" />
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
{{ t('admin.accounts.oauth.sessionKey') }}
|
||||
<span
|
||||
@@ -62,16 +92,30 @@
|
||||
class="text-blue-500 hover:text-blue-600"
|
||||
@click="showHelpDialog = !showHelpDialog"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sessionKeyInput"
|
||||
rows="3"
|
||||
class="input w-full font-mono text-sm resize-y"
|
||||
:placeholder="allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="
|
||||
allowMultiple
|
||||
? t('admin.accounts.oauth.sessionKeyPlaceholder')
|
||||
: t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
|
||||
"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="parsedKeyCount > 1 && allowMultiple"
|
||||
@@ -84,12 +128,14 @@
|
||||
<!-- Help Section -->
|
||||
<div
|
||||
v-if="showHelpDialog && showHelp"
|
||||
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30 p-3"
|
||||
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||
{{ t('admin.accounts.oauth.howToGetSessionKey') }}
|
||||
</h5>
|
||||
<ol class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
<ol
|
||||
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<li v-html="t('admin.accounts.oauth.step1')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step2')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step3')"></li>
|
||||
@@ -97,15 +143,18 @@
|
||||
<li v-html="t('admin.accounts.oauth.step5')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step6')"></li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400" v-html="t('admin.accounts.oauth.sessionKeyFormat')"></p>
|
||||
<p
|
||||
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
|
||||
v-html="t('admin.accounts.oauth.sessionKeyFormat')"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,14 +166,45 @@
|
||||
:disabled="loading || !sessionKeyInput.trim()"
|
||||
@click="handleCookieAuth"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" 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
|
||||
v-if="loading"
|
||||
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>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<svg
|
||||
v-else
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
{{ loading ? t('admin.accounts.oauth.authorizing') : t('admin.accounts.oauth.startAutoAuth') }}
|
||||
{{
|
||||
loading
|
||||
? t('admin.accounts.oauth.authorizing')
|
||||
: t('admin.accounts.oauth.startAutoAuth')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,15 +216,44 @@
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Generate Auth URL -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ oauthStep1GenerateUrl }}
|
||||
</p>
|
||||
<div v-if="showProjectId && platform === 'gemini'" class="mb-3">
|
||||
<label class="input-label flex items-center gap-2">
|
||||
{{ t('admin.accounts.oauth.gemini.projectIdLabel') }}
|
||||
<a
|
||||
href="https://console.cloud.google.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-xs font-normal text-blue-500 hover:text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.gemini.howToGetProjectId') }}
|
||||
</a>
|
||||
</label>
|
||||
<input
|
||||
v-model="projectId"
|
||||
type="text"
|
||||
class="input w-full font-mono text-sm"
|
||||
:placeholder="t('admin.accounts.oauth.gemini.projectIdPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.gemini.projectIdHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!authUrl"
|
||||
type="button"
|
||||
@@ -152,12 +261,39 @@
|
||||
class="btn btn-primary text-sm"
|
||||
@click="handleGenerateUrl"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" 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
|
||||
v-if="loading"
|
||||
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>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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
|
||||
v-else
|
||||
class="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
|
||||
</button>
|
||||
@@ -167,7 +303,7 @@
|
||||
:value="authUrl"
|
||||
readonly
|
||||
type="text"
|
||||
class="input flex-1 bg-gray-50 dark:bg-gray-700 font-mono text-xs"
|
||||
class="input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -175,11 +311,33 @@
|
||||
title="Copy URL"
|
||||
@click="handleCopyUrl"
|
||||
>
|
||||
<svg v-if="!copied" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
<svg
|
||||
v-if="!copied"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -188,8 +346,18 @@
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg
|
||||
class="mr-1 inline h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.oauth.regenerate') }}
|
||||
</button>
|
||||
@@ -199,9 +367,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Open URL and authorize -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
@@ -212,58 +384,120 @@
|
||||
{{ oauthOpenUrlDesc }}
|
||||
</p>
|
||||
<!-- OpenAI Important Notice -->
|
||||
<div v-if="isOpenAI" class="mt-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 p-3">
|
||||
<p class="text-xs text-amber-800 dark:text-amber-300" v-html="oauthImportantNotice">
|
||||
</p>
|
||||
<div
|
||||
v-if="isOpenAI"
|
||||
class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||
>
|
||||
<p
|
||||
class="text-xs text-amber-800 dark:text-amber-300"
|
||||
v-html="oauthImportantNotice"
|
||||
></p>
|
||||
</div>
|
||||
<!-- Proxy Warning (for non-OpenAI) -->
|
||||
<div v-else-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
|
||||
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
|
||||
</p>
|
||||
<div
|
||||
v-else-if="showProxyWarning"
|
||||
class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||
>
|
||||
<p
|
||||
class="text-xs text-yellow-800 dark:text-yellow-300"
|
||||
v-html="t('admin.accounts.oauth.proxyWarning')"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Enter authorization code -->
|
||||
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
||||
<div
|
||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||
{{ oauthStep3EnterCode }}
|
||||
</p>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="oauthAuthCodeDesc">
|
||||
</p>
|
||||
<p
|
||||
class="mb-3 text-sm text-blue-700 dark:text-blue-300"
|
||||
v-html="oauthAuthCodeDesc"
|
||||
></p>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
<svg class="w-4 h-4 inline mr-1 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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
|
||||
class="mr-1 inline h-4 w-4 text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
{{ oauthAuthCode }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="authCodeInput"
|
||||
rows="3"
|
||||
class="input w-full font-mono text-sm resize-none"
|
||||
class="input w-full resize-none font-mono text-sm"
|
||||
:placeholder="oauthAuthCodePlaceholder"
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-3 h-3 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
<svg
|
||||
class="mr-1 inline h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
{{ oauthAuthCodeHint }}
|
||||
</p>
|
||||
|
||||
<!-- Gemini-specific state parameter warning -->
|
||||
<div
|
||||
v-if="platform === 'gemini'"
|
||||
class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-amber-800 dark:text-amber-300">
|
||||
<p class="font-semibold">{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}</p>
|
||||
<p class="mt-1">{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
|
||||
class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -293,7 +527,8 @@ interface Props {
|
||||
allowMultiple?: boolean
|
||||
methodLabel?: string
|
||||
showCookieOption?: boolean // Whether to show cookie auto-auth option
|
||||
platform?: 'anthropic' | 'openai' // Platform type for different UI/text
|
||||
platform?: 'anthropic' | 'openai' | 'gemini' // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -306,7 +541,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
allowMultiple: false,
|
||||
methodLabel: 'Authorization Method',
|
||||
showCookieOption: true,
|
||||
platform: 'anthropic'
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -318,16 +554,12 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Platform-specific translation helpers
|
||||
const isOpenAI = computed(() => props.platform === 'openai')
|
||||
|
||||
// Get translation key based on platform
|
||||
const getOAuthKey = (key: string) => {
|
||||
if (isOpenAI.value) {
|
||||
// Try OpenAI-specific key first
|
||||
const openaiKey = `admin.accounts.oauth.openai.${key}`
|
||||
return openaiKey
|
||||
}
|
||||
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
|
||||
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
|
||||
return `admin.accounts.oauth.${key}`
|
||||
}
|
||||
|
||||
@@ -343,20 +575,27 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
|
||||
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
|
||||
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
|
||||
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
|
||||
const oauthImportantNotice = computed(() => isOpenAI.value ? t('admin.accounts.oauth.openai.importantNotice') : '')
|
||||
const oauthImportantNotice = computed(() =>
|
||||
props.platform === 'openai' ? t('admin.accounts.oauth.openai.importantNotice') : ''
|
||||
)
|
||||
|
||||
// Local state
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const showHelpDialog = ref(false)
|
||||
const oauthState = ref('')
|
||||
const projectId = ref('')
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
// Computed
|
||||
const parsedKeyCount = computed(() => {
|
||||
return sessionKeyInput.value.split('\n').map(k => k.trim()).filter(k => k).length
|
||||
return sessionKeyInput.value
|
||||
.split('\n')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
@@ -367,7 +606,7 @@ watch(inputMethod, (newVal) => {
|
||||
// Auto-extract code from OpenAI callback URL
|
||||
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
|
||||
watch(authCodeInput, (newVal) => {
|
||||
if (!isOpenAI.value) return
|
||||
if (props.platform !== 'openai' && props.platform !== 'gemini') return
|
||||
|
||||
const trimmed = newVal.trim()
|
||||
// Check if it looks like a URL with code parameter
|
||||
@@ -376,6 +615,10 @@ watch(authCodeInput, (newVal) => {
|
||||
// Try to parse as URL
|
||||
const url = new URL(trimmed)
|
||||
const code = url.searchParams.get('code')
|
||||
const stateParam = url.searchParams.get('state')
|
||||
if (props.platform === 'gemini' && stateParam) {
|
||||
oauthState.value = stateParam
|
||||
}
|
||||
if (code && code !== trimmed) {
|
||||
// Replace the input with just the code
|
||||
authCodeInput.value = code
|
||||
@@ -383,6 +626,10 @@ watch(authCodeInput, (newVal) => {
|
||||
} catch {
|
||||
// If URL parsing fails, try regex extraction
|
||||
const match = trimmed.match(/[?&]code=([^&]+)/)
|
||||
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
|
||||
if (props.platform === 'gemini' && stateMatch && stateMatch[1]) {
|
||||
oauthState.value = stateMatch[1]
|
||||
}
|
||||
if (match && match[1] && match[1] !== trimmed) {
|
||||
authCodeInput.value = match[1]
|
||||
}
|
||||
@@ -415,10 +662,14 @@ const handleCookieAuth = () => {
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
oauthState,
|
||||
projectId,
|
||||
sessionKey: sessionKeyInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
oauthState.value = ''
|
||||
projectId.value = ''
|
||||
sessionKeyInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
showHelpDialog.value = false
|
||||
|
||||
@@ -7,29 +7,55 @@
|
||||
>
|
||||
<div v-if="account" class="space-y-5">
|
||||
<!-- Account Info -->
|
||||
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
|
||||
]">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
: 'from-orange-500 to-orange-600'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{
|
||||
account.name
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ isOpenAI ? t('admin.accounts.openaiAccount') : t('admin.accounts.claudeCodeAccount') }}
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: t('admin.accounts.claudeCodeAccount')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method Selection (Claude only) -->
|
||||
<div v-if="!isOpenAI">
|
||||
<div v-if="isAnthropic">
|
||||
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<div class="mt-2 flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="addMethod"
|
||||
@@ -46,12 +72,119 @@
|
||||
value="setup-token"
|
||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.setupTokenLongLived') }}</span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('admin.accounts.setupTokenLongLived')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Authorization Section -->
|
||||
<!-- Gemini OAuth Type Selection -->
|
||||
<div v-if="isGemini">
|
||||
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSelectGeminiOAuthType('code_assist')"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
geminiOAuthType === 'code_assist'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
geminiOAuthType === 'code_assist'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
|
||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
|
||||
t('admin.accounts.oauth.gemini.needsProjectId')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.oauth.gemini.needsProjectIdDesc')
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!geminiAIStudioOAuthEnabled"
|
||||
@click="handleSelectGeminiOAuthType('ai_studio')"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
|
||||
geminiOAuthType === 'ai_studio'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
geminiOAuthType === 'ai_studio'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
|
||||
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
|
||||
}}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
|
||||
}}</span>
|
||||
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
|
||||
<span
|
||||
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
|
||||
</span>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthAuthorizationFlow
|
||||
ref="oauthFlowRef"
|
||||
:add-method="addMethod"
|
||||
@@ -59,22 +192,19 @@
|
||||
:session-id="currentSessionId"
|
||||
:loading="currentLoading"
|
||||
:error="currentError"
|
||||
:show-help="!isOpenAI"
|
||||
:show-proxy-warning="!isOpenAI"
|
||||
:show-cookie-option="!isOpenAI"
|
||||
:show-help="isAnthropic"
|
||||
:show-proxy-warning="isAnthropic"
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : 'anthropic'"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
|
||||
:show-project-id="isGemini && 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="handleClose"
|
||||
>
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@@ -86,14 +216,29 @@
|
||||
>
|
||||
<svg
|
||||
v-if="currentLoading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
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>
|
||||
<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>
|
||||
{{ currentLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
|
||||
{{
|
||||
currentLoading
|
||||
? t('admin.accounts.oauth.verifying')
|
||||
: t('admin.accounts.oauth.completeAuth')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,8 +250,13 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAccountOAuth, type AddMethod, type AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
import {
|
||||
useAccountOAuth,
|
||||
type AddMethod,
|
||||
type AuthInputMethod
|
||||
} from '@/composables/useAccountOAuth'
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import type { Account } from '@/types'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
@@ -115,6 +265,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
|
||||
interface OAuthFlowExposed {
|
||||
authCode: string
|
||||
oauthState: string
|
||||
projectId: string
|
||||
sessionKey: string
|
||||
inputMethod: AuthInputMethod
|
||||
reset: () => void
|
||||
@@ -137,55 +289,113 @@ const { t } = useI18n()
|
||||
// OAuth composables - use both Claude and OpenAI
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
|
||||
// State
|
||||
const addMethod = ref<AddMethod>('oauth')
|
||||
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
|
||||
// Computed - check if this is an OpenAI account
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value)
|
||||
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value)
|
||||
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value)
|
||||
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.value)
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
})
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI always uses manual input (no cookie auth option)
|
||||
return isOpenAI.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
return isOpenAI.value || isGemini.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value
|
||||
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value
|
||||
const sessionId = isOpenAI.value
|
||||
? openaiOAuth.sessionId.value
|
||||
: isGemini.value
|
||||
? geminiOAuth.sessionId.value
|
||||
: claudeOAuth.sessionId.value
|
||||
const loading = isOpenAI.value
|
||||
? openaiOAuth.loading.value
|
||||
: isGemini.value
|
||||
? geminiOAuth.loading.value
|
||||
: claudeOAuth.loading.value
|
||||
return authCode.trim() && sessionId && !loading
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
// Initialize addMethod based on current account type (Claude only)
|
||||
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
|
||||
addMethod.value = props.account.type as AddMethod
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal && props.account) {
|
||||
// Initialize addMethod based on current account type (Claude only)
|
||||
if (
|
||||
isAnthropic.value &&
|
||||
(props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
) {
|
||||
addMethod.value = props.account.type as AddMethod
|
||||
}
|
||||
if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
geminiOAuthType.value = creds.oauth_type === 'ai_studio' ? 'ai_studio' : 'code_assist'
|
||||
}
|
||||
if (isGemini.value) {
|
||||
geminiOAuth.getCapabilities().then((caps) => {
|
||||
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
|
||||
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Methods
|
||||
const resetState = () => {
|
||||
addMethod.value = 'oauth'
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
geminiAIStudioOAuthEnabled.value = false
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => {
|
||||
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
|
||||
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
|
||||
return
|
||||
}
|
||||
geminiOAuthType.value = oauthType
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
@@ -195,6 +405,9 @@ const handleGenerateUrl = async () => {
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
|
||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
|
||||
} else {
|
||||
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
}
|
||||
@@ -211,7 +424,11 @@ const handleExchangeCode = async () => {
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(authCode.trim(), sessionId, props.account.proxy_id)
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
@@ -236,6 +453,38 @@ const handleExchangeCode = async () => {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || geminiOAuth.state.value
|
||||
if (!stateToUse) return
|
||||
|
||||
const tokenInfo = await geminiOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId,
|
||||
state: stateToUse,
|
||||
proxyId: props.account.proxy_id,
|
||||
oauthType: geminiOAuthType.value
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = geminiOAuth.buildCredentials(tokenInfo)
|
||||
|
||||
try {
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
}
|
||||
} else {
|
||||
// Claude OAuth flow
|
||||
const sessionId = claudeOAuth.sessionId.value
|
||||
@@ -246,9 +495,10 @@ const handleExchangeCode = async () => {
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint = addMethod.value === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
const endpoint =
|
||||
addMethod.value === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: sessionId,
|
||||
@@ -288,9 +538,10 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint = addMethod.value === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
: '/admin/accounts/setup-token-cookie-auth'
|
||||
const endpoint =
|
||||
addMethod.value === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
: '/admin/accounts/setup-token-cookie-auth'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: '',
|
||||
@@ -314,7 +565,8 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
claudeOAuth.error.value =
|
||||
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
} finally {
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
<div class="text-sm text-gray-600 dark:text-dark-300">
|
||||
{{ t('admin.accounts.syncFromCrsDesc') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3">
|
||||
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
|
||||
>
|
||||
已有账号仅同步 CRS
|
||||
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
|
||||
</div>
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.accounts.crsVersionRequirement') }}
|
||||
</div>
|
||||
|
||||
@@ -31,12 +36,7 @@
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
class="input"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<input v-model="form.username" type="text" class="input" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
|
||||
@@ -50,12 +50,19 @@
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
|
||||
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" />
|
||||
<input
|
||||
v-model="form.sync_proxies"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 dark:border-dark-600"
|
||||
/>
|
||||
{{ t('admin.accounts.syncProxies') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
|
||||
<div
|
||||
v-if="result"
|
||||
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.syncResult') }}
|
||||
</div>
|
||||
@@ -67,9 +74,12 @@
|
||||
<div class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('admin.accounts.syncErrors') }}
|
||||
</div>
|
||||
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono">
|
||||
<div
|
||||
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
|
||||
>
|
||||
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
|
||||
{{ item.kind }} {{ item.crs_account_id }} — {{ item.action }}{{ item.error ? `: ${item.error}` : '' }}
|
||||
{{ item.kind }} {{ item.crs_account_id }} — {{ item.action
|
||||
}}{{ item.error ? `: ${item.error}` : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
|
||||
<div v-if="windowStats" class="flex items-center justify-between mb-0.5" :title="`5h 窗口用量统计`">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help">
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
v-if="windowStats"
|
||||
class="mb-0.5 flex items-center justify-between"
|
||||
:title="`5h 窗口用量统计`"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatRequests }} req
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
|
||||
{{ formatTokens }}
|
||||
</span>
|
||||
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
|
||||
${{ formatCost }}
|
||||
</span>
|
||||
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,16 +23,13 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Label badge (fixed width for alignment) -->
|
||||
<span
|
||||
:class="[
|
||||
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
|
||||
labelClass
|
||||
]"
|
||||
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<!-- Progress bar container -->
|
||||
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
|
||||
<div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
:class="['h-full transition-all duration-300', barClass]"
|
||||
:style="{ width: barWidth }"
|
||||
@@ -36,12 +37,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Percentage -->
|
||||
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
|
||||
<span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
|
||||
{{ displayPercent }}
|
||||
</span>
|
||||
|
||||
<!-- Reset time -->
|
||||
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
|
||||
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
|
||||
{{ formatResetTime }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -54,7 +55,7 @@ import type { WindowStats } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
utilization: number // Percentage (0-100+)
|
||||
utilization: number // Percentage (0-100+)
|
||||
resetsAt?: string | null
|
||||
color: 'indigo' | 'emerald' | 'purple'
|
||||
windowStats?: WindowStats | null
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
||||
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.modelDistribution') }}
|
||||
</h3>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
|
||||
<div class="w-48 h-48">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut :data="chartData" :options="doughnutOptions" />
|
||||
</div>
|
||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
||||
<th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
<tr
|
||||
v-for="model in modelStats"
|
||||
:key="model.model"
|
||||
class="border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<td
|
||||
class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white"
|
||||
:title="model.model"
|
||||
>
|
||||
{{ model.model }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(model.requests) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">
|
||||
{{ formatTokens(model.total_tokens) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
|
||||
${{ formatCost(model.actual_cost) }}
|
||||
</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
|
||||
${{ formatCost(model.cost) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,12 +62,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import type { ModelStat } from '@/types'
|
||||
@@ -60,20 +77,30 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const chartColors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#14b8a6',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.modelStats?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.modelStats.map(m => m.model),
|
||||
datasets: [{
|
||||
data: props.modelStats.map(m => m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
labels: props.modelStats.map((m) => m.model),
|
||||
datasets: [
|
||||
{
|
||||
data: props.modelStats.map((m) => m.total_tokens),
|
||||
backgroundColor: chartColors.slice(0, props.modelStats.length),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
||||
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dashboard.tokenUsageTrend') }}
|
||||
</h3>
|
||||
<div v-if="loading" class="flex h-48 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="trendData.length > 0 && chartData" class="h-48">
|
||||
<Line :data="chartData" :options="lineOptions" />
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
input: '#3b82f6',
|
||||
output: '#10b981',
|
||||
cache: '#f59e0b',
|
||||
cache: '#f59e0b'
|
||||
}))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.trendData?.length) return null
|
||||
|
||||
return {
|
||||
labels: props.trendData.map(d => d.date),
|
||||
labels: props.trendData.map((d) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Input',
|
||||
data: props.trendData.map(d => d.input_tokens),
|
||||
data: props.trendData.map((d) => d.input_tokens),
|
||||
borderColor: chartColors.value.input,
|
||||
backgroundColor: `${chartColors.value.input}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
data: props.trendData.map(d => d.output_tokens),
|
||||
data: props.trendData.map((d) => d.output_tokens),
|
||||
borderColor: chartColors.value.output,
|
||||
backgroundColor: `${chartColors.value.output}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: 'Cache',
|
||||
data: props.trendData.map(d => d.cache_tokens),
|
||||
data: props.trendData.map((d) => d.cache_tokens),
|
||||
borderColor: chartColors.value.cache,
|
||||
backgroundColor: `${chartColors.value.cache}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
tension: 0.3
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
mode: 'index' as const
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
|
||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => formatTokens(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
callback: (value: string | number) => formatTokens(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button
|
||||
@click="handleCancel"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500"
|
||||
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
@@ -17,7 +17,7 @@
|
||||
@click="handleConfirm"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
|
||||
'rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider"
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
|
||||
@click="column.sortable && handleSort(column.key)"
|
||||
>
|
||||
@@ -16,8 +16,8 @@
|
||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||
<svg
|
||||
v-if="sortKey === column.key"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'transform rotate-180': sortOrder === 'desc' }"
|
||||
class="h-4 w-4"
|
||||
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
@@ -37,22 +37,30 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-for="column in columns" :key="column.key" class="px-6 py-4 whitespace-nowrap">
|
||||
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div>
|
||||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500 dark:text-dark-400">
|
||||
<td
|
||||
:colspan="columns.length"
|
||||
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-400 dark:text-dark-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -60,18 +68,25 @@
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ t('empty.noData') }}</p>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('empty.noData') }}
|
||||
</p>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data rows -->
|
||||
<tr v-else v-for="(row, index) in sortedData" :key="index" class="hover:bg-gray-50 dark:hover:bg-dark-800">
|
||||
<tr
|
||||
v-else
|
||||
v-for="(row, index) in sortedData"
|
||||
:key="index"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||
>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
|
||||
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
|
||||
@@ -3,14 +3,21 @@
|
||||
<button
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:class="[
|
||||
'date-picker-trigger',
|
||||
isOpen && 'date-picker-trigger-open'
|
||||
]"
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
@@ -18,7 +25,7 @@
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -30,20 +37,14 @@
|
||||
</button>
|
||||
|
||||
<Transition name="date-picker-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<div v-if="isOpen" class="date-picker-dropdown">
|
||||
<!-- Quick presets -->
|
||||
<div class="date-picker-presets">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="selectPreset(preset)"
|
||||
:class="[
|
||||
'date-picker-preset',
|
||||
isPresetActive(preset) && 'date-picker-preset-active'
|
||||
]"
|
||||
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
|
||||
>
|
||||
{{ t(preset.labelKey) }}
|
||||
</button>
|
||||
@@ -64,8 +65,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3" />
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
@@ -83,10 +94,7 @@
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="date-picker-actions">
|
||||
<button
|
||||
@click="apply"
|
||||
class="date-picker-apply"
|
||||
>
|
||||
<button @click="apply" class="date-picker-apply">
|
||||
{{ t('dates.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (activePreset.value) {
|
||||
const preset = presets.find(p => p.value === activePreset.value)
|
||||
const preset = presets.find((p) => p.value === activePreset.value)
|
||||
if (preset) return t(preset.labelKey)
|
||||
}
|
||||
|
||||
@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
|
||||
}
|
||||
|
||||
// Sync local state with props
|
||||
watch(() => props.startDate, (val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
watch(
|
||||
() => props.startDate,
|
||||
(val) => {
|
||||
localStartDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
watch(() => props.endDate, (val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
})
|
||||
watch(
|
||||
() => props.endDate,
|
||||
(val) => {
|
||||
localEndDate.value = val
|
||||
onDateChange()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
@@ -301,18 +315,18 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.date-picker-trigger {
|
||||
@apply flex items-center gap-2;
|
||||
@apply px-3 py-2 rounded-lg text-sm;
|
||||
@apply rounded-lg px-3 py-2 text-sm;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply transition-all duration-200;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.date-picker-trigger-open {
|
||||
@apply ring-2 ring-primary-500/30 border-primary-500;
|
||||
@apply border-primary-500 ring-2 ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-icon {
|
||||
@@ -328,7 +342,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-dropdown {
|
||||
@apply absolute z-[100] mt-2 left-0;
|
||||
@apply absolute left-0 z-[100] mt-2;
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@@ -342,7 +356,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-preset {
|
||||
@apply px-3 py-1.5 text-xs font-medium rounded-md;
|
||||
@apply rounded-md px-3 py-1.5 text-xs font-medium;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
|
||||
@apply transition-colors duration-150;
|
||||
@@ -366,15 +380,15 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1;
|
||||
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
@apply w-full px-2 py-1.5 text-sm rounded-md;
|
||||
@apply w-full rounded-md px-2 py-1.5 text-sm;
|
||||
@apply bg-gray-50 dark:bg-dark-700;
|
||||
@apply border border-gray-200 dark:border-dark-600;
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
|
||||
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
||||
}
|
||||
|
||||
.date-picker-input::-webkit-calendar-picker-indicator {
|
||||
@@ -395,7 +409,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.date-picker-apply {
|
||||
@apply px-4 py-1.5 text-sm font-medium rounded-lg;
|
||||
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
|
||||
@apply bg-primary-600 text-white;
|
||||
@apply hover:bg-primary-700;
|
||||
@apply transition-colors duration-150;
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<!-- Icon -->
|
||||
<div class="w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center">
|
||||
<div
|
||||
class="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
|
||||
>
|
||||
<slot name="icon">
|
||||
<component
|
||||
v-if="icon"
|
||||
:is="icon"
|
||||
class="empty-state-icon w-10 h-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component v-if="icon" :is="icon" class="empty-state-icon h-10 w-10" aria-hidden="true" />
|
||||
<svg
|
||||
v-else
|
||||
class="empty-state-icon w-10 h-10"
|
||||
class="empty-state-icon h-10 w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -48,17 +45,13 @@
|
||||
>
|
||||
<svg
|
||||
v-if="actionIcon"
|
||||
class="w-5 h-5 mr-2"
|
||||
class="mr-2 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="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ actionText }}
|
||||
</component>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
badgeClass
|
||||
]"
|
||||
>
|
||||
@@ -10,10 +10,7 @@
|
||||
<!-- Group name -->
|
||||
<span class="truncate">{{ name }}</span>
|
||||
<!-- Right side label -->
|
||||
<span
|
||||
v-if="showLabel"
|
||||
:class="labelClass"
|
||||
>
|
||||
<span v-if="showLabel" :class="labelClass">
|
||||
{{ labelText }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -31,7 +28,7 @@ interface Props {
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
showRate?: boolean
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -97,6 +94,9 @@ const labelClass = computed(() => {
|
||||
if (props.platform === 'openai') {
|
||||
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
|
||||
}
|
||||
if (props.platform === 'gemini') {
|
||||
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
|
||||
}
|
||||
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
|
||||
})
|
||||
|
||||
@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
}
|
||||
if (props.platform === 'gemini') {
|
||||
return isSubscription.value
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||
}
|
||||
// Fallback: original colors
|
||||
return isSubscription.value
|
||||
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<div>
|
||||
<label class="input-label">
|
||||
Groups
|
||||
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span>
|
||||
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
|
||||
class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<label
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
|
||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
||||
>
|
||||
<input
|
||||
@@ -18,19 +18,19 @@
|
||||
:value="group.id"
|
||||
:checked="modelValue.includes(group.id)"
|
||||
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0"
|
||||
class="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||
/>
|
||||
<GroupBadge
|
||||
:name="group.name"
|
||||
:subscription-type="group.subscription_type"
|
||||
:rate-multiplier="group.rate_multiplier"
|
||||
class="flex-1 min-w-0"
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
|
||||
<span class="shrink-0 text-xs text-gray-400">{{ group.account_count || 0 }}</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
|
||||
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No groups available
|
||||
</div>
|
||||
@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
|
||||
if (!props.platform) {
|
||||
return props.groups
|
||||
}
|
||||
return props.groups.filter(g => g.platform === props.platform)
|
||||
return props.groups.filter((g) => g.platform === props.platform)
|
||||
})
|
||||
|
||||
const handleChange = (groupId: number, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...props.modelValue, groupId]
|
||||
: props.modelValue.filter(id => id !== groupId)
|
||||
: props.modelValue.filter((id) => id !== groupId)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
<span class="text-base">{{ currentLocale?.flag }}</span>
|
||||
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200"
|
||||
class="h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -22,20 +22,23 @@
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50"
|
||||
class="absolute right-0 z-50 mt-1 w-32 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
|
||||
locale.code === currentLocaleCode
|
||||
}"
|
||||
>
|
||||
<span class="text-base">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
<svg
|
||||
v-if="locale.code === currentLocaleCode"
|
||||
class="w-4 h-4 ml-auto text-primary-500"
|
||||
class="ml-auto h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -60,7 +63,7 @@ const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find(l => l.code === locale.value))
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
|
||||
@@ -9,24 +9,24 @@
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div
|
||||
:class="['modal-content', sizeClasses]"
|
||||
@click.stop
|
||||
>
|
||||
<div :class="['modal-content', sizeClasses]" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h3
|
||||
id="modal-title"
|
||||
class="modal-title"
|
||||
>
|
||||
<h3 id="modal-title" class="modal-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
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="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<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>
|
||||
@@ -38,10 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="modal-footer"
|
||||
>
|
||||
<div v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6">
|
||||
<div class="flex items-center justify-between flex-1 sm:hidden">
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-between sm:hidden">
|
||||
<!-- Mobile pagination -->
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
@@ -15,7 +17,7 @@
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('pagination.next') }}
|
||||
</button>
|
||||
@@ -36,8 +38,10 @@
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span>
|
||||
<div class="w-20 page-size-select">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>{{ t('pagination.perPage') }}:</span
|
||||
>
|
||||
<div class="page-size-select w-20">
|
||||
<Select
|
||||
:model-value="pageSize"
|
||||
:options="pageSizeSelectOptions"
|
||||
@@ -56,10 +60,10 @@
|
||||
<button
|
||||
@click="goToPage(page - 1)"
|
||||
:disabled="page === 1"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.previous')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
@@ -75,13 +79,15 @@
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
'relative inline-flex items-center px-4 py-2 text-sm font-medium border',
|
||||
'relative inline-flex items-center border px-4 py-2 text-sm font-medium',
|
||||
pageNum === page
|
||||
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600',
|
||||
? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600',
|
||||
typeof pageNum !== 'number' && 'cursor-default'
|
||||
]"
|
||||
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined"
|
||||
:aria-label="
|
||||
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined
|
||||
"
|
||||
:aria-current="pageNum === page ? 'page' : undefined"
|
||||
>
|
||||
{{ pageNum }}
|
||||
@@ -91,10 +97,10 @@
|
||||
<button
|
||||
@click="goToPage(page + 1)"
|
||||
:disabled="page === totalPages"
|
||||
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.next')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
@@ -145,7 +151,7 @@ const toItem = computed(() => {
|
||||
})
|
||||
|
||||
const pageSizeSelectOptions = computed(() => {
|
||||
return props.pageSizeOptions.map(size => ({
|
||||
return props.pageSizeOptions.map((size) => ({
|
||||
value: size,
|
||||
label: String(size)
|
||||
}))
|
||||
@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
|
||||
|
||||
<style scoped>
|
||||
.page-size-select :deep(.select-trigger) {
|
||||
@apply py-1.5 px-3 text-sm;
|
||||
@apply px-3 py-1.5 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user