Merge PR #37: Add Gemini OAuth and Messages Compat Support

This commit is contained in:
shaw
2025-12-26 22:42:34 +08:00
165 changed files with 18625 additions and 6327 deletions

3
.gitignore vendored
View File

@@ -21,6 +21,9 @@ coverage.html
# 依赖(使用 go mod
vendor/
# Go 编译缓存
backend/.gocache/
# ===================
# Node.js / Vue 前端
# ===================

View File

@@ -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()
}},

View File

@@ -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()
}},

View File

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

View File

@@ -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() {

View 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)
}

View File

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

View 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
}

View File

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

View File

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

View 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}
}

View 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"`
}

View 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)"
)

View 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"

View 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
}

View 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 == '='
}

View 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"`
}

View 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"
}
}

View 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
}

View 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()
}

View 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",
},
}
}

View File

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

View File

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

View File

@@ -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 服务器

View File

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

View 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()
}

View File

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

View File

@@ -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")
{

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
autoprefixer: {}
}
}

View File

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

View File

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

View File

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

View 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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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