diff --git a/admin/account_groups.go b/admin/account_groups.go new file mode 100644 index 00000000..aa3e4ea3 --- /dev/null +++ b/admin/account_groups.go @@ -0,0 +1,279 @@ +package admin + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/codex2api/database" + "github.com/gin-gonic/gin" +) + +const ( + maxAccountGroups = 64 + maxAccountGroupNameRuneSize = 80 +) + +type accountGroupResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + SortOrder int64 `json:"sort_order"` + MemberCount int64 `json:"member_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toAccountGroupResponse(g database.AccountGroup) accountGroupResponse { + return accountGroupResponse{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Color: g.Color, + SortOrder: g.SortOrder, + MemberCount: g.MemberCount, + CreatedAt: g.CreatedAt.Format(time.RFC3339), + UpdatedAt: g.UpdatedAt.Format(time.RFC3339), + } +} + +func (h *Handler) ListAccountGroups(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + writeInternalError(c, err) + return + } + out := make([]accountGroupResponse, 0, len(groups)) + for _, group := range groups { + out = append(out, toAccountGroupResponse(group)) + } + c.JSON(http.StatusOK, gin.H{"groups": out}) +} + +type createAccountGroupReq struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + SortOrder *int64 `json:"sort_order"` +} + +func (h *Handler) CreateAccountGroup(c *gin.Context) { + var req createAccountGroupReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + name, err := sanitizeAccountGroupName(req.Name) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + description := strings.TrimSpace(req.Description) + if utf8.RuneCountInString(description) > 240 { + writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符") + return + } + color := strings.TrimSpace(req.Color) + if utf8.RuneCountInString(color) > 20 { + writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符") + return + } + sortOrder := int64(0) + if req.SortOrder != nil { + sortOrder = *req.SortOrder + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + writeInternalError(c, err) + return + } + if len(groups) >= maxAccountGroups { + writeError(c, http.StatusBadRequest, "分组数量已达上限") + return + } + id, err := h.db.CreateAccountGroup(ctx, name, description, color, sortOrder) + if err != nil { + if errors.Is(err, database.ErrDuplicateAccountGroupName) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"id": id, "message": "分组已创建"}) +} + +type updateAccountGroupReq struct { + Name *string `json:"name"` + Description *string `json:"description"` + Color *string `json:"color"` + SortOrder *int64 `json:"sort_order"` +} + +func (h *Handler) UpdateAccountGroup(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的分组 ID") + return + } + var req updateAccountGroupReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + if req.Name != nil { + name, err := sanitizeAccountGroupName(*req.Name) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + req.Name = &name + } + if req.Description != nil { + desc := strings.TrimSpace(*req.Description) + if utf8.RuneCountInString(desc) > 240 { + writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符") + return + } + req.Description = &desc + } + if req.Color != nil { + color := strings.TrimSpace(*req.Color) + if utf8.RuneCountInString(color) > 20 { + writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符") + return + } + req.Color = &color + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if err := h.db.UpdateAccountGroup(ctx, id, req.Name, req.Description, req.Color, req.SortOrder); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "分组不存在") + return + } + if errors.Is(err, database.ErrDuplicateAccountGroupName) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + writeMessage(c, http.StatusOK, "分组已更新") +} + +func (h *Handler) DeleteAccountGroup(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的分组 ID") + return + } + force := strings.EqualFold(c.Query("force"), "true") + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if err := h.db.DeleteAccountGroup(ctx, id, force); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "分组不存在") + return + } + if errors.Is(err, database.ErrAccountGroupNotEmpty) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + if h.store != nil { + for _, acc := range h.store.Accounts() { + acc.Mu().RLock() + groups := removeInt64(acc.GroupIDs, id) + acc.Mu().RUnlock() + h.store.ApplyAccountGroups(acc.DBID, groups) + } + } + h.refreshAPIKeyAllowedGroupsAfterGroupDelete(ctx, id) + writeMessage(c, http.StatusOK, "分组已删除") +} + +func (h *Handler) refreshAPIKeyAllowedGroupsAfterGroupDelete(ctx context.Context, groupID int64) { + if h == nil || h.db == nil || groupID <= 0 { + return + } + keys, err := h.db.ListAPIKeys(ctx) + if err != nil { + return + } + for _, key := range keys { + if key == nil { + continue + } + if h.store != nil { + h.store.SetAPIKeyAllowedGroups(key.ID, key.AllowedGroupIDs) + } + h.invalidateAPIKeyRuntimeCaches(ctx, key.Key) + } +} + +func sanitizeAccountGroupName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", errors.New("分组名称不能为空") + } + if utf8.RuneCountInString(name) > maxAccountGroupNameRuneSize { + return "", errors.New("分组名称长度超过 80 字符") + } + for _, r := range name { + if r < 0x20 || r == 0x7f { + return "", errors.New("分组名称包含非法控制字符") + } + } + return name, nil +} + +func removeInt64(slice []int64, target int64) []int64 { + out := make([]int64, 0, len(slice)) + for _, v := range slice { + if v != target { + out = append(out, v) + } + } + return out +} + +func containsInt64(slice []int64, target int64) bool { + for _, v := range slice { + if v == target { + return true + } + } + return false +} + +func dedupeInt64(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/admin/handler.go b/admin/handler.go index e92d6f7a..2fffe307 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log" @@ -223,7 +224,12 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.DELETE("/usage/logs", h.ClearUsageLogs) api.GET("/keys", h.ListAPIKeys) api.POST("/keys", h.CreateAPIKey) + api.PATCH("/keys/:id", h.UpdateAPIKey) api.DELETE("/keys/:id", h.DeleteAPIKey) + api.GET("/account-groups", h.ListAccountGroups) + api.POST("/account-groups", h.CreateAccountGroup) + api.PATCH("/account-groups/:id", h.UpdateAccountGroup) + api.DELETE("/account-groups/:id", h.DeleteAccountGroup) api.GET("/health", h.GetHealth) api.GET("/system/update", h.GetSelfUpdateStatus) api.POST("/system/update", h.StartSelfUpdate) @@ -422,6 +428,8 @@ type accountResponse struct { Enabled bool `json:"enabled"` Locked bool `json:"locked"` AllowedAPIKeyIDs []int64 `json:"allowed_api_key_ids"` + Tags []string `json:"tags"` + GroupIDs []int64 `json:"group_ids"` // 图片配额信息 ImageQuotaRemaining *int `json:"image_quota_remaining,omitempty"` ImageQuotaTotal *int `json:"image_quota_total,omitempty"` @@ -509,6 +517,7 @@ func (h *Handler) ListAccounts(c *gin.Context) { Enabled: row.Enabled, Locked: row.Locked, AllowedAPIKeyIDs: row.GetCredentialInt64Slice("allowed_api_key_ids"), + Tags: append([]string(nil), row.Tags...), ScoreBiasOverride: nullableInt64Pointer(row.ScoreBiasOverride), ScoreBiasEffective: effectiveScoreBias(planType, row.ScoreBiasOverride), BaseConcurrencyOverride: nullableInt64Pointer(row.BaseConcurrencyOverride), @@ -517,6 +526,9 @@ func (h *Handler) ListAccounts(c *gin.Context) { UpdatedAt: row.UpdatedAt.Format(time.RFC3339), } if acc, ok := accountMap[row.ID]; ok { + acc.Mu().RLock() + resp.GroupIDs = append([]int64(nil), acc.GroupIDs...) + acc.Mu().RUnlock() resp.ActiveRequests = acc.GetActiveRequests() resp.TotalRequests = acc.GetTotalRequests() debug := acc.GetSchedulerDebugSnapshot(int64(h.store.GetMaxConcurrency())) @@ -628,6 +640,9 @@ type updateAccountSchedulerReq struct { ScoreBiasOverride json.RawMessage `json:"score_bias_override"` BaseConcurrencyOverride json.RawMessage `json:"base_concurrency_override"` AllowedAPIKeyIDs json.RawMessage `json:"allowed_api_key_ids"` + Tags json.RawMessage `json:"tags"` + GroupIDs json.RawMessage `json:"group_ids"` + ProxyURL *string `json:"proxy_url"` } // UpdateAccountScheduler 更新账号调度配置。 @@ -661,6 +676,16 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { writeError(c, http.StatusBadRequest, err.Error()) return } + tags, err := parseOptionalStringSliceField(req.Tags, "tags") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + groupIDs, err := parseOptionalIntegerSliceField(req.GroupIDs, "group_ids") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() @@ -680,9 +705,28 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { return } } + if groupIDs.Set { + missingGroupIDs, err := h.db.VerifyAccountGroupIDs(ctx, groupIDs.Values) + if err != nil { + writeError(c, http.StatusInternalServerError, "校验账号分组失败: "+err.Error()) + return + } + if len(missingGroupIDs) > 0 { + values := make([]string, 0, len(missingGroupIDs)) + for _, value := range missingGroupIDs { + values = append(values, strconv.FormatInt(value, 10)) + } + writeError(c, http.StatusBadRequest, "group_ids 包含不存在的分组 ID: "+strings.Join(values, ", ")) + return + } + } - if err := h.db.UpdateAccountSchedulerConfig(ctx, id, scoreBiasOverride, baseConcurrencyOverride, allowedAPIKeyIDs); err != nil { - if err == sql.ErrNoRows { + proxyURL := database.OptionalString{} + if req.ProxyURL != nil { + proxyURL = database.OptionalString{Set: true, Value: *req.ProxyURL} + } + if err := h.db.UpdateAccountSchedulerMetadata(ctx, id, scoreBiasOverride, baseConcurrencyOverride, allowedAPIKeyIDs, database.OptionalStringSlice{Set: tags.Set, Values: tags.Values}, groupIDs, proxyURL); err != nil { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -690,32 +734,106 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { return } if h.store != nil { - h.store.ApplyAccountSchedulerOverrides(id, nullableInt64Pointer(scoreBiasOverride), nullableInt64Pointer(baseConcurrencyOverride)) + if scoreBiasOverride.Set || baseConcurrencyOverride.Set { + current := h.store.FindByID(id) + var score *int64 + var concurrency *int64 + if current != nil { + current.Mu().RLock() + if current.ScoreBiasOverride != nil { + value := *current.ScoreBiasOverride + score = &value + } + if current.BaseConcurrencyOverride != nil { + value := *current.BaseConcurrencyOverride + concurrency = &value + } + current.Mu().RUnlock() + } + if scoreBiasOverride.Set { + score = nullableInt64Pointer(scoreBiasOverride.Value) + } + if baseConcurrencyOverride.Set { + concurrency = nullableInt64Pointer(baseConcurrencyOverride.Value) + } + h.store.ApplyAccountSchedulerOverrides(id, score, concurrency) + } if allowedAPIKeyIDs.Set { h.store.ApplyAccountAllowedAPIKeys(id, allowedAPIKeyIDs.Values) } } + if h.store != nil && tags.Set { + h.store.ApplyAccountTags(id, tags.Values) + } + if h.store != nil && groupIDs.Set { + h.store.ApplyAccountGroups(id, groupIDs.Values) + } + if h.store != nil && req.ProxyURL != nil { + h.store.ApplyAccountProxyURL(id, *req.ProxyURL) + } writeMessage(c, http.StatusOK, "账号调度配置已更新") } -func parseOptionalIntegerField(raw json.RawMessage, field string, minValue, maxValue int64) (sql.NullInt64, error) { - if len(raw) == 0 || string(raw) == "null" { - return sql.NullInt64{}, nil +type optionalStringSlice struct { + Set bool + Values []string +} + +func parseOptionalStringSliceField(raw json.RawMessage, field string) (optionalStringSlice, error) { + if len(raw) == 0 { + return optionalStringSlice{}, nil + } + if string(raw) == "null" { + return optionalStringSlice{Set: true, Values: []string{}}, nil + } + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return optionalStringSlice{}, fmt.Errorf("%s 必须是字符串数组或 null", field) + } + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + if utf8.RuneCountInString(clean) > 40 { + return optionalStringSlice{}, fmt.Errorf("%s 单个标签不能超过 40 字符", field) + } + key := strings.ToLower(clean) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, clean) + } + if len(out) > 32 { + return optionalStringSlice{}, fmt.Errorf("%s 最多 32 个标签", field) + } + return optionalStringSlice{Set: true, Values: out}, nil +} + +func parseOptionalIntegerField(raw json.RawMessage, field string, minValue, maxValue int64) (database.OptionalNullInt64, error) { + if len(raw) == 0 { + return database.OptionalNullInt64{}, nil + } + if string(raw) == "null" { + return database.OptionalNullInt64{Set: true}, nil } var number json.Number if err := json.Unmarshal(raw, &number); err != nil { - return sql.NullInt64{}, fmt.Errorf("%s 必须是整数或 null", field) + return database.OptionalNullInt64{}, fmt.Errorf("%s 必须是整数或 null", field) } value, err := number.Int64() if err != nil { - return sql.NullInt64{}, fmt.Errorf("%s 必须是整数或 null", field) + return database.OptionalNullInt64{}, fmt.Errorf("%s 必须是整数或 null", field) } if value < minValue || value > maxValue { - return sql.NullInt64{}, fmt.Errorf("%s 超出范围,必须在 %d..%d 之间", field, minValue, maxValue) + return database.OptionalNullInt64{}, fmt.Errorf("%s 超出范围,必须在 %d..%d 之间", field, minValue, maxValue) } - return sql.NullInt64{Int64: value, Valid: true}, nil + return database.OptionalNullInt64{Set: true, Value: sql.NullInt64{Int64: value, Valid: true}}, nil } func parseOptionalIntegerSliceField(raw json.RawMessage, field string) (database.OptionalInt64Slice, error) { @@ -1284,7 +1402,7 @@ func (h *Handler) FetchOpenAIResponsesModels(c *gin.Context) { if req.AccountID > 0 && req.APIKey == "" { row, err := h.db.GetAccountByID(c.Request.Context(), req.AccountID) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -1350,7 +1468,7 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) { defer cancel() row, err := h.db.GetAccountByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -1415,7 +1533,7 @@ func (h *Handler) UpdateOpenAIResponsesAccount(c *gin.Context) { } if err := h.db.UpdateOpenAIResponsesAccount(ctx, id, name, credentials, req.ProxyURL); err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -2195,6 +2313,10 @@ func (h *Handler) DeleteAccount(c *gin.Context) { // 软删除:保留账号数据与事件记录,但从运行时池和 active 列表中移除。 if err := h.db.SoftDeleteAccount(ctx, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "账号不存在") + return + } writeError(c, http.StatusInternalServerError, "删除失败: "+err.Error()) return } @@ -2255,7 +2377,7 @@ func (h *Handler) ToggleAccountEnabled(c *gin.Context) { defer cancel() if err := h.db.SetAccountEnabled(ctx, id, *req.Enabled); err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -3008,12 +3130,13 @@ func (h *Handler) ListAPIKeys(c *gin.Context) { } type createKeyReq struct { - Name string `json:"name"` - Key string `json:"key"` - QuotaLimit *float64 `json:"quota_limit"` - Quota *float64 `json:"quota"` - ExpiresAt string `json:"expires_at"` - ExpiresInDays *int `json:"expires_in_days"` + Name string `json:"name"` + Key string `json:"key"` + QuotaLimit *float64 `json:"quota_limit"` + Quota *float64 `json:"quota"` + ExpiresAt string `json:"expires_at"` + ExpiresInDays *int `json:"expires_in_days"` + AllowedGroupIDs json.RawMessage `json:"allowed_group_ids"` } // generateKey 生成随机 API Key @@ -3069,6 +3192,11 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { writeError(c, http.StatusBadRequest, err.Error()) return } + allowedGroupIDs, err := parseOptionalIntegerSliceField(req.AllowedGroupIDs, "allowed_group_ids") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } key := req.Key if key == "" { @@ -3084,17 +3212,39 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() + if allowedGroupIDs.Set { + missing, err := h.db.VerifyAccountGroupIDs(ctx, allowedGroupIDs.Values) + if err != nil { + writeInternalError(c, err) + return + } + if len(missing) > 0 { + values := make([]string, 0, len(missing)) + for _, value := range missing { + values = append(values, strconv.FormatInt(value, 10)) + } + writeError(c, http.StatusBadRequest, "allowed_group_ids 包含不存在的分组 ID: "+strings.Join(values, ", ")) + return + } + } id, err := h.db.InsertAPIKeyWithOptions(ctx, database.APIKeyInput{ - Name: req.Name, - Key: key, - QuotaLimit: quotaLimit, - ExpiresAt: expiresAt, + Name: req.Name, + Key: key, + QuotaLimit: quotaLimit, + ExpiresAt: expiresAt, + AllowedGroupIDs: allowedGroupIDs.Values, }) if err != nil { writeError(c, http.StatusInternalServerError, "创建失败: "+err.Error()) return } + if allowedGroupIDs.Set { + values := dedupeInt64(allowedGroupIDs.Values) + if h.store != nil { + h.store.SetAPIKeyAllowedGroups(id, values) + } + } h.invalidateAPIKeyRuntimeCaches(ctx, key) // 记录安全审计日志 @@ -3106,15 +3256,166 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { expiresAtResponse = &formatted } c.JSON(http.StatusOK, createAPIKeyResponse{ - ID: id, - Key: key, - Name: req.Name, - QuotaLimit: quotaLimit, - QuotaUsed: 0, - ExpiresAt: expiresAtResponse, + ID: id, + Key: key, + Name: req.Name, + QuotaLimit: quotaLimit, + QuotaUsed: 0, + ExpiresAt: expiresAtResponse, + AllowedGroupIDs: dedupeInt64(allowedGroupIDs.Values), }) } +type updateAPIKeyReq struct { + Name *string `json:"name"` + QuotaLimit json.RawMessage `json:"quota_limit"` + Quota json.RawMessage `json:"quota"` + ExpiresAt json.RawMessage `json:"expires_at"` + ExpiresInDays *int `json:"expires_in_days"` + AllowedGroupIDs json.RawMessage `json:"allowed_group_ids"` +} + +func (h *Handler) UpdateAPIKey(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效 ID") + return + } + var req updateAPIKeyReq + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + allowedGroupIDs, err := parseOptionalIntegerSliceField(req.AllowedGroupIDs, "allowed_group_ids") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + quotaLimit, quotaLimitSet, err := parseOptionalAPIKeyQuota(req.QuotaLimit, req.Quota) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + expiresAt, expiresAtSet, err := parseOptionalAPIKeyExpiration(req.ExpiresAt, req.ExpiresInDays) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + row, err := h.db.GetAPIKeyByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "API Key 不存在") + return + } + writeInternalError(c, err) + return + } + if req.Name != nil { + name := security.SanitizeInput(*req.Name) + if strings.TrimSpace(name) == "" { + writeError(c, http.StatusBadRequest, "名称不能为空") + return + } + if utf8.RuneCountInString(name) > 100 { + writeError(c, http.StatusBadRequest, "名称长度不能超过100字符") + return + } + if security.ContainsXSS(name) { + writeError(c, http.StatusBadRequest, "名称包含非法字符") + return + } + req.Name = &name + } + if quotaLimitSet { + if quotaLimit > 1000000000 { + writeError(c, http.StatusBadRequest, "额度限制不能超过 1000000000") + return + } + } + var allowedGroupValues []int64 + if allowedGroupIDs.Set { + missing, err := h.db.VerifyAccountGroupIDs(ctx, allowedGroupIDs.Values) + if err != nil { + writeInternalError(c, err) + return + } + if len(missing) > 0 { + values := make([]string, 0, len(missing)) + for _, value := range missing { + values = append(values, strconv.FormatInt(value, 10)) + } + writeError(c, http.StatusBadRequest, "allowed_group_ids 包含不存在的分组 ID: "+strings.Join(values, ", ")) + return + } + allowedGroupValues = dedupeInt64(allowedGroupIDs.Values) + } + update := database.APIKeyUpdate{ + QuotaLimit: quotaLimit, + QuotaLimitSet: quotaLimitSet, + ExpiresAt: expiresAt, + ExpiresAtSet: expiresAtSet, + AllowedGroupIDs: allowedGroupValues, + AllowedGroupIDsSet: allowedGroupIDs.Set, + } + if req.Name != nil { + update.Name = *req.Name + update.NameSet = true + } + if err := h.db.UpdateAPIKey(ctx, id, update); err != nil { + writeInternalError(c, err) + return + } + if allowedGroupIDs.Set && h.store != nil { + h.store.SetAPIKeyAllowedGroups(id, allowedGroupValues) + } + h.invalidateAPIKeyRuntimeCaches(ctx, row.Key) + writeMessage(c, http.StatusOK, "API Key 已更新") +} + +func parseOptionalAPIKeyQuota(quotaLimitRaw, quotaRaw json.RawMessage) (float64, bool, error) { + raw := quotaLimitRaw + if len(raw) == 0 { + raw = quotaRaw + } + if len(raw) == 0 { + return 0, false, nil + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return 0, true, nil + } + var value float64 + if err := json.Unmarshal(raw, &value); err != nil { + return 0, true, fmt.Errorf("额度限制必须是数字") + } + if value < 0 { + return 0, true, fmt.Errorf("额度限制不能小于 0") + } + return value, true, nil +} + +func parseOptionalAPIKeyExpiration(raw json.RawMessage, expiresInDays *int) (sql.NullTime, bool, error) { + if expiresInDays != nil { + expiresAt, err := parseAPIKeyExpiresAt("", expiresInDays) + return expiresAt, true, err + } + if len(raw) == 0 { + return sql.NullTime{}, false, nil + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return sql.NullTime{}, true, nil + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return sql.NullTime{}, true, fmt.Errorf("过期时间格式无效") + } + expiresAt, err := parseAPIKeyExpiresAt(value, nil) + return expiresAt, true, err +} + func parseAPIKeyExpiresAt(raw string, expiresInDays *int) (sql.NullTime, error) { if expiresInDays != nil { if *expiresInDays < 0 { @@ -3172,6 +3473,9 @@ func (h *Handler) DeleteAPIKey(c *gin.Context) { writeError(c, http.StatusInternalServerError, "删除失败: "+err.Error()) return } + if h.store != nil { + h.store.SetAPIKeyAllowedGroups(id, nil) + } h.invalidateAPIKeyRuntimeCaches(ctx, keyToInvalidate) writeMessage(c, http.StatusOK, "已删除") } @@ -3615,10 +3919,13 @@ func (h *Handler) UpdateSettings(c *gin.Context) { } if req.ProxyPoolEnabled != nil { - h.store.SetProxyPoolEnabled(*req.ProxyPoolEnabled) if *req.ProxyPoolEnabled { - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + writeError(c, http.StatusInternalServerError, "代理池刷新失败: "+err.Error()) + return + } } + h.store.SetProxyPoolEnabled(*req.ProxyPoolEnabled) log.Printf("设置已更新: proxy_pool_enabled = %t", *req.ProxyPoolEnabled) } @@ -4061,7 +4368,7 @@ func (h *Handler) GetAccountAuthJSON(c *gin.Context) { row, err := h.db.GetAccountByID(ctx, id) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "账号不存在") return } @@ -4434,12 +4741,13 @@ func (h *Handler) AddProxies(c *gin.Context) { inserted, err := h.db.InsertProxies(ctx, cleaned, req.Label) if err != nil { - writeError(c, http.StatusInternalServerError, "添加代理失败") + writeError(c, http.StatusInternalServerError, "添加代理失败: "+err.Error()) return } - // 刷新代理池 - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + log.Printf("代理已添加,但代理池刷新失败: %v", err) + } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("成功添加 %d 个代理", inserted), @@ -4460,11 +4768,17 @@ func (h *Handler) DeleteProxy(c *gin.Context) { defer cancel() if err := h.db.DeleteProxy(ctx, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "代理不存在") + return + } writeError(c, http.StatusInternalServerError, "删除代理失败") return } - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + log.Printf("代理已删除,但代理池刷新失败: %v", err) + } c.JSON(http.StatusOK, gin.H{"message": "代理已删除"}) } @@ -4477,6 +4791,7 @@ func (h *Handler) UpdateProxy(c *gin.Context) { } var req struct { + URL *string `json:"url"` Label *string `json:"label"` Enabled *bool `json:"enabled"` } @@ -4488,12 +4803,18 @@ func (h *Handler) UpdateProxy(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() - if err := h.db.UpdateProxy(ctx, id, req.Label, req.Enabled); err != nil { + if err := h.db.UpdateProxy(ctx, id, req.URL, req.Label, req.Enabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(c, http.StatusNotFound, "代理不存在") + return + } writeError(c, http.StatusInternalServerError, "更新代理失败") return } - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + log.Printf("代理已更新,但代理池刷新失败: %v", err) + } c.JSON(http.StatusOK, gin.H{"message": "代理已更新"}) } @@ -4575,7 +4896,10 @@ func (h *Handler) TestProxy(c *gin.Context) { if req.ID > 0 { ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() - _ = h.db.UpdateProxyTestResult(ctx, req.ID, ip, location, latencyMs) + if err := h.db.UpdateProxyTestResult(ctx, req.ID, ip, location, latencyMs); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "代理测试结果保存失败: " + err.Error(), "latency_ms": latencyMs}) + return + } } c.JSON(http.StatusOK, gin.H{ diff --git a/admin/handler_test.go b/admin/handler_test.go index 43193237..346e1acf 100644 --- a/admin/handler_test.go +++ b/admin/handler_test.go @@ -12,8 +12,10 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/codex2api/auth" + "github.com/codex2api/cache" "github.com/codex2api/database" "github.com/gin-gonic/gin" ) @@ -185,6 +187,124 @@ func TestCreateAPIKeyPersistsQuotaAndExpiration(t *testing.T) { } } +func TestUpdateAPIKeyPreservesOmittedFieldsAndUpdatesLimits(t *testing.T) { + gin.SetMode(gin.TestMode) + + dbPath := filepath.Join(t.TempDir(), "codex2api.db") + db, err := database.New("sqlite", dbPath) + if err != nil { + t.Fatalf("database.New 返回错误: %v", err) + } + defer db.Close() + + expiresAt := sql.NullTime{Time: time.Now().AddDate(0, 0, 3), Valid: true} + id, err := db.InsertAPIKeyWithOptions(context.Background(), database.APIKeyInput{ + Name: "Client A", + Key: "sk-test-update-client-1234567890", + QuotaLimit: 0.25, + ExpiresAt: expiresAt, + }) + if err != nil { + t.Fatalf("InsertAPIKeyWithOptions 返回错误: %v", err) + } + + handler := &Handler{db: db} + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", id)}} + ctx.Request = httptest.NewRequest(http.MethodPatch, "/api/admin/keys/1", strings.NewReader(`{"name":"Client B"}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateAPIKey(ctx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + row, err := db.GetAPIKeyByID(context.Background(), id) + if err != nil { + t.Fatalf("GetAPIKeyByID 返回错误: %v", err) + } + if row.Name != "Client B" || row.QuotaLimit != 0.25 || !row.ExpiresAt.Valid { + t.Fatalf("row = %#v, want renamed with quota/expiration preserved", row) + } + + recorder = httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", id)}} + ctx.Request = httptest.NewRequest(http.MethodPatch, "/api/admin/keys/1", strings.NewReader(`{"quota_limit":0,"expires_at":null}`)) + ctx.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateAPIKey(ctx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + row, err = db.GetAPIKeyByID(context.Background(), id) + if err != nil { + t.Fatalf("GetAPIKeyByID 返回错误: %v", err) + } + if row.Name != "Client B" || row.QuotaLimit != 0 || row.ExpiresAt.Valid { + t.Fatalf("row = %#v, want quota/expiration cleared with name preserved", row) + } +} + +func TestUpdateAPIKeyRefreshesRuntimeStoreAndCache(t *testing.T) { + gin.SetMode(gin.TestMode) + + dbPath := filepath.Join(t.TempDir(), "codex2api.db") + db, err := database.New("sqlite", dbPath) + if err != nil { + t.Fatalf("database.New 返回错误: %v", err) + } + defer db.Close() + + ctx := context.Background() + groupID, err := db.CreateAccountGroup(ctx, "Team", "", "#2563eb", 0) + if err != nil { + t.Fatalf("CreateAccountGroup 返回错误: %v", err) + } + key := "sk-test-runtime-refresh-1234567890" + keyID, err := db.InsertAPIKey(ctx, "Client A", key) + if err != nil { + t.Fatalf("InsertAPIKey 返回错误: %v", err) + } + store := auth.NewStore(nil, nil, nil) + tc := cache.NewMemory(1) + handler := &Handler{db: db, store: store, cache: tc} + payload, err := json.Marshal(map[string]interface{}{ + "id": keyID, + "name": "Client A", + "created_at": time.Now().UTC(), + }) + if err != nil { + t.Fatalf("marshal runtime cache: %v", err) + } + if err := tc.SetRuntime(ctx, adminAPIKeyCacheNamespace, key, payload, time.Minute); err != nil { + t.Fatalf("SetRuntime api key: %v", err) + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", keyID)}} + ginCtx.Request = httptest.NewRequest(http.MethodPatch, "/api/admin/keys/1", strings.NewReader(fmt.Sprintf(`{"allowed_group_ids":[%d]}`, groupID))) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateAPIKey(ginCtx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if got := store.GetAPIKeyAllowedGroups(keyID); len(got) != 1 || got[0] != groupID { + t.Fatalf("runtime store allowed groups = %v, want [%d]", got, groupID) + } + if _, ok, err := tc.GetRuntime(ctx, adminAPIKeyCacheNamespace, key); err != nil || ok { + t.Fatalf("runtime api key cache after update ok=%v err=%v, want miss", ok, err) + } + if _, ok, err := tc.GetRuntime(ctx, adminAPIKeyCountNamespace, "all"); err != nil || ok { + t.Fatalf("runtime api key count cache after update ok=%v err=%v, want miss", ok, err) + } +} + func TestGetAccountAuthJSONRejectsInvalidID(t *testing.T) { gin.SetMode(gin.TestMode) @@ -491,7 +611,7 @@ func TestUpdateAccountSchedulerResetsToAutoOnNull(t *testing.T) { db := newTestAdminDB(t) accountID := insertTestAccount(t, db) ctx := context.Background() - if err := db.UpdateAccountSchedulerConfig(ctx, accountID, sql.NullInt64{Int64: 20, Valid: true}, sql.NullInt64{Int64: 4, Valid: true}, database.OptionalInt64Slice{}); err != nil { + if err := db.UpdateAccountSchedulerConfig(ctx, accountID, database.OptionalNullInt64{Set: true, Value: sql.NullInt64{Int64: 20, Valid: true}}, database.OptionalNullInt64{Set: true, Value: sql.NullInt64{Int64: 4, Valid: true}}, database.OptionalInt64Slice{}); err != nil { t.Fatalf("seed scheduler config: %v", err) } @@ -523,6 +643,49 @@ func TestUpdateAccountSchedulerResetsToAutoOnNull(t *testing.T) { } } +func TestUpdateAccountSchedulerPartialMetadataPatchPreservesSchedulerConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := newTestAdminDB(t) + accountID := insertTestAccount(t, db) + keyID := insertTestAPIKey(t, db, "Team A") + ctx := context.Background() + if err := db.UpdateAccountSchedulerConfig(ctx, accountID, + database.OptionalNullInt64{Set: true, Value: sql.NullInt64{Int64: 20, Valid: true}}, + database.OptionalNullInt64{Set: true, Value: sql.NullInt64{Int64: 4, Valid: true}}, + database.OptionalInt64Slice{Set: true, Values: []int64{keyID}}, + ); err != nil { + t.Fatalf("seed scheduler config: %v", err) + } + + handler := &Handler{db: db} + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", accountID)}} + ginCtx.Request = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/admin/accounts/%d/scheduler", accountID), strings.NewReader(`{"tags":["ops"]}`)) + ginCtx.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateAccountScheduler(ginCtx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + + rows, err := db.ListActive(context.Background()) + if err != nil { + t.Fatalf("ListActive: %v", err) + } + if !rows[0].ScoreBiasOverride.Valid || rows[0].ScoreBiasOverride.Int64 != 20 { + t.Fatalf("score_bias_override = %+v, want 20", rows[0].ScoreBiasOverride) + } + if !rows[0].BaseConcurrencyOverride.Valid || rows[0].BaseConcurrencyOverride.Int64 != 4 { + t.Fatalf("base_concurrency_override = %+v, want 4", rows[0].BaseConcurrencyOverride) + } + if got := rows[0].GetCredentialInt64Slice("allowed_api_key_ids"); len(got) != 1 || got[0] != keyID { + t.Fatalf("allowed_api_key_ids = %v, want [%d]", got, keyID) + } +} + func TestUpdateAccountSchedulerClearsAllowedAPIKeyIDsOnNull(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/admin/image_studio.go b/admin/image_studio.go index 40ee1c10..cafe9c1f 100644 --- a/admin/image_studio.go +++ b/admin/image_studio.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" "image" _ "image/gif" @@ -137,7 +138,7 @@ func (h *Handler) UpdateImagePromptTemplate(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() existing, err := h.db.GetImagePromptTemplate(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "模板不存在") return } @@ -343,7 +344,7 @@ func (h *Handler) GetImageGenerationJob(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() job, err := h.db.GetImageGenerationJob(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "任务不存在") return } @@ -415,7 +416,7 @@ func (h *Handler) GetImageAssetFile(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() asset, err := h.db.GetImageAsset(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "图片不存在") return } @@ -449,7 +450,7 @@ func (h *Handler) GetSignedImageAssetFile(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() asset, err := h.db.GetImageAsset(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "图片不存在") return } @@ -603,7 +604,7 @@ func (h *Handler) DeleteImageAsset(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() asset, err := h.db.GetImageAsset(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { writeError(c, http.StatusNotFound, "图片不存在") return } @@ -981,13 +982,13 @@ func (h *Handler) upscaleImageJobAsset(ctx context.Context, jobID int64, assetIn func (h *Handler) resolveImageJobAPIKey(ctx context.Context, id int64) (*database.APIKeyRow, error) { if id > 0 { key, err := h.db.GetAPIKeyByID(ctx, id) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("API Key 不存在") } return key, err } key, err := h.db.FirstAPIKey(ctx) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } return key, err diff --git a/admin/responses.go b/admin/responses.go index d4dfc994..69432b78 100644 --- a/admin/responses.go +++ b/admin/responses.go @@ -49,15 +49,16 @@ type apiKeysResponse struct { // MaskedAPIKeyRow API Key 响应(含脱敏和完整 key) type MaskedAPIKeyRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - RawKey string `json:"raw_key"` - QuotaLimit float64 `json:"quota_limit"` - QuotaUsed float64 `json:"quota_used"` - ExpiresAt *string `json:"expires_at"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + RawKey string `json:"raw_key"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + ExpiresAt *string `json:"expires_at"` + AllowedGroupIDs []int64 `json:"allowed_group_ids"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` } // NewMaskedAPIKeyRow 创建 API Key 响应 @@ -74,25 +75,27 @@ func NewMaskedAPIKeyRow(row *database.APIKeyRow) *MaskedAPIKeyRow { status = "quota_exhausted" } return &MaskedAPIKeyRow{ - ID: row.ID, - Name: row.Name, - Key: security.MaskAPIKey(row.Key), - RawKey: row.Key, - QuotaLimit: row.QuotaLimit, - QuotaUsed: row.QuotaUsed, - ExpiresAt: expiresAt, - Status: status, - CreatedAt: row.CreatedAt.Format(time.RFC3339), + ID: row.ID, + Name: row.Name, + Key: security.MaskAPIKey(row.Key), + RawKey: row.Key, + QuotaLimit: row.QuotaLimit, + QuotaUsed: row.QuotaUsed, + ExpiresAt: expiresAt, + AllowedGroupIDs: append([]int64(nil), row.AllowedGroupIDs...), + Status: status, + CreatedAt: row.CreatedAt.Format(time.RFC3339), } } type createAPIKeyResponse struct { - ID int64 `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - QuotaLimit float64 `json:"quota_limit"` - QuotaUsed float64 `json:"quota_used"` - ExpiresAt *string `json:"expires_at"` + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + ExpiresAt *string `json:"expires_at"` + AllowedGroupIDs []int64 `json:"allowed_group_ids"` } type opsOverviewResponse struct { diff --git a/auth/fast_scheduler.go b/auth/fast_scheduler.go index f496e054..992644c8 100644 --- a/auth/fast_scheduler.go +++ b/auth/fast_scheduler.go @@ -33,11 +33,12 @@ type fastSchedulerPosition struct { // 调度策略:按健康层级分桶,桶内按调度分排序后 round-robin。 // 验证过的账号只作为同分 tie-breaker,避免历史请求量盖过额度快重置优先级。 type FastScheduler struct { - mu sync.RWMutex - baseLimit int64 - buckets map[AccountHealthTier][]fastSchedulerEntry - positions map[int64]fastSchedulerPosition - cursors [3]atomic.Uint64 + mu sync.RWMutex + baseLimit int64 + buckets map[AccountHealthTier][]fastSchedulerEntry + positions map[int64]fastSchedulerPosition + cursors [3]atomic.Uint64 + groupCheck func(apiKeyID int64, account *Account) bool } func NewFastScheduler(baseLimit int64) *FastScheduler { @@ -55,6 +56,15 @@ func NewFastScheduler(baseLimit int64) *FastScheduler { } } +func (s *FastScheduler) SetGroupCheck(check func(apiKeyID int64, account *Account) bool) { + if s == nil { + return + } + s.mu.Lock() + s.groupCheck = check + s.mu.Unlock() +} + // BuildFastScheduler 用当前 Store 快照构建一个独立 scheduler。 // 该方法不会影响现有生产流量路径,只用于 POC/benchmark/灰度验证。 func (s *Store) BuildFastScheduler() *FastScheduler { @@ -226,6 +236,9 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta if !entry.acc.AllowsAPIKey(apiKeyID) { continue } + if s.groupCheck != nil && !s.groupCheck(apiKeyID, entry.acc) { + continue + } if filter != nil && !filter(entry.acc) { continue } diff --git a/auth/fast_scheduler_test.go b/auth/fast_scheduler_test.go index 44d846bd..915f5f2d 100644 --- a/auth/fast_scheduler_test.go +++ b/auth/fast_scheduler_test.go @@ -147,6 +147,29 @@ func TestStoreNextExcludingRespectsAPIKeyWhitelist(t *testing.T) { } } +func TestStoreNextExcludingRespectsAPIKeyAllowedGroups(t *testing.T) { + restricted := newFastSchedulerTestAccount(1, HealthTierHealthy, 120, 1) + restricted.GroupIDs = []int64{10} + fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 1) + fallback.GroupIDs = []int64{20} + + store := &Store{ + accounts: []*Account{restricted, fallback}, + maxConcurrency: 1, + } + store.SetAPIKeyAllowedGroups(1, []int64{20}) + + got := store.NextExcluding(1, nil) + if got == nil { + t.Fatal("NextExcluding() returned nil") + } + defer store.Release(got) + + if got.DBID != 2 { + t.Fatalf("NextExcluding() picked dbID=%d, want 2", got.DBID) + } +} + func TestStoreNextSkipsDispatchPausedAccount(t *testing.T) { paused := newFastSchedulerTestAccount(1, HealthTierHealthy, 120, 1) atomic.StoreInt32(&paused.DispatchPaused, 1) diff --git a/auth/session_affinity_test.go b/auth/session_affinity_test.go index c9c337d3..b69f93cb 100644 --- a/auth/session_affinity_test.go +++ b/auth/session_affinity_test.go @@ -316,3 +316,26 @@ func TestNextForSessionFallsBackWhenAPIKeyNotAllowed(t *testing.T) { t.Fatalf("proxyURL = %q, want empty fallback proxy", proxyURL) } } + +func TestNextForSessionFallsBackWhenAPIKeyGroupNotAllowed(t *testing.T) { + store := &Store{ + accounts: []*Account{ + {DBID: 1, AccessToken: "tok-1", GroupIDs: []int64{20}}, + {DBID: 2, AccessToken: "tok-2", GroupIDs: []int64{10}}, + }, + maxConcurrency: 2, + } + store.SetAPIKeyAllowedGroups(1, []int64{20}) + store.bindSessionAffinity("session-1", store.accounts[1], "http://proxy-2") + + acc, proxyURL := store.NextForSession("session-1", 1, nil) + if acc == nil { + t.Fatal("expected fallback account") + } + if acc.DBID != 1 { + t.Fatalf("account DBID = %d, want %d", acc.DBID, 1) + } + if proxyURL != "" { + t.Fatalf("proxyURL = %q, want empty fallback proxy", proxyURL) + } +} diff --git a/auth/store.go b/auth/store.go index e0973576..4420b744 100644 --- a/auth/store.go +++ b/auth/store.go @@ -112,6 +112,8 @@ type Account struct { BaseConcurrencyOverride *int64 AllowedAPIKeyIDs []int64 allowedAPIKeySet map[int64]struct{} + Tags []string + GroupIDs []int64 ModelCooldowns map[string]ModelCooldown } @@ -1452,6 +1454,9 @@ type Store struct { testModel atomic.Value // 测试连接使用的模型(string) db *database.DB tokenCache cache.TokenCache + apiKeyGroupsMu sync.RWMutex + apiKeyAllowedGroups map[int64][]int64 + apiKeyAllowedGroupSets map[int64]map[int64]struct{} usageProbeMu sync.RWMutex usageProbe func(context.Context, *Account) error usageProbeBatch atomic.Bool @@ -1909,7 +1914,9 @@ func (s *Store) rebuildFastScheduler() { if s == nil || !s.fastSchedulerEnabled.Load() { return } - s.fastScheduler.Store(s.BuildFastScheduler()) + scheduler := s.BuildFastScheduler() + scheduler.SetGroupCheck(s.APIKeyAllowsAccount) + s.fastScheduler.Store(scheduler) } func (s *Store) recomputeAllAccountSchedulerState() { @@ -2267,6 +2274,7 @@ func (s *Store) loadFromDB(ctx context.Context) error { account.ScoreBiasOverride = reflectOptionalInt64Field(row, "ScoreBiasOverride") account.BaseConcurrencyOverride = reflectOptionalInt64Field(row, "BaseConcurrencyOverride") account.setAllowedAPIKeyIDsLocked(row.GetCredentialInt64Slice("allowed_api_key_ids")) + account.Tags = cloneStringSlice(row.Tags) if row.Locked { atomic.StoreInt32(&account.Locked, 1) } @@ -2349,6 +2357,14 @@ func (s *Store) loadFromDB(ctx context.Context) error { } log.Printf("从数据库加载了 %d 个账号", len(s.accounts)) + if memberships, err := s.db.ListAccountGroupMemberships(ctx); err == nil { + s.ApplyAccountGroupMemberships(memberships) + } else { + log.Printf("加载账号分组失败: %v", err) + } + if err := s.LoadAPIKeyAllowedGroups(ctx); err != nil { + log.Printf("加载 API Key 分组限制失败: %v", err) + } return nil } @@ -2500,7 +2516,7 @@ func (s *Store) NextExcludingWithFilter(apiKeyID int64, exclude map[int64]bool, if !acc.IsAvailable() { continue } - if !acc.AllowsAPIKey(apiKeyID) { + if !s.accountAllowedForAPIKey(acc, apiKeyID) { continue } if filter != nil && !filter(acc) { @@ -2694,7 +2710,7 @@ func (s *Store) takeByIDExcluding(id int64, apiKeyID int64, exclude map[int64]bo if s.accountHasCachedCooldown(target) { return nil } - if !target.AllowsAPIKey(apiKeyID) { + if !s.accountAllowedForAPIKey(target, apiKeyID) { return nil } if filter != nil && !filter(target) { @@ -2746,7 +2762,7 @@ func (s *Store) hasDispatchCandidateWithFilter(apiKeyID int64, exclude map[int64 if s.accountHasCachedCooldown(acc) { continue } - if !acc.AllowsAPIKey(apiKeyID) { + if !s.accountAllowedForAPIKey(acc, apiKeyID) { continue } if filter != nil && !filter(acc) { @@ -3031,6 +3047,145 @@ func (s *Store) ApplyAccountAllowedAPIKeys(dbID int64, allowedAPIKeyIDs []int64) return true } +func (s *Store) ApplyAccountTags(dbID int64, tags []string) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.Tags = cloneStringSlice(tags) + acc.mu.Unlock() + return true +} + +func (s *Store) ApplyAccountGroups(dbID int64, groupIDs []int64) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.GroupIDs = cloneInt64Slice(groupIDs) + acc.mu.Unlock() + s.fastSchedulerUpdate(acc) + return true +} + +func (s *Store) ApplyAccountGroupMemberships(memberships map[int64][]int64) { + for _, acc := range s.Accounts() { + acc.mu.Lock() + acc.GroupIDs = cloneInt64Slice(memberships[acc.DBID]) + acc.mu.Unlock() + s.fastSchedulerUpdate(acc) + } +} + +func (s *Store) SetAPIKeyAllowedGroups(apiKeyID int64, groupIDs []int64) { + if apiKeyID <= 0 { + return + } + normalized := normalizeAllowedGroupIDs(groupIDs) + s.apiKeyGroupsMu.Lock() + if s.apiKeyAllowedGroups == nil { + s.apiKeyAllowedGroups = make(map[int64][]int64) + } + if s.apiKeyAllowedGroupSets == nil { + s.apiKeyAllowedGroupSets = make(map[int64]map[int64]struct{}) + } + if len(normalized) == 0 { + delete(s.apiKeyAllowedGroups, apiKeyID) + delete(s.apiKeyAllowedGroupSets, apiKeyID) + } else { + s.apiKeyAllowedGroups[apiKeyID] = cloneInt64Slice(normalized) + s.apiKeyAllowedGroupSets[apiKeyID] = int64Set(normalized) + } + s.apiKeyGroupsMu.Unlock() + s.rebuildFastScheduler() +} + +func (s *Store) GetAPIKeyAllowedGroups(apiKeyID int64) []int64 { + if apiKeyID <= 0 { + return nil + } + s.apiKeyGroupsMu.RLock() + defer s.apiKeyGroupsMu.RUnlock() + return cloneInt64Slice(s.apiKeyAllowedGroups[apiKeyID]) +} + +func (s *Store) LoadAPIKeyAllowedGroups(ctx context.Context) error { + if s == nil || s.db == nil { + return nil + } + keys, err := s.db.ListAPIKeys(ctx) + if err != nil { + return err + } + s.apiKeyGroupsMu.Lock() + s.apiKeyAllowedGroups = make(map[int64][]int64, len(keys)) + s.apiKeyAllowedGroupSets = make(map[int64]map[int64]struct{}, len(keys)) + for _, key := range keys { + normalized := normalizeAllowedGroupIDs(key.AllowedGroupIDs) + if len(normalized) > 0 { + s.apiKeyAllowedGroups[key.ID] = cloneInt64Slice(normalized) + s.apiKeyAllowedGroupSets[key.ID] = int64Set(normalized) + } + } + s.apiKeyGroupsMu.Unlock() + s.rebuildFastScheduler() + return nil +} + +func (s *Store) APIKeyAllowsAccount(apiKeyID int64, acc *Account) bool { + if s == nil || apiKeyID <= 0 || acc == nil { + return true + } + s.apiKeyGroupsMu.RLock() + allowedSet := s.apiKeyAllowedGroupSets[apiKeyID] + s.apiKeyGroupsMu.RUnlock() + if len(allowedSet) == 0 { + return true + } + acc.mu.RLock() + defer acc.mu.RUnlock() + for _, id := range acc.GroupIDs { + if _, ok := allowedSet[id]; ok { + return true + } + } + return false +} + +func normalizeAllowedGroupIDs(groupIDs []int64) []int64 { + out := make([]int64, 0, len(groupIDs)) + seen := make(map[int64]struct{}, len(groupIDs)) + for _, id := range groupIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func int64Set(values []int64) map[int64]struct{} { + out := make(map[int64]struct{}, len(values)) + for _, value := range values { + out[value] = struct{}{} + } + return out +} + +func (s *Store) accountAllowedForAPIKey(acc *Account, apiKeyID int64) bool { + if acc == nil { + return false + } + return acc.AllowsAPIKey(apiKeyID) && s.APIKeyAllowsAccount(apiKeyID, acc) +} + func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, models []string, proxyURL string) bool { acc := s.FindByID(dbID) if acc == nil { @@ -3056,6 +3211,17 @@ func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, m return true } +func (s *Store) ApplyAccountProxyURL(dbID int64, proxyURL string) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.ProxyURL = strings.TrimSpace(proxyURL) + acc.mu.Unlock() + return true +} + func (s *Store) ApplyAccountEnabled(dbID int64, enabled bool) bool { acc := s.FindByID(dbID) if acc == nil { diff --git a/database/account_groups.go b/database/account_groups.go new file mode 100644 index 00000000..52d49c11 --- /dev/null +++ b/database/account_groups.go @@ -0,0 +1,446 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +type AccountGroup struct { + ID int64 + Name string + Description string + Color string + SortOrder int64 + MemberCount int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (db *DB) ListAccountGroups(ctx context.Context) ([]AccountGroup, error) { + rows, err := db.conn.QueryContext(ctx, ` + SELECT g.id, g.name, g.description, g.color, g.sort_order, + COALESCE(COUNT(a.id), 0), g.created_at, g.updated_at + FROM account_groups g + LEFT JOIN account_group_members m ON m.group_id = g.id + LEFT JOIN accounts a ON a.id = m.account_id + AND a.status <> 'deleted' + AND COALESCE(a.error_message, '') <> 'deleted' + GROUP BY g.id, g.name, g.description, g.color, g.sort_order, g.created_at, g.updated_at + ORDER BY g.sort_order, g.name`) + if err != nil { + return nil, err + } + defer rows.Close() + groups := make([]AccountGroup, 0) + for rows.Next() { + var g AccountGroup + var createdRaw, updatedRaw interface{} + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Color, &g.SortOrder, &g.MemberCount, &createdRaw, &updatedRaw); err != nil { + return nil, err + } + var parseErr error + g.CreatedAt, parseErr = parseDBTimeValue(createdRaw) + if parseErr != nil { + return nil, parseErr + } + g.UpdatedAt, parseErr = parseDBTimeValue(updatedRaw) + if parseErr != nil { + return nil, parseErr + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +func (db *DB) CreateAccountGroup(ctx context.Context, name, description, color string, sortOrder ...int64) (int64, error) { + name = strings.TrimSpace(name) + if name == "" { + return 0, fmt.Errorf("group name is required") + } + order := int64(0) + if len(sortOrder) > 0 { + order = sortOrder[0] + } + if db.isSQLite() { + res, err := db.conn.ExecContext(ctx, `INSERT INTO account_groups (name, description, color, sort_order) VALUES (?, ?, ?, ?)`, name, description, color, order) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicateAccountGroupName + } + return 0, err + } + return res.LastInsertId() + } + var id int64 + err := db.conn.QueryRowContext(ctx, `INSERT INTO account_groups (name, description, color, sort_order) VALUES ($1, $2, $3, $4) RETURNING id`, name, description, color, order).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicateAccountGroupName + } + return 0, err + } + return id, nil +} + +func (db *DB) UpdateAccountGroup(ctx context.Context, id int64, name, description, color *string, sortOrder ...*int64) error { + sets := make([]string, 0, 5) + args := make([]interface{}, 0, 6) + add := func(col string, value interface{}) { + args = append(args, value) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + sets = append(sets, col+" = "+ph) + } + if name != nil { + clean := strings.TrimSpace(*name) + if clean == "" { + return fmt.Errorf("group name is required") + } + add("name", clean) + } + if description != nil { + add("description", *description) + } + if color != nil { + add("color", *color) + } + if len(sortOrder) > 0 && sortOrder[0] != nil { + add("sort_order", *sortOrder[0]) + } + if len(sets) == 0 { + return nil + } + sets = append(sets, "updated_at = CURRENT_TIMESTAMP") + args = append(args, id) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + res, err := db.conn.ExecContext(ctx, "UPDATE account_groups SET "+strings.Join(sets, ", ")+" WHERE id = "+ph, args...) + if err != nil { + if isUniqueViolation(err) { + return ErrDuplicateAccountGroupName + } + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) DeleteAccountGroup(ctx context.Context, id int64, force ...bool) error { + allowMembers := len(force) > 0 && force[0] + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + ph := "$1" + if db.isSQLite() { + ph = "?" + } + var count int64 + memberCountQuery := ` + SELECT COUNT(*) + FROM account_group_members m + JOIN accounts a ON a.id = m.account_id + WHERE m.group_id = ` + ph + ` AND a.status <> 'deleted' AND COALESCE(a.error_message, '') <> 'deleted'` + if err := tx.QueryRowContext(ctx, memberCountQuery, id).Scan(&count); err != nil { + return err + } + if count > 0 && !allowMembers { + return ErrAccountGroupNotEmpty + } + if _, err := tx.ExecContext(ctx, "DELETE FROM account_group_members WHERE group_id = "+ph, id); err != nil { + return err + } + if err := pruneDeletedGroupFromAPIKeyScopes(ctx, tx, db.isSQLite(), id); err != nil { + return err + } + res, err := tx.ExecContext(ctx, "DELETE FROM account_groups WHERE id = "+ph, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return tx.Commit() +} + +func pruneDeletedGroupFromAPIKeyScopes(ctx context.Context, tx *sql.Tx, sqlite bool, groupID int64) error { + rows, err := tx.QueryContext(ctx, `SELECT id, COALESCE(allowed_group_ids, '[]') FROM api_keys`) + if err != nil { + return err + } + defer rows.Close() + + type update struct { + id int64 + groups []int64 + } + updates := make([]update, 0) + for rows.Next() { + var id int64 + var raw interface{} + if err := rows.Scan(&id, &raw); err != nil { + return err + } + groups := decodeInt64SliceValue(raw) + if !containsInt64(groups, groupID) { + continue + } + nextGroups := removeInt64(groups, groupID) + // If the deleted group was the key's only allowed group, keep the stale + // ID instead of broadening the key into an unrestricted key. + if len(nextGroups) == 0 && len(groups) > 0 { + continue + } + updates = append(updates, update{id: id, groups: nextGroups}) + } + if err := rows.Err(); err != nil { + return err + } + + query := `UPDATE api_keys SET allowed_group_ids = $1::jsonb WHERE id = $2` + if sqlite { + query = `UPDATE api_keys SET allowed_group_ids = ? WHERE id = ?` + } + for _, item := range updates { + if _, err := tx.ExecContext(ctx, query, encodeInt64SliceJSON(item.groups), item.id); err != nil { + return err + } + } + return nil +} + +func removeInt64(slice []int64, target int64) []int64 { + out := make([]int64, 0, len(slice)) + for _, v := range slice { + if v != target { + out = append(out, v) + } + } + return out +} + +func containsInt64(slice []int64, target int64) bool { + for _, v := range slice { + if v == target { + return true + } + } + return false +} + +func (db *DB) SetAccountGroups(ctx context.Context, accountID int64, groupIDs []int64) error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + ph := "$1" + insertQ := "INSERT INTO account_group_members (account_id, group_id) VALUES ($1, $2)" + if db.isSQLite() { + ph = "?" + insertQ = "INSERT INTO account_group_members (account_id, group_id) VALUES (?, ?)" + } + if _, err := tx.ExecContext(ctx, "DELETE FROM account_group_members WHERE account_id = "+ph, accountID); err != nil { + return err + } + seen := make(map[int64]struct{}, len(groupIDs)) + for _, gid := range groupIDs { + if gid <= 0 { + continue + } + if _, ok := seen[gid]; ok { + continue + } + seen[gid] = struct{}{} + if _, err := tx.ExecContext(ctx, insertQ, accountID, gid); err != nil { + return err + } + } + return tx.Commit() +} + +func (db *DB) GetAccountGroupIDs(ctx context.Context, accountID int64) ([]int64, error) { + query := "SELECT group_id FROM account_group_members WHERE account_id = $1 ORDER BY group_id" + if db.isSQLite() { + query = "SELECT group_id FROM account_group_members WHERE account_id = ? ORDER BY group_id" + } + rows, err := db.conn.QueryContext(ctx, query, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (db *DB) ListAccountIDsInGroups(ctx context.Context, groupIDs []int64) ([]int64, error) { + groupIDs = normalizeIDSlice(groupIDs) + if len(groupIDs) == 0 { + return nil, nil + } + placeholders := make([]string, len(groupIDs)) + args := make([]interface{}, len(groupIDs)) + for i, id := range groupIDs { + if db.isSQLite() { + placeholders[i] = "?" + } else { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + args[i] = id + } + rows, err := db.conn.QueryContext(ctx, fmt.Sprintf("SELECT DISTINCT account_id FROM account_group_members WHERE group_id IN (%s) ORDER BY account_id", strings.Join(placeholders, ",")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (db *DB) ListAccountGroupMemberships(ctx context.Context) (map[int64][]int64, error) { + rows, err := db.conn.QueryContext(ctx, `SELECT account_id, group_id FROM account_group_members ORDER BY account_id, group_id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[int64][]int64) + for rows.Next() { + var accountID, groupID int64 + if err := rows.Scan(&accountID, &groupID); err != nil { + return nil, err + } + out[accountID] = append(out[accountID], groupID) + } + return out, rows.Err() +} + +func (db *DB) VerifyAccountGroupIDs(ctx context.Context, ids []int64) ([]int64, error) { + ids = normalizeIDSlice(ids) + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + if db.isSQLite() { + placeholders[i] = "?" + } else { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + args[i] = id + } + rows, err := db.conn.QueryContext(ctx, fmt.Sprintf("SELECT id FROM account_groups WHERE id IN (%s)", strings.Join(placeholders, ",")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + exists := make(map[int64]struct{}, len(ids)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + exists[id] = struct{}{} + } + missing := make([]int64, 0) + for _, id := range ids { + if _, ok := exists[id]; !ok { + missing = append(missing, id) + } + } + return missing, rows.Err() +} + +func (db *DB) UpdateAccountTags(ctx context.Context, id int64, tags []string) error { + payload := encodeTagsJSON(tags) + query := `UPDATE accounts SET tags = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2` + if !db.isSQLite() { + query = `UPDATE accounts SET tags = $1::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = $2` + } + res, err := db.conn.ExecContext(ctx, query, payload, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) UpdateAccountProxyURL(ctx context.Context, id int64, proxyURL string) error { + res, err := db.conn.ExecContext(ctx, `UPDATE accounts SET proxy_url = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, strings.TrimSpace(proxyURL), id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func normalizeIDSlice(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +var ErrDuplicateAccountGroupName = fmt.Errorf("account group name already exists") +var ErrAccountGroupNotEmpty = fmt.Errorf("account group still has members") + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate key") || strings.Contains(msg, "23505") +} diff --git a/database/helpers.go b/database/helpers.go index 3f3e3579..7e249ca2 100644 --- a/database/helpers.go +++ b/database/helpers.go @@ -359,3 +359,53 @@ func (db *DB) insertRowID(ctx context.Context, postgresQuery string, sqliteQuery err := db.conn.QueryRowContext(ctx, postgresQuery, args...).Scan(&id) return id, err } + +// decodeInt64SliceValue parses a JSON array of integers from a JSONB or TEXT column. +func decodeInt64SliceValue(raw interface{}) []int64 { + data := bytesFromDBValue(raw) + if len(data) == 0 { + return nil + } + var out []int64 + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +// encodeInt64SliceJSON marshals []int64 to JSON. Returns "[]" for nil/empty. +func encodeInt64SliceJSON(values []int64) string { + if len(values) == 0 { + return "[]" + } + b, err := json.Marshal(values) + if err != nil { + return "[]" + } + return string(b) +} + +// decodeTagsValue parses a tags column value (from JSONB on PG, TEXT on SQLite) into []string. +func decodeTagsValue(raw interface{}) []string { + data := bytesFromDBValue(raw) + if len(data) == 0 { + return nil + } + var out []string + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +// encodeTagsJSON marshals a []string to JSON array string. Returns "[]" for nil/empty. +func encodeTagsJSON(tags []string) string { + if len(tags) == 0 { + return "[]" + } + b, err := json.Marshal(tags) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/database/image_studio.go b/database/image_studio.go index c45fb7b1..ede2d6e2 100644 --- a/database/image_studio.go +++ b/database/image_studio.go @@ -586,8 +586,8 @@ func scanAPIKeyRow(scanner interface { Scan(dest ...interface{}) error }) (*APIKeyRow, error) { row := &APIKeyRow{} - var createdAtRaw, expiresAtRaw interface{} - if err := scanner.Scan(&row.ID, &row.Name, &row.Key, &createdAtRaw, &row.QuotaLimit, &row.QuotaUsed, &expiresAtRaw); err != nil { + var createdAtRaw, expiresAtRaw, allowedGroupsRaw interface{} + if err := scanner.Scan(&row.ID, &row.Name, &row.Key, &createdAtRaw, &row.QuotaLimit, &row.QuotaUsed, &expiresAtRaw, &allowedGroupsRaw); err != nil { return nil, err } createdAt, err := parseDBTimeValue(createdAtRaw) @@ -600,5 +600,6 @@ func scanAPIKeyRow(scanner interface { } row.CreatedAt = createdAt row.ExpiresAt = expiresAt + row.AllowedGroupIDs = decodeInt64SliceValue(allowedGroupsRaw) return row, nil } diff --git a/database/models.go b/database/models.go index e501db71..d198d723 100644 --- a/database/models.go +++ b/database/models.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "errors" "time" ) @@ -134,7 +135,7 @@ func (db *DB) GetModelRegistrySyncState(ctx context.Context) (*ModelRegistrySync err := db.conn.QueryRowContext(ctx, ` SELECT source_url, last_synced_at FROM model_registry_sync WHERE id = 1 `).Scan(&sourceURL, &syncedRaw) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { diff --git a/database/postgres.go b/database/postgres.go index cdc7ad79..f34811c4 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" "strings" @@ -31,6 +32,7 @@ type AccountRow struct { Locked bool ScoreBiasOverride sql.NullInt64 BaseConcurrencyOverride sql.NullInt64 + Tags []string CreatedAt time.Time UpdatedAt time.Time } @@ -48,6 +50,29 @@ type OptionalInt64Slice struct { Values []int64 } +type OptionalStringSlice struct { + Set bool + Values []string +} + +type OptionalString struct { + Set bool + Value string +} + +type OptionalNullInt64 struct { + Set bool + Value sql.NullInt64 +} + +// AccountCredentialIndex holds pre-built sets of existing credentials for fast import dedup. +type AccountCredentialIndex struct { + RefreshTokens map[string]bool + AccessTokens map[string]bool + SessionTokens map[string]bool + AccountIDs map[string]bool +} + // GetCredential 从 credentials JSONB 获取字符串字段 func (a *AccountRow) GetCredential(key string) string { if a.Credentials == nil { @@ -104,6 +129,7 @@ type DB struct { usageLogBatchSize int64 usageLogFlushInterval int64 // ns logFlushNotify chan struct{} + accountInsertMu sync.Mutex } const ( @@ -120,6 +146,8 @@ const ( maxUsageLogFlushIntervalSeconds = 300 ) +var ErrDuplicateAccountCredential = errors.New("duplicate account credential") + func NormalizeUsageLogMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "", UsageLogModeFull: @@ -281,6 +309,9 @@ func New(driver string, dsn string, schema ...string) (*DB, error) { prompt_tokens BIGINT NOT NULL DEFAULT 0, completion_tokens BIGINT NOT NULL DEFAULT 0, cached_tokens BIGINT NOT NULL DEFAULT 0, + cache_hit_requests BIGINT NOT NULL DEFAULT 0, + first_token_ms_sum DOUBLE PRECISION NOT NULL DEFAULT 0, + first_token_samples BIGINT NOT NULL DEFAULT 0, account_billed DOUBLE PRECISION NOT NULL DEFAULT 0, user_billed DOUBLE PRECISION NOT NULL DEFAULT 0 ) @@ -313,6 +344,9 @@ func (db *DB) ensureUsageStatsBaselineBillingColumns(ctx context.Context) error }{ {name: "account_billed", def: "REAL NOT NULL DEFAULT 0"}, {name: "user_billed", def: "REAL NOT NULL DEFAULT 0"}, + {name: "cache_hit_requests", def: "INTEGER NOT NULL DEFAULT 0"}, + {name: "first_token_ms_sum", def: "REAL NOT NULL DEFAULT 0"}, + {name: "first_token_samples", def: "INTEGER NOT NULL DEFAULT 0"}, } { if _, ok := columns[column.name]; ok { continue @@ -326,6 +360,9 @@ func (db *DB) ensureUsageStatsBaselineBillingColumns(ctx context.Context) error _, err := db.conn.ExecContext(ctx, ` ALTER TABLE usage_stats_baseline ADD COLUMN IF NOT EXISTS account_billed DOUBLE PRECISION NOT NULL DEFAULT 0; ALTER TABLE usage_stats_baseline ADD COLUMN IF NOT EXISTS user_billed DOUBLE PRECISION NOT NULL DEFAULT 0; + ALTER TABLE usage_stats_baseline ADD COLUMN IF NOT EXISTS cache_hit_requests BIGINT NOT NULL DEFAULT 0; + ALTER TABLE usage_stats_baseline ADD COLUMN IF NOT EXISTS first_token_ms_sum DOUBLE PRECISION NOT NULL DEFAULT 0; + ALTER TABLE usage_stats_baseline ADD COLUMN IF NOT EXISTS first_token_samples BIGINT NOT NULL DEFAULT 0; `) return err } @@ -443,6 +480,30 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE accounts ADD COLUMN IF NOT EXISTS image_quota_total INT NULL; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS today_used_count INT DEFAULT 0; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS image_quota_reset_at TIMESTAMPTZ NULL; + ALTER TABLE accounts ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]'::jsonb; + + CREATE TABLE IF NOT EXISTS account_groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(80) UNIQUE NOT NULL, + description TEXT DEFAULT '', + color VARCHAR(20) DEFAULT '', + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''; + ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS color VARCHAR(20) DEFAULT ''; + ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS sort_order INT DEFAULT 0; + ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(); + + CREATE TABLE IF NOT EXISTS account_group_members ( + account_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + PRIMARY KEY (account_id, group_id) + ); + CREATE INDEX IF NOT EXISTS idx_account_group_members_group ON account_group_members(group_id); + CREATE INDEX IF NOT EXISTS idx_account_group_members_account ON account_group_members(account_id); UPDATE accounts SET status = 'deleted', @@ -519,6 +580,8 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NULL; CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS allowed_group_ids JSONB DEFAULT '[]'::jsonb; + CREATE TABLE IF NOT EXISTS system_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), site_name TEXT DEFAULT 'CodexProxy', @@ -738,24 +801,37 @@ func (db *DB) migrate(ctx context.Context) error { // APIKeyRow API 密钥行 type APIKeyRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - QuotaLimit float64 `json:"quota_limit"` - QuotaUsed float64 `json:"quota_used"` - ExpiresAt sql.NullTime `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + ExpiresAt sql.NullTime `json:"expires_at"` + AllowedGroupIDs []int64 `json:"allowed_group_ids"` + CreatedAt time.Time `json:"created_at"` } type APIKeyInput struct { - Name string - Key string - QuotaLimit float64 - QuotaUsed float64 - ExpiresAt sql.NullTime + Name string + Key string + QuotaLimit float64 + QuotaUsed float64 + ExpiresAt sql.NullTime + AllowedGroupIDs []int64 } -const apiKeySelectColumns = `id, name, key, created_at, COALESCE(quota_limit, 0), COALESCE(quota_used, 0), expires_at` +type APIKeyUpdate struct { + Name string + NameSet bool + QuotaLimit float64 + QuotaLimitSet bool + ExpiresAt sql.NullTime + ExpiresAtSet bool + AllowedGroupIDs []int64 + AllowedGroupIDsSet bool +} + +const apiKeySelectColumns = `id, name, key, created_at, COALESCE(quota_limit, 0), COALESCE(quota_used, 0), expires_at, COALESCE(allowed_group_ids, '[]')` // ListAPIKeys 获取所有 API 密钥 func (db *DB) ListAPIKeys(ctx context.Context) ([]*APIKeyRow, error) { @@ -811,9 +887,9 @@ func (db *DB) InsertAPIKeyWithOptions(ctx context.Context, input APIKeyInput) (i input.QuotaUsed = 0 } return db.insertRowID(ctx, - `INSERT INTO api_keys (name, key, quota_limit, quota_used, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id`, - `INSERT INTO api_keys (name, key, quota_limit, quota_used, expires_at) VALUES ($1, $2, $3, $4, $5)`, - input.Name, input.Key, input.QuotaLimit, input.QuotaUsed, nullableTimeArg(input.ExpiresAt), + `INSERT INTO api_keys (name, key, quota_limit, quota_used, expires_at, allowed_group_ids) VALUES ($1, $2, $3, $4, $5, $6::jsonb) RETURNING id`, + `INSERT INTO api_keys (name, key, quota_limit, quota_used, expires_at, allowed_group_ids) VALUES ($1, $2, $3, $4, $5, $6)`, + input.Name, input.Key, input.QuotaLimit, input.QuotaUsed, nullableTimeArg(input.ExpiresAt), encodeInt64SliceJSON(input.AllowedGroupIDs), ) } @@ -833,7 +909,146 @@ func (row *APIKeyRow) IsQuotaExhausted() bool { } func (row *APIKeyRow) HasAccessConstraints() bool { - return row != nil && (row.QuotaLimit > 0 || row.ExpiresAt.Valid) + return row != nil && (row.QuotaLimit > 0 || row.ExpiresAt.Valid || len(row.AllowedGroupIDs) > 0) +} + +// UpdateAPIKeyName updates the display name of an API key without changing the key value. +func (db *DB) UpdateAPIKeyName(ctx context.Context, id int64, name string) error { + res, err := db.conn.ExecContext(ctx, `UPDATE api_keys SET name = $1 WHERE id = $2`, name, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateAPIKeyQuotaLimit updates the quota ceiling. A non-positive value clears the limit. +func (db *DB) UpdateAPIKeyQuotaLimit(ctx context.Context, id int64, quotaLimit float64) error { + if quotaLimit < 0 { + quotaLimit = 0 + } + res, err := db.conn.ExecContext(ctx, `UPDATE api_keys SET quota_limit = $1 WHERE id = $2`, quotaLimit, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateAPIKeyExpiresAt updates or clears the key expiration. +func (db *DB) UpdateAPIKeyExpiresAt(ctx context.Context, id int64, expiresAt sql.NullTime) error { + res, err := db.conn.ExecContext(ctx, `UPDATE api_keys SET expires_at = $1 WHERE id = $2`, nullableTimeArg(expiresAt), id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateAPIKeyAllowedGroups persists the allowed-group scope for an API key. +// Empty slice clears the scope (key may schedule any account). +func (db *DB) UpdateAPIKeyAllowedGroups(ctx context.Context, id int64, groupIDs []int64) error { + payload := encodeInt64SliceJSON(groupIDs) + var ( + res sql.Result + err error + ) + if db.isSQLite() { + res, err = db.conn.ExecContext(ctx, `UPDATE api_keys SET allowed_group_ids = $1 WHERE id = $2`, payload, id) + } else { + res, err = db.conn.ExecContext(ctx, `UPDATE api_keys SET allowed_group_ids = $1::jsonb WHERE id = $2`, payload, id) + } + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) UpdateAPIKeyAllowedGroupIDs(ctx context.Context, id int64, groupIDs []int64) error { + return db.UpdateAPIKeyAllowedGroups(ctx, id, groupIDs) +} + +// UpdateAPIKey applies multiple editable fields in one transaction. +// Omitted fields keep their existing values. +func (db *DB) UpdateAPIKey(ctx context.Context, id int64, update APIKeyUpdate) error { + sets := make([]string, 0, 4) + args := make([]interface{}, 0, 5) + placeholder := func() string { + args = append(args, nil) + if db.isSQLite() { + return "?" + } + return fmt.Sprintf("$%d", len(args)) + } + setArg := func(value interface{}) string { + ph := placeholder() + args[len(args)-1] = value + return ph + } + if update.NameSet { + sets = append(sets, "name = "+setArg(update.Name)) + } + if update.QuotaLimitSet { + quotaLimit := update.QuotaLimit + if quotaLimit < 0 { + quotaLimit = 0 + } + sets = append(sets, "quota_limit = "+setArg(quotaLimit)) + } + if update.ExpiresAtSet { + sets = append(sets, "expires_at = "+setArg(nullableTimeArg(update.ExpiresAt))) + } + if update.AllowedGroupIDsSet { + payload := encodeInt64SliceJSON(update.AllowedGroupIDs) + ph := setArg(payload) + if db.isSQLite() { + sets = append(sets, "allowed_group_ids = "+ph) + } else { + sets = append(sets, "allowed_group_ids = "+ph+"::jsonb") + } + } + if len(sets) == 0 { + return nil + } + idPlaceholder := placeholder() + args[len(args)-1] = id + res, err := db.conn.ExecContext(ctx, "UPDATE api_keys SET "+strings.Join(sets, ", ")+" WHERE id = "+idPlaceholder, args...) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil } // ==================== System Settings ==================== @@ -952,7 +1167,7 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { &s.UsageLogFlushIntervalSeconds, &s.StreamFlushPolicy, &s.StreamFlushIntervalMS, &s.ImageStorageConfig, ) - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } s.SiteName = NormalizeSiteName(s.SiteName) @@ -1141,9 +1356,12 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i if db.isSQLite() { res, err := db.conn.ExecContext(ctx, `INSERT INTO proxies (url, label) VALUES ($1, $2) ON CONFLICT(url) DO NOTHING`, u, label) if err != nil { - continue + return inserted, err + } + affected, err := res.RowsAffected() + if err != nil { + return inserted, err } - affected, _ := res.RowsAffected() if affected > 0 { inserted++ } @@ -1154,6 +1372,10 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i `INSERT INTO proxies (url, label) VALUES ($1, $2) ON CONFLICT (url) DO NOTHING RETURNING id`, u, label).Scan(&id) if err == nil { inserted++ + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return inserted, err } } return inserted, nil @@ -1161,8 +1383,18 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i // DeleteProxy 删除单个代理 func (db *DB) DeleteProxy(ctx context.Context, id int64) error { - _, err := db.conn.ExecContext(ctx, `DELETE FROM proxies WHERE id = $1`, id) - return err + res, err := db.conn.ExecContext(ctx, `DELETE FROM proxies WHERE id = $1`, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil } // DeleteProxies 批量删除代理 @@ -1187,16 +1419,43 @@ func (db *DB) DeleteProxies(ctx context.Context, ids []int64) (int, error) { } // UpdateProxy 更新代理 -func (db *DB) UpdateProxy(ctx context.Context, id int64, label *string, enabled *bool) error { - if label != nil { - if _, err := db.conn.ExecContext(ctx, `UPDATE proxies SET label = $1 WHERE id = $2`, *label, id); err != nil { +func (db *DB) UpdateProxy(ctx context.Context, id int64, urlValue *string, label *string, enabled *bool) error { + if urlValue == nil && label == nil && enabled == nil { + var exists int + if err := db.conn.QueryRowContext(ctx, `SELECT 1 FROM proxies WHERE id = $1`, id).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } return err } + return nil + } + assignments := make([]string, 0, 3) + args := make([]interface{}, 0, 4) + if urlValue != nil { + args = append(args, *urlValue) + assignments = append(assignments, fmt.Sprintf("url = $%d", len(args))) + } + if label != nil { + args = append(args, *label) + assignments = append(assignments, fmt.Sprintf("label = $%d", len(args))) } if enabled != nil { - if _, err := db.conn.ExecContext(ctx, `UPDATE proxies SET enabled = $1 WHERE id = $2`, *enabled, id); err != nil { - return err - } + args = append(args, *enabled) + assignments = append(assignments, fmt.Sprintf("enabled = $%d", len(args))) + } + args = append(args, id) + query := fmt.Sprintf("UPDATE proxies SET %s WHERE id = $%d", strings.Join(assignments, ", "), len(args)) + res, err := db.conn.ExecContext(ctx, query, args...) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows } return nil } @@ -1583,16 +1842,20 @@ type UsageStats struct { TotalPrompt int64 `json:"total_prompt_tokens"` TotalCompletion int64 `json:"total_completion_tokens"` TotalCachedTokens int64 `json:"total_cached_tokens"` + TotalCacheRate float64 `json:"total_cache_rate"` TotalAccountBilled float64 `json:"total_account_billed"` TotalUserBilled float64 `json:"total_user_billed"` AvgAccountBilled float64 `json:"avg_account_billed_per_request"` AvgUserBilled float64 `json:"avg_user_billed_per_request"` TodayRequests int64 `json:"today_requests"` TodayTokens int64 `json:"today_tokens"` + TodayCachedTokens int64 `json:"today_cached_tokens"` + TodayCacheRate float64 `json:"today_cache_rate"` TodayAccountBilled float64 `json:"today_account_billed"` TodayUserBilled float64 `json:"today_user_billed"` RPM float64 `json:"rpm"` TPM float64 `json:"tpm"` + AvgFirstTokenMs float64 `json:"avg_first_token_ms"` AvgDurationMs float64 `json:"avg_duration_ms"` ErrorRate float64 `json:"error_rate"` FeatureStats UsageFeatureStat `json:"feature_stats"` @@ -1668,14 +1931,16 @@ func (db *DB) GetUsageStats(ctx context.Context) (*UsageStats, error) { SELECT COUNT(*) AS today_requests, COALESCE(SUM(total_tokens), 0) AS today_tokens, - COALESCE(SUM(prompt_tokens), 0) AS today_prompt, - COALESCE(SUM(completion_tokens), 0) AS today_completion, - COALESCE(SUM(cached_tokens), 0) AS today_cached, - COALESCE(SUM(account_billed), 0) AS today_account_billed, - COALESCE(SUM(user_billed), 0) AS today_user_billed, - COALESCE(SUM(CASE WHEN created_at >= $2 THEN 1 ELSE 0 END), 0) AS rpm, - COALESCE(SUM(CASE WHEN created_at >= $2 THEN total_tokens ELSE 0 END), 0) AS tpm, - COALESCE(AVG(duration_ms), 0) AS avg_duration_ms, + COALESCE(SUM(prompt_tokens), 0) AS today_prompt, + COALESCE(SUM(completion_tokens), 0) AS today_completion, + COALESCE(SUM(cached_tokens), 0) AS today_cached, + COALESCE(SUM(account_billed), 0) AS today_account_billed, + COALESCE(SUM(user_billed), 0) AS today_user_billed, + COALESCE(SUM(CASE WHEN created_at >= $2 THEN 1 ELSE 0 END), 0) AS rpm, + COALESCE(SUM(CASE WHEN created_at >= $2 THEN total_tokens ELSE 0 END), 0) AS tpm, + COALESCE(AVG(NULLIF(first_token_ms, 0)), 0) AS avg_first_token_ms, + COALESCE(AVG(duration_ms), 0) AS avg_duration_ms, + COALESCE(SUM(CASE WHEN cached_tokens > 0 THEN 1 ELSE 0 END), 0) AS today_cache_hit_requests, COALESCE(SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END), 0) AS today_errors FROM usage_logs WHERE created_at >= $1 @@ -1683,12 +1948,15 @@ func (db *DB) GetUsageStats(ctx context.Context) (*UsageStats, error) { ` var todayErrors int64 + var todayCacheHitRequests int64 var todayPrompt, todayCompletion, todayCached int64 err := db.conn.QueryRowContext(ctx, todayQuery, todayStart, minuteAgo).Scan( &stats.TodayRequests, &stats.TodayTokens, &todayPrompt, &todayCompletion, &todayCached, &stats.TodayAccountBilled, &stats.TodayUserBilled, &stats.RPM, &stats.TPM, + &stats.AvgFirstTokenMs, &stats.AvgDurationMs, + &todayCacheHitRequests, &todayErrors, ) if err != nil { @@ -1697,7 +1965,10 @@ func (db *DB) GetUsageStats(ctx context.Context) (*UsageStats, error) { // 统计当前可见请求总数和计费总额(排除 499,保证与使用统计列表口径一致) var visibleTotal int64 + var visibleCacheHitRequests int64 + var visibleFirstTokenSamples int64 var currentTokens, currentPrompt, currentCompletion, currentCached int64 + var currentFirstTokenMsSum float64 var currentAccountBilled, currentUserBilled float64 _ = db.conn.QueryRowContext(ctx, ` SELECT @@ -1706,27 +1977,41 @@ func (db *DB) GetUsageStats(ctx context.Context) (*UsageStats, error) { COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0), COALESCE(SUM(cached_tokens), 0), + COALESCE(SUM(CASE WHEN cached_tokens > 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN first_token_ms > 0 THEN first_token_ms ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN first_token_ms > 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(account_billed), 0), COALESCE(SUM(user_billed), 0) FROM usage_logs WHERE status_code <> 499 - `).Scan(&visibleTotal, ¤tTokens, ¤tPrompt, ¤tCompletion, ¤tCached, ¤tAccountBilled, ¤tUserBilled) + `).Scan(&visibleTotal, ¤tTokens, ¤tPrompt, ¤tCompletion, ¤tCached, &visibleCacheHitRequests, ¤tFirstTokenMsSum, &visibleFirstTokenSamples, ¤tAccountBilled, ¤tUserBilled) // 加上基线值(清空日志前保存的累计值) - var bReq, bTok, bPrompt, bComp, bCached int64 + var bReq, bTok, bPrompt, bComp, bCached, bCacheHitRequests, bFirstTokenSamples int64 + var bFirstTokenMsSum float64 var bAccountBilled, bUserBilled float64 _ = db.conn.QueryRowContext(ctx, ` - SELECT total_requests, total_tokens, prompt_tokens, completion_tokens, cached_tokens, account_billed, user_billed + SELECT total_requests, total_tokens, prompt_tokens, completion_tokens, cached_tokens, cache_hit_requests, first_token_ms_sum, first_token_samples, account_billed, user_billed FROM usage_stats_baseline WHERE id = 1 - `).Scan(&bReq, &bTok, &bPrompt, &bComp, &bCached, &bAccountBilled, &bUserBilled) + `).Scan(&bReq, &bTok, &bPrompt, &bComp, &bCached, &bCacheHitRequests, &bFirstTokenMsSum, &bFirstTokenSamples, &bAccountBilled, &bUserBilled) stats.TotalRequests = visibleTotal + bReq stats.TotalTokens = currentTokens + bTok stats.TotalPrompt = currentPrompt + bPrompt stats.TotalCompletion = currentCompletion + bComp stats.TotalCachedTokens = currentCached + bCached + stats.TodayCachedTokens = todayCached stats.TotalAccountBilled = currentAccountBilled + bAccountBilled stats.TotalUserBilled = currentUserBilled + bUserBilled + if stats.TodayRequests > 0 { + stats.TodayCacheRate = float64(todayCacheHitRequests) / float64(stats.TodayRequests) * 100 + } + if stats.TotalRequests > 0 { + stats.TotalCacheRate = float64(visibleCacheHitRequests+bCacheHitRequests) / float64(stats.TotalRequests) * 100 + } + if totalFirstTokenSamples := visibleFirstTokenSamples + bFirstTokenSamples; totalFirstTokenSamples > 0 { + stats.AvgFirstTokenMs = (currentFirstTokenMsSum + bFirstTokenMsSum) / float64(totalFirstTokenSamples) + } if stats.TotalRequests > 0 { stats.AvgAccountBilled = stats.TotalAccountBilled / float64(stats.TotalRequests) stats.AvgUserBilled = stats.TotalUserBilled / float64(stats.TotalRequests) @@ -2523,12 +2808,15 @@ func (db *DB) ClearUsageLogs(ctx context.Context) error { _, err := db.conn.ExecContext(ctx, ` UPDATE usage_stats_baseline SET total_requests = total_requests + COALESCE((SELECT COUNT(*) FROM usage_logs WHERE status_code <> 499), 0), - total_tokens = total_tokens + COALESCE((SELECT SUM(total_tokens) FROM usage_logs WHERE status_code <> 499), 0), - prompt_tokens = prompt_tokens + COALESCE((SELECT SUM(prompt_tokens) FROM usage_logs WHERE status_code <> 499), 0), - completion_tokens = completion_tokens + COALESCE((SELECT SUM(completion_tokens) FROM usage_logs WHERE status_code <> 499), 0), - cached_tokens = cached_tokens + COALESCE((SELECT SUM(cached_tokens) FROM usage_logs WHERE status_code <> 499), 0), - account_billed = account_billed + COALESCE((SELECT SUM(account_billed) FROM usage_logs WHERE status_code <> 499), 0), - user_billed = user_billed + COALESCE((SELECT SUM(user_billed) FROM usage_logs WHERE status_code <> 499), 0) + total_tokens = total_tokens + COALESCE((SELECT SUM(total_tokens) FROM usage_logs WHERE status_code <> 499), 0), + prompt_tokens = prompt_tokens + COALESCE((SELECT SUM(prompt_tokens) FROM usage_logs WHERE status_code <> 499), 0), + completion_tokens = completion_tokens + COALESCE((SELECT SUM(completion_tokens) FROM usage_logs WHERE status_code <> 499), 0), + cached_tokens = cached_tokens + COALESCE((SELECT SUM(cached_tokens) FROM usage_logs WHERE status_code <> 499), 0), + cache_hit_requests = cache_hit_requests + COALESCE((SELECT SUM(CASE WHEN cached_tokens > 0 THEN 1 ELSE 0 END) FROM usage_logs WHERE status_code <> 499), 0), + first_token_ms_sum = first_token_ms_sum + COALESCE((SELECT SUM(CASE WHEN first_token_ms > 0 THEN first_token_ms ELSE 0 END) FROM usage_logs WHERE status_code <> 499), 0), + first_token_samples = first_token_samples + COALESCE((SELECT SUM(CASE WHEN first_token_ms > 0 THEN 1 ELSE 0 END) FROM usage_logs WHERE status_code <> 499), 0), + account_billed = account_billed + COALESCE((SELECT SUM(account_billed) FROM usage_logs WHERE status_code <> 499), 0), + user_billed = user_billed + COALESCE((SELECT SUM(user_billed) FROM usage_logs WHERE status_code <> 499), 0) WHERE id = 1 `) if err != nil { @@ -2645,7 +2933,7 @@ func (db *DB) GetAccountTimeRangeUsage(ctx context.Context, since time.Time) (ma // ListActive 获取所有未删除账号。 func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' ORDER BY id @@ -2661,6 +2949,7 @@ func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { a := &AccountRow{} var credRaw interface{} var cooldownUntilRaw interface{} + var tagsRaw interface{} var createdAtRaw interface{} var updatedAtRaw interface{} if err := rows.Scan( @@ -2678,12 +2967,14 @@ func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { &a.Locked, &a.ScoreBiasOverride, &a.BaseConcurrencyOverride, + &tagsRaw, &createdAtRaw, &updatedAtRaw, ); err != nil { return nil, fmt.Errorf("扫描账号行失败: %w", err) } a.Credentials = decodeCredentials(credRaw) + a.Tags = decodeTagsValue(tagsRaw) a.CooldownUntil, err = parseDBNullTimeValue(cooldownUntilRaw) if err != nil { return nil, fmt.Errorf("解析 cooldown_until 失败: %w", err) @@ -2783,7 +3074,7 @@ func (db *DB) ClearExpiredModelCooldowns(ctx context.Context) error { // GetAccountByID 获取未删除账号的完整数据库行。 func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' LIMIT 1 @@ -2791,6 +3082,7 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) a := &AccountRow{} var credRaw interface{} var cooldownUntilRaw interface{} + var tagsRaw interface{} var createdAtRaw interface{} var updatedAtRaw interface{} err := db.conn.QueryRowContext(ctx, query, id).Scan( @@ -2808,16 +3100,18 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) &a.Locked, &a.ScoreBiasOverride, &a.BaseConcurrencyOverride, + &tagsRaw, &createdAtRaw, &updatedAtRaw, ) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, sql.ErrNoRows } return nil, fmt.Errorf("查询账号失败: %w", err) } a.Credentials = decodeCredentials(credRaw) + a.Tags = decodeTagsValue(tagsRaw) a.CooldownUntil, err = parseDBNullTimeValue(cooldownUntilRaw) if err != nil { return nil, fmt.Errorf("解析 cooldown_until 失败: %w", err) @@ -2834,35 +3128,65 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) } // UpdateAccountSchedulerConfig 更新账号调度配置。 -func (db *DB) UpdateAccountSchedulerConfig(ctx context.Context, id int64, scoreBiasOverride sql.NullInt64, baseConcurrencyOverride sql.NullInt64, allowedAPIKeyIDs OptionalInt64Slice) error { +func (db *DB) UpdateAccountSchedulerConfig(ctx context.Context, id int64, scoreBiasOverride OptionalNullInt64, baseConcurrencyOverride OptionalNullInt64, allowedAPIKeyIDs OptionalInt64Slice) error { tx, err := db.conn.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - result, err := tx.ExecContext(ctx, ` - UPDATE accounts - SET score_bias_override = $1, - base_concurrency_override = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $3 - `, nullableInt64Value(scoreBiasOverride), nullableInt64Value(baseConcurrencyOverride), id) - if err != nil { - return err - } - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - if rowsAffected == 0 { - return sql.ErrNoRows + if scoreBiasOverride.Set || baseConcurrencyOverride.Set { + sets := make([]string, 0, 3) + args := make([]interface{}, 0, 3) + add := func(column string, value interface{}) { + args = append(args, value) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + sets = append(sets, column+" = "+ph) + } + if scoreBiasOverride.Set { + add("score_bias_override", nullableInt64Value(scoreBiasOverride.Value)) + } + if baseConcurrencyOverride.Set { + add("base_concurrency_override", nullableInt64Value(baseConcurrencyOverride.Value)) + } + sets = append(sets, "updated_at = CURRENT_TIMESTAMP") + args = append(args, id) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + result, err := tx.ExecContext(ctx, "UPDATE accounts SET "+strings.Join(sets, ", ")+" WHERE id = "+ph+" AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'", args...) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return sql.ErrNoRows + } + } else { + query := `SELECT 1 FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` + if db.isSQLite() { + query = `SELECT 1 FROM accounts WHERE id = ? AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` + } + var exists int + if err := tx.QueryRowContext(ctx, query, id).Scan(&exists); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } } if allowedAPIKeyIDs.Set { - selectQuery := `SELECT credentials FROM accounts WHERE id = $1` + selectQuery := `SELECT credentials FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` if db.isSQLite() { - selectQuery = `SELECT credentials FROM accounts WHERE id = ?` + selectQuery = `SELECT credentials FROM accounts WHERE id = ? AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` } else { selectQuery += ` FOR UPDATE` } @@ -2892,6 +3216,101 @@ func (db *DB) UpdateAccountSchedulerConfig(ctx context.Context, id int64, scoreB return tx.Commit() } +// UpdateAccountSchedulerMetadata applies scheduler overrides and UI metadata in +// one transaction. Runtime store updates should happen only after this returns. +func (db *DB) UpdateAccountSchedulerMetadata(ctx context.Context, id int64, scoreBiasOverride OptionalNullInt64, baseConcurrencyOverride OptionalNullInt64, allowedAPIKeyIDs OptionalInt64Slice, tags OptionalStringSlice, groupIDs OptionalInt64Slice, proxyURL OptionalString) error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + query := `SELECT credentials FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` + if db.isSQLite() { + query = `SELECT credentials FROM accounts WHERE id = ? AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted'` + } else { + query += ` FOR UPDATE` + } + var currentRaw interface{} + if err := tx.QueryRowContext(ctx, query, id).Scan(¤tRaw); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return sql.ErrNoRows + } + return err + } + + sets := make([]string, 0, 6) + args := make([]interface{}, 0, 8) + add := func(column string, value interface{}) { + args = append(args, value) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + sets = append(sets, column+" = "+ph) + } + if scoreBiasOverride.Set { + add("score_bias_override", nullableInt64Value(scoreBiasOverride.Value)) + } + if baseConcurrencyOverride.Set { + add("base_concurrency_override", nullableInt64Value(baseConcurrencyOverride.Value)) + } + if tags.Set { + if db.isSQLite() { + add("tags", encodeTagsJSON(tags.Values)) + } else { + args = append(args, encodeTagsJSON(tags.Values)) + sets = append(sets, fmt.Sprintf("tags = $%d::jsonb", len(args))) + } + } + if proxyURL.Set { + add("proxy_url", strings.TrimSpace(proxyURL.Value)) + } + if allowedAPIKeyIDs.Set { + merged := mergeCredentialMaps(decodeCredentials(currentRaw), map[string]interface{}{ + "allowed_api_key_ids": normalizePositiveInt64Slice(allowedAPIKeyIDs.Values), + }) + credJSON, err := json.Marshal(merged) + if err != nil { + return fmt.Errorf("序列化 credentials 失败: %w", err) + } + if db.isSQLite() { + add("credentials", credJSON) + } else { + args = append(args, credJSON) + sets = append(sets, fmt.Sprintf("credentials = $%d::jsonb", len(args))) + } + } + if len(sets) > 0 { + sets = append(sets, "updated_at = CURRENT_TIMESTAMP") + args = append(args, id) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + if _, err := tx.ExecContext(ctx, "UPDATE accounts SET "+strings.Join(sets, ", ")+" WHERE id = "+ph, args...); err != nil { + return err + } + } + if groupIDs.Set { + ph := "$1" + insertQ := "INSERT INTO account_group_members (account_id, group_id) VALUES ($1, $2)" + if db.isSQLite() { + ph = "?" + insertQ = "INSERT INTO account_group_members (account_id, group_id) VALUES (?, ?)" + } + if _, err := tx.ExecContext(ctx, "DELETE FROM account_group_members WHERE account_id = "+ph, id); err != nil { + return err + } + for _, gid := range normalizeIDSlice(groupIDs.Values) { + if _, err := tx.ExecContext(ctx, insertQ, id, gid); err != nil { + return err + } + } + } + return tx.Commit() +} + func nullableInt64Value(v sql.NullInt64) interface{} { if !v.Valid { return nil @@ -3056,6 +3475,12 @@ func (db *DB) BatchSetError(ctx context.Context, ids []int64, errorMsg string) e // SoftDeleteAccount 将账号标记为 deleted,保留数据用于审计和事件追溯。 func (db *DB) SoftDeleteAccount(ctx context.Context, id int64) error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + query := ` UPDATE accounts SET status = 'deleted', @@ -3066,8 +3491,21 @@ func (db *DB) SoftDeleteAccount(ctx context.Context, id int64) error { updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status <> 'deleted' ` - _, err := db.conn.ExecContext(ctx, query, id) - return err + res, err := tx.ExecContext(ctx, query, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + if _, err := tx.ExecContext(ctx, `DELETE FROM account_group_members WHERE account_id = $1`, id); err != nil { + return err + } + return tx.Commit() } // BatchSoftDeleteAccounts 批量软删除账号,分批执行避免 SQL 参数过多。 diff --git a/database/sqlite.go b/database/sqlite.go index e681d376..5609e81f 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -80,9 +80,24 @@ func (db *DB) migrateSQLite(ctx context.Context) error { key TEXT NOT NULL UNIQUE, quota_limit REAL DEFAULT 0, quota_used REAL DEFAULT 0, + allowed_group_ids TEXT DEFAULT '[]', expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`, + `CREATE TABLE IF NOT EXISTS account_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + color TEXT DEFAULT '', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE TABLE IF NOT EXISTS account_group_members ( + account_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (account_id, group_id) + );`, `CREATE TABLE IF NOT EXISTS account_model_cooldowns ( account_id INTEGER NOT NULL, model TEXT NOT NULL, @@ -240,6 +255,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"accounts", "cooldown_until", "TIMESTAMP NULL"}, {"accounts", "score_bias_override", "INTEGER NULL"}, {"accounts", "base_concurrency_override", "INTEGER NULL"}, + {"accounts", "tags", "TEXT DEFAULT '[]'"}, {"accounts", "deleted_at", "TIMESTAMP NULL"}, {"usage_logs", "input_tokens", "INTEGER DEFAULT 0"}, {"usage_logs", "output_tokens", "INTEGER DEFAULT 0"}, @@ -269,7 +285,13 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"usage_logs", "error_message", "TEXT DEFAULT ''"}, {"api_keys", "quota_limit", "REAL DEFAULT 0"}, {"api_keys", "quota_used", "REAL DEFAULT 0"}, + {"api_keys", "allowed_group_ids", "TEXT DEFAULT '[]'"}, {"api_keys", "expires_at", "TIMESTAMP NULL"}, + {"account_groups", "description", "TEXT DEFAULT ''"}, + {"account_groups", "color", "TEXT DEFAULT ''"}, + {"account_groups", "sort_order", "INTEGER DEFAULT 0"}, + {"account_groups", "created_at", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"}, + {"account_groups", "updated_at", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"}, {"system_settings", "site_name", "TEXT DEFAULT 'CodexProxy'"}, {"system_settings", "site_logo", "TEXT DEFAULT ''"}, {"system_settings", "pg_max_conns", "INTEGER DEFAULT 50"}, @@ -334,6 +356,8 @@ func (db *DB) migrateSQLite(ctx context.Context) error { `CREATE INDEX IF NOT EXISTS idx_usage_logs_account_status ON usage_logs(account_id, status_code);`, `CREATE INDEX IF NOT EXISTS idx_usage_logs_api_key_created_at ON usage_logs(api_key_id, created_at);`, `CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);`, + `CREATE INDEX IF NOT EXISTS idx_account_group_members_group ON account_group_members(group_id);`, + `CREATE INDEX IF NOT EXISTS idx_account_group_members_account ON account_group_members(account_id);`, `CREATE INDEX IF NOT EXISTS idx_account_model_cooldowns_reset_at ON account_model_cooldowns(reset_at);`, `CREATE INDEX IF NOT EXISTS idx_account_events_created ON account_events(created_at);`, `CREATE INDEX IF NOT EXISTS idx_account_events_type_created ON account_events(event_type, created_at);`, @@ -679,7 +703,7 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { rows, err := db.conn.QueryContext(ctx, ` SELECT created_at, total_tokens, prompt_tokens, completion_tokens, - cached_tokens, duration_ms, status_code, account_billed, user_billed + cached_tokens, first_token_ms, duration_ms, status_code, account_billed, user_billed FROM usage_logs WHERE created_at >= $1 AND status_code <> 499 `, db.timeArg(todayStart)) @@ -691,15 +715,18 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { stats := &UsageStats{} var todayErrors int64 var totalDuration float64 + var totalFirstTokenMs float64 + var totalFirstTokenSamples int64 + var todayCacheHitRequests int64 for rows.Next() { var createdRaw interface{} var totalTokens, promptTokens, completionTokens, cachedTokens int64 - var durationMs int + var firstTokenMs, durationMs int var statusCode int var accountBilled, userBilled float64 if err := rows.Scan(&createdRaw, &totalTokens, &promptTokens, &completionTokens, - &cachedTokens, &durationMs, &statusCode, &accountBilled, &userBilled); err != nil { + &cachedTokens, &firstTokenMs, &durationMs, &statusCode, &accountBilled, &userBilled); err != nil { return nil, err } createdAt, err := parseDBTimeValue(createdRaw) @@ -712,9 +739,17 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { stats.TotalPrompt += promptTokens stats.TotalCompletion += completionTokens stats.TotalCachedTokens += cachedTokens + stats.TodayCachedTokens += cachedTokens stats.TodayAccountBilled += accountBilled stats.TodayUserBilled += userBilled totalDuration += float64(durationMs) + if firstTokenMs > 0 { + totalFirstTokenMs += float64(firstTokenMs) + totalFirstTokenSamples++ + } + if cachedTokens > 0 { + todayCacheHitRequests++ + } if statusCode >= 400 { todayErrors++ @@ -732,11 +767,16 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { if stats.TodayRequests > 0 { stats.AvgDurationMs = totalDuration / float64(stats.TodayRequests) stats.ErrorRate = float64(todayErrors) / float64(stats.TodayRequests) * 100 + stats.TodayCacheRate = float64(todayCacheHitRequests) / float64(stats.TodayRequests) * 100 + } + if totalFirstTokenSamples > 0 { + stats.AvgFirstTokenMs = totalFirstTokenMs / float64(totalFirstTokenSamples) } // 可见请求总数(排除 499) - var visibleTotal int64 + var visibleTotal, visibleCacheHitRequests, visibleFirstTokenSamples int64 var currentTokens, currentPrompt, currentCompletion, currentCached int64 + var currentFirstTokenMsSum float64 var currentAccountBilled, currentUserBilled float64 _ = db.conn.QueryRowContext(ctx, ` SELECT @@ -745,19 +785,23 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0), COALESCE(SUM(cached_tokens), 0), + COALESCE(SUM(CASE WHEN cached_tokens > 0 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN first_token_ms > 0 THEN first_token_ms ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN first_token_ms > 0 THEN 1 ELSE 0 END), 0), COALESCE(SUM(account_billed), 0), COALESCE(SUM(user_billed), 0) FROM usage_logs WHERE status_code <> 499 - `).Scan(&visibleTotal, ¤tTokens, ¤tPrompt, ¤tCompletion, ¤tCached, ¤tAccountBilled, ¤tUserBilled) + `).Scan(&visibleTotal, ¤tTokens, ¤tPrompt, ¤tCompletion, ¤tCached, &visibleCacheHitRequests, ¤tFirstTokenMsSum, &visibleFirstTokenSamples, ¤tAccountBilled, ¤tUserBilled) // 基线值 - var bReq, bTok, bPrompt, bComp, bCached int64 + var bReq, bTok, bPrompt, bComp, bCached, bCacheHitRequests, bFirstTokenSamples int64 + var bFirstTokenMsSum float64 var bAccountBilled, bUserBilled float64 _ = db.conn.QueryRowContext(ctx, ` - SELECT total_requests, total_tokens, prompt_tokens, completion_tokens, cached_tokens, account_billed, user_billed + SELECT total_requests, total_tokens, prompt_tokens, completion_tokens, cached_tokens, cache_hit_requests, first_token_ms_sum, first_token_samples, account_billed, user_billed FROM usage_stats_baseline WHERE id = 1 - `).Scan(&bReq, &bTok, &bPrompt, &bComp, &bCached, &bAccountBilled, &bUserBilled) + `).Scan(&bReq, &bTok, &bPrompt, &bComp, &bCached, &bCacheHitRequests, &bFirstTokenMsSum, &bFirstTokenSamples, &bAccountBilled, &bUserBilled) stats.TotalRequests = visibleTotal + bReq stats.TotalTokens = currentTokens + bTok @@ -766,6 +810,12 @@ func (db *DB) getUsageStatsSQLite(ctx context.Context) (*UsageStats, error) { stats.TotalCachedTokens = currentCached + bCached stats.TotalAccountBilled = currentAccountBilled + bAccountBilled stats.TotalUserBilled = currentUserBilled + bUserBilled + if stats.TotalRequests > 0 { + stats.TotalCacheRate = float64(visibleCacheHitRequests+bCacheHitRequests) / float64(stats.TotalRequests) * 100 + } + if visibleFirstTokenSamples+bFirstTokenSamples > 0 { + stats.AvgFirstTokenMs = (currentFirstTokenMsSum + bFirstTokenMsSum) / float64(visibleFirstTokenSamples+bFirstTokenSamples) + } if stats.TotalRequests > 0 { stats.AvgAccountBilled = stats.TotalAccountBilled / float64(stats.TotalRequests) stats.AvgUserBilled = stats.TotalUserBilled / float64(stats.TotalRequests) diff --git a/database/sqlite_test.go b/database/sqlite_test.go index 9db2ea4a..46045fbf 100644 --- a/database/sqlite_test.go +++ b/database/sqlite_test.go @@ -107,6 +107,95 @@ func TestSQLiteAPIKeyQuotaAndExpiration(t *testing.T) { } } +func TestSQLiteUpdateAPIKeyPatchesSelectedFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "codex2api.db") + + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) 返回错误: %v", err) + } + defer db.Close() + + ctx := context.Background() + key := "sk-test-patch-1234567890" + expiresAt := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + id, err := db.InsertAPIKeyWithOptions(ctx, APIKeyInput{ + Name: "patch", + Key: key, + QuotaLimit: 1, + ExpiresAt: sql.NullTime{Time: expiresAt, Valid: true}, + AllowedGroupIDs: []int64{1, 2}, + }) + if err != nil { + t.Fatalf("InsertAPIKeyWithOptions 返回错误: %v", err) + } + + if err := db.UpdateAPIKey(ctx, id, APIKeyUpdate{Name: "patched", NameSet: true}); err != nil { + t.Fatalf("UpdateAPIKey name 返回错误: %v", err) + } + row, err := db.GetAPIKeyByValue(ctx, key) + if err != nil { + t.Fatalf("GetAPIKeyByValue 返回错误: %v", err) + } + if row.Name != "patched" || row.QuotaLimit != 1 || !row.ExpiresAt.Valid || len(row.AllowedGroupIDs) != 2 { + t.Fatalf("row = %#v, want only name patched", row) + } + + if err := db.UpdateAPIKey(ctx, id, APIKeyUpdate{ + QuotaLimitSet: true, + QuotaLimit: 0, + ExpiresAtSet: true, + ExpiresAt: sql.NullTime{}, + AllowedGroupIDsSet: true, + AllowedGroupIDs: []int64{3}, + }); err != nil { + t.Fatalf("UpdateAPIKey limits 返回错误: %v", err) + } + row, err = db.GetAPIKeyByValue(ctx, key) + if err != nil { + t.Fatalf("GetAPIKeyByValue after patch 返回错误: %v", err) + } + if row.Name != "patched" || row.QuotaLimit != 0 || row.ExpiresAt.Valid || len(row.AllowedGroupIDs) != 1 || row.AllowedGroupIDs[0] != 3 { + t.Fatalf("row = %#v, want limits/groups patched", row) + } +} + +func TestSQLiteMigratesLegacyAPIKeysColumns(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + raw, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy sqlite: %v", err) + } + if _, err := raw.Exec(`CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + t.Fatalf("create legacy api_keys: %v", err) + } + if _, err := raw.Exec(`INSERT INTO api_keys (name, key) VALUES ('legacy', 'sk-legacy-1234567890')`); err != nil { + t.Fatalf("insert legacy api key: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close legacy sqlite: %v", err) + } + + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite legacy) 返回错误: %v", err) + } + defer db.Close() + + row, err := db.GetAPIKeyByValue(context.Background(), "sk-legacy-1234567890") + if err != nil { + t.Fatalf("GetAPIKeyByValue legacy 返回错误: %v", err) + } + if row.Name != "legacy" || row.QuotaLimit != 0 || row.QuotaUsed != 0 || row.ExpiresAt.Valid || len(row.AllowedGroupIDs) != 0 { + t.Fatalf("legacy row = %#v, want migrated defaults", row) + } +} + func TestSQLiteAccountsEnabledDefaultsAndCanToggle(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "codex2api.db") @@ -455,13 +544,71 @@ func TestSQLiteUsageStatsBaselineHasBillingColumns(t *testing.T) { t.Fatalf("sqliteTableColumns 返回错误: %v", err) } - for _, name := range []string{"account_billed", "user_billed"} { + for _, name := range []string{"account_billed", "user_billed", "cache_hit_requests", "first_token_ms_sum", "first_token_samples"} { if _, ok := columns[name]; !ok { t.Fatalf("usage_stats_baseline 缺少列 %q", name) } } } +func TestDeleteAccountGroupDoesNotBroadenScopedAPIKey(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "codex2api.db") + + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) 返回错误: %v", err) + } + defer db.Close() + + ctx := context.Background() + groupA, err := db.CreateAccountGroup(ctx, "Group A", "", "#2563eb", 0) + if err != nil { + t.Fatalf("CreateAccountGroup A 返回错误: %v", err) + } + groupB, err := db.CreateAccountGroup(ctx, "Group B", "", "#16a34a", 1) + if err != nil { + t.Fatalf("CreateAccountGroup B 返回错误: %v", err) + } + + keyOnlyA, err := db.InsertAPIKeyWithOptions(ctx, APIKeyInput{ + Name: "Only A", + Key: "sk-only-a-1234567890", + AllowedGroupIDs: []int64{groupA}, + }) + if err != nil { + t.Fatalf("InsertAPIKeyWithOptions only-a 返回错误: %v", err) + } + keyAB, err := db.InsertAPIKeyWithOptions(ctx, APIKeyInput{ + Name: "A and B", + Key: "sk-a-b-1234567890", + AllowedGroupIDs: []int64{groupA, groupB}, + }) + if err != nil { + t.Fatalf("InsertAPIKeyWithOptions a-b 返回错误: %v", err) + } + + if err := db.DeleteAccountGroup(ctx, groupA, true); err != nil { + t.Fatalf("DeleteAccountGroup 返回错误: %v", err) + } + + rows, err := db.ListAPIKeys(ctx) + if err != nil { + t.Fatalf("ListAPIKeys 返回错误: %v", err) + } + + got := make(map[int64][]int64) + for _, row := range rows { + got[row.ID] = row.AllowedGroupIDs + } + + if actual := got[keyOnlyA]; len(actual) != 1 || actual[0] != groupA { + t.Fatalf("keyOnlyA allowed groups = %v, want stale [%d] to preserve deny-all semantics", actual, groupA) + } + if actual := got[keyAB]; len(actual) != 1 || actual[0] != groupB { + t.Fatalf("keyAB allowed groups = %v, want [%d]", actual, groupB) + } +} + func TestUsageLogsPersistEffectiveModel(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "codex2api.db") @@ -709,6 +856,7 @@ func TestUsageStatsIncludeCodex2APIBreakdowns(t *testing.T) { Stream: true, ServiceTier: "fast", CachedTokens: 128, + FirstTokenMs: 820, ReasoningTokens: 32, APIKeyID: 7, APIKeyName: "Claude Code", @@ -764,6 +912,18 @@ func TestUsageStatsIncludeCodex2APIBreakdowns(t *testing.T) { if stats.TotalRequests != 3 { t.Fatalf("TotalRequests = %d, want 3", stats.TotalRequests) } + if stats.TodayCachedTokens != 128 { + t.Fatalf("TodayCachedTokens = %d, want 128", stats.TodayCachedTokens) + } + if stats.TodayCacheRate < 33.3 || stats.TodayCacheRate > 33.4 { + t.Fatalf("TodayCacheRate = %.4f, want about 33.33", stats.TodayCacheRate) + } + if stats.TotalCacheRate < 33.3 || stats.TotalCacheRate > 33.4 { + t.Fatalf("TotalCacheRate = %.4f, want about 33.33", stats.TotalCacheRate) + } + if stats.AvgFirstTokenMs != 820 { + t.Fatalf("AvgFirstTokenMs = %.2f, want 820", stats.AvgFirstTokenMs) + } features := stats.FeatureStats if features.StreamRequests != 1 || features.SyncRequests != 2 || features.FastRequests != 1 || features.CacheHitRequests != 1 || features.ReasoningRequests != 1 || features.ImageRequests != 1 || @@ -791,6 +951,64 @@ func TestUsageStatsIncludeCodex2APIBreakdowns(t *testing.T) { } } +func TestUsageStatsBaselinePreservesCacheRateAndFirstTokenAfterClear(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "codex2api.db") + + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) 返回错误: %v", err) + } + defer db.Close() + + ctx := context.Background() + for _, usageLog := range []*UsageLogInput{ + { + AccountID: 1, + Endpoint: "/v1/responses", + Model: "gpt-5.5", + StatusCode: 200, + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + CachedTokens: 32, + FirstTokenMs: 600, + }, + { + AccountID: 1, + Endpoint: "/v1/responses", + Model: "gpt-5.5", + StatusCode: 200, + InputTokens: 80, + OutputTokens: 20, + TotalTokens: 100, + FirstTokenMs: 300, + }, + } { + if err := db.InsertUsageLog(ctx, usageLog); err != nil { + t.Fatalf("InsertUsageLog 返回错误: %v", err) + } + } + db.flushLogs() + + if err := db.ClearUsageLogs(ctx); err != nil { + t.Fatalf("ClearUsageLogs 返回错误: %v", err) + } + + stats, err := db.GetUsageStats(ctx) + if err != nil { + t.Fatalf("GetUsageStats 返回错误: %v", err) + } + if stats.TotalRequests != 2 { + t.Fatalf("TotalRequests = %d, want 2", stats.TotalRequests) + } + if stats.TotalCacheRate < 49.9 || stats.TotalCacheRate > 50.1 { + t.Fatalf("TotalCacheRate = %.4f, want about 50.00", stats.TotalCacheRate) + } + if stats.AvgFirstTokenMs < 449.9 || stats.AvgFirstTokenMs > 450.1 { + t.Fatalf("AvgFirstTokenMs = %.4f, want about 450.00", stats.AvgFirstTokenMs) + } +} + func TestSoftDeleteAccountMarksDeletedStatus(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "codex2api.db") diff --git a/docs/API.md b/docs/API.md index 1bf1839c..b0d8097f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -35,6 +35,7 @@ Codex2API 提供兼容 OpenAI 风格的 API 接口,同时包含完整的管理 **Base URL:** `http://localhost:8080` (默认端口) **请求格式:** + - 请求头: `Content-Type: application/json` - 认证头: `Authorization: Bearer ` @@ -47,11 +48,13 @@ Codex2API 提供兼容 OpenAI 风格的 API 接口,同时包含完整的管理 公共 API (`/v1/*`) 需要 API Key 进行认证。 **请求头:** + ```http Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx ``` **配置方式:** + 1. 通过管理后台 `/admin/settings` 页面配置 2. 如果没有配置任何 API Key,则 `/v1/*` 接口跳过鉴权(开发模式) @@ -60,6 +63,7 @@ Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx 管理 API (`/api/admin/*`) 需要 Admin Secret 进行认证。 **请求头:** + ```http X-Admin-Key: your-admin-secret ``` @@ -71,6 +75,7 @@ Authorization: Bearer your-admin-secret ``` **配置方式:** + - 环境变量: `ADMIN_SECRET` - 数据库: 通过管理后台设置 @@ -85,12 +90,13 @@ Authorization: Bearer your-admin-secret **说明:** OpenAI 风格的 Chat Completions 接口,支持流式和非流式响应。 **请求示例:** + ```json { "model": "gpt-5.4", "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"} + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": "Hello!" } ], "stream": false, "reasoning_effort": "medium", @@ -100,17 +106,18 @@ Authorization: Bearer your-admin-secret **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| model | string | 是 | 模型名称,见 [支持模型](#支持模型) | -| messages | array | 是 | 消息列表 | -| stream | boolean | 否 | 是否启用流式响应,默认 false | -| reasoning_effort | string | 否 | 推理强度: low/medium/high | -| service_tier | string | 否 | 服务等级: fast/auto | -| max_tokens | integer | 否 | 最大输出 token 数(Codex 不支持,会被过滤) | -| temperature | float | 否 | 温度参数(Codex 不支持,会被过滤) | +| 参数 | 类型 | 必填 | 说明 | +| ---------------- | ------- | ---- | ------------------------------------------- | +| model | string | 是 | 模型名称,见 [支持模型](#支持模型) | +| messages | array | 是 | 消息列表 | +| stream | boolean | 否 | 是否启用流式响应,默认 false | +| reasoning_effort | string | 否 | 推理强度: low/medium/high | +| service_tier | string | 否 | 服务等级: fast/auto | +| max_tokens | integer | 否 | 最大输出 token 数(Codex 不支持,会被过滤) | +| temperature | float | 否 | 温度参数(Codex 不支持,会被过滤) | **非流式响应示例:** + ```json { "id": "chatcmpl-xxxxxxxx", @@ -136,6 +143,7 @@ Authorization: Bearer your-admin-secret ``` **流式响应示例:** + ``` data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1712345678,"model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} @@ -155,12 +163,13 @@ data: [DONE] **说明:** Codex 原生 Responses 接口,直接透传,无需协议翻译。 **请求示例:** + ```json { "model": "gpt-5.4", "input": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello!"} + { "role": "system", "content": "You are a helpful assistant." }, + { "role": "user", "content": "Hello!" } ], "stream": false, "reasoning": { @@ -173,17 +182,18 @@ data: [DONE] **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| model | string | 是 | 模型名称 | -| input | array/string | 是 | 输入内容(支持数组或字符串) | -| stream | boolean | 否 | 是否启用流式响应,默认 false。仅当显式传 `stream=true` 时返回 SSE(流式响应),否则返回普通 JSON。 | -| reasoning.effort | string | 否 | 推理强度: low/medium/high | -| service_tier | string | 否 | 服务等级: fast/auto | -| include | array | 否 | 包含的额外字段 | -| previous_response_id | string | 否 | 上一响应 ID,用于上下文连续 | +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------------ | ---- | -------------------------------------------------------------------------------------------------- | +| model | string | 是 | 模型名称 | +| input | array/string | 是 | 输入内容(支持数组或字符串) | +| stream | boolean | 否 | 是否启用流式响应,默认 false。仅当显式传 `stream=true` 时返回 SSE(流式响应),否则返回普通 JSON。 | +| reasoning.effort | string | 否 | 推理强度: low/medium/high | +| service_tier | string | 否 | 服务等级: fast/auto | +| include | array | 否 | 包含的额外字段 | +| previous_response_id | string | 否 | 上一响应 ID,用于上下文连续 | **响应示例:** + ```json { "id": "resp_xxxxxxxx", @@ -219,6 +229,7 @@ data: [DONE] **说明:** OpenAI Images 兼容入口。外部请求使用 `gpt-image-2`,内部按 `CLIProxyAPI/` 与 `sub2api/` 的链路转换为 Codex `/responses`:主模型为 `gpt-5.4-mini`,图像模型写入 `tools[0].model`。 **请求示例:** + ```json { "model": "gpt-image-2", @@ -236,18 +247,18 @@ data: [DONE] **说明:** 支持 JSON `images[].image_url` 和 multipart `image` / `image[]` 上传。`mask.image_url` 或 multipart `mask` 可用于遮罩编辑。 **JSON 请求示例:** + ```json { "model": "gpt-image-2", "prompt": "Replace the background with aurora lights", - "images": [ - {"image_url": "https://example.com/source.png"} - ], + "images": [{ "image_url": "https://example.com/source.png" }], "output_format": "png" } ``` **响应示例:** + ```json { "created": 1710000000, @@ -270,17 +281,18 @@ data: [DONE] **说明:** 获取支持的模型列表。 **响应示例:** + ```json { "object": "list", "data": [ - {"id": "gpt-5.5", "object": "model", "owned_by": "openai"}, - {"id": "gpt-5.4", "object": "model", "owned_by": "openai"}, - {"id": "gpt-5.4-mini", "object": "model", "owned_by": "openai"}, - {"id": "gpt-5.3-codex", "object": "model", "owned_by": "openai"}, - {"id": "gpt-5.3-codex-spark", "object": "model", "owned_by": "openai"}, - {"id": "gpt-5.2", "object": "model", "owned_by": "openai"}, - {"id": "gpt-image-2", "object": "model", "owned_by": "openai"} + { "id": "gpt-5.5", "object": "model", "owned_by": "openai" }, + { "id": "gpt-5.4", "object": "model", "owned_by": "openai" }, + { "id": "gpt-5.4-mini", "object": "model", "owned_by": "openai" }, + { "id": "gpt-5.3-codex", "object": "model", "owned_by": "openai" }, + { "id": "gpt-5.3-codex-spark", "object": "model", "owned_by": "openai" }, + { "id": "gpt-5.2", "object": "model", "owned_by": "openai" }, + { "id": "gpt-image-2", "object": "model", "owned_by": "openai" } ] } ``` @@ -292,6 +304,7 @@ data: [DONE] **说明:** 健康检查端点,返回服务状态。 **响应示例:** + ```json { "status": "ok", @@ -313,6 +326,7 @@ data: [DONE] 获取仪表盘统计数据。 **响应:** + ```json { "total": 10, @@ -327,6 +341,7 @@ data: [DONE] 系统健康检查(扩展版)。 **响应:** + ```json { "status": "ok", @@ -342,6 +357,7 @@ data: [DONE] 获取账号列表。 **响应:** + ```json { "accounts": [ @@ -391,21 +407,22 @@ data: [DONE] 字段说明补充: -| 字段 | 类型 | 说明 | -|------|------|------| -| scheduler_score | number | 原始健康分,仅反映动态调度健康状态 | -| dispatch_score | number | 最终用于调度排序的分数;优先读取运行时快照 | -| score_bias_override | integer/null | 手工配置的总加权分覆盖值,`null` 表示跟随套餐默认 | -| score_bias_effective | integer | 当前生效的加权分 | -| base_concurrency_override | integer/null | 手工配置的基础并发覆盖值,`null` 表示跟随全局 `max_concurrency` | -| base_concurrency_effective | integer | 当前生效的基础并发值 | -| allowed_api_key_ids | integer[] | 允许调用该账号的 API Key ID 列表;空数组表示所有 API Key 均可调用 | +| 字段 | 类型 | 说明 | +| -------------------------- | ------------ | ----------------------------------------------------------------- | +| scheduler_score | number | 原始健康分,仅反映动态调度健康状态 | +| dispatch_score | number | 最终用于调度排序的分数;优先读取运行时快照 | +| score_bias_override | integer/null | 手工配置的总加权分覆盖值,`null` 表示跟随套餐默认 | +| score_bias_effective | integer | 当前生效的加权分 | +| base_concurrency_override | integer/null | 手工配置的基础并发覆盖值,`null` 表示跟随全局 `max_concurrency` | +| base_concurrency_effective | integer | 当前生效的基础并发值 | +| allowed_api_key_ids | integer[] | 允许调用该账号的 API Key ID 列表;空数组表示所有 API Key 均可调用 | #### PATCH /api/admin/accounts/:id/scheduler 更新账号调度配置。 **请求:** + ```json { "score_bias_override": 80, @@ -426,13 +443,14 @@ data: [DONE] **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| score_bias_override | integer/null | 否 | 总加权分覆盖值,范围 `-200..200`,`null` 表示恢复套餐默认 | -| base_concurrency_override | integer/null | 否 | 基础并发覆盖值,范围 `1..50`,`null` 表示恢复全局默认 | -| allowed_api_key_ids | integer[]/null | 否 | 允许调用该账号的 API Key ID 列表,去重升序保存;字段省略时保持原值,传 `null` 或 `[]` 表示恢复为全部可调用 | +| 参数 | 类型 | 必填 | 说明 | +| ------------------------- | -------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| score_bias_override | integer/null | 否 | 总加权分覆盖值,范围 `-200..200`,`null` 表示恢复套餐默认 | +| base_concurrency_override | integer/null | 否 | 基础并发覆盖值,范围 `1..50`,`null` 表示恢复全局默认 | +| allowed_api_key_ids | integer[]/null | 否 | 允许调用该账号的 API Key ID 列表,去重升序保存;字段省略时保持原值,传 `null` 或 `[]` 表示恢复为全部可调用 | **响应:** + ```json { "message": "账号调度配置已更新" @@ -444,6 +462,7 @@ data: [DONE] 添加 Refresh Token 账号(支持批量)。 **请求:** + ```json { "name": "my-account", @@ -454,13 +473,14 @@ data: [DONE] **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| name | string | 否 | 账号名称,批量时自动追加序号,默认 `account-{n}` | -| refresh_token | string | 是 | Refresh Token,多个用 `\n` 换行分隔(单次最多 100 个) | -| proxy_url | string | 否 | 代理 URL | +| 参数 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ------------------------------------------------------ | +| name | string | 否 | 账号名称,批量时自动追加序号,默认 `account-{n}` | +| refresh_token | string | 是 | Refresh Token,多个用 `\n` 换行分隔(单次最多 100 个) | +| proxy_url | string | 否 | 代理 URL | 批量添加(使用换行分隔): + ```json { "name": "batch", @@ -470,6 +490,7 @@ data: [DONE] ``` **响应:** + ```json { "message": "成功添加 3 个账号", @@ -481,6 +502,7 @@ data: [DONE] **curl 示例:** 单个添加: + ```bash curl -X POST http://localhost:8080/api/admin/accounts \ -H "X-Admin-Key: your-admin-secret" \ @@ -489,6 +511,7 @@ curl -X POST http://localhost:8080/api/admin/accounts \ ``` 批量添加(换行分隔): + ```bash curl -X POST http://localhost:8080/api/admin/accounts \ -H "X-Admin-Key: your-admin-secret" \ @@ -503,6 +526,7 @@ curl -X POST http://localhost:8080/api/admin/accounts \ 添加 Access Token(AT-only)账号(支持批量)。适用于只有 AT 没有 RT 的场景。 **请求:** + ```json { "name": "my-at-account", @@ -513,13 +537,14 @@ curl -X POST http://localhost:8080/api/admin/accounts \ **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| name | string | 否 | 账号名称,批量时自动追加序号,默认 `at-account-{n}` | -| access_token | string | 是 | Access Token,多个用 `\n` 换行分隔(单次最多 100 个) | -| proxy_url | string | 否 | 代理 URL | +| 参数 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | ----------------------------------------------------- | +| name | string | 否 | 账号名称,批量时自动追加序号,默认 `at-account-{n}` | +| access_token | string | 是 | Access Token,多个用 `\n` 换行分隔(单次最多 100 个) | +| proxy_url | string | 否 | 代理 URL | 批量添加: + ```json { "name": "batch-at", @@ -529,6 +554,7 @@ curl -X POST http://localhost:8080/api/admin/accounts \ ``` **响应:** + ```json { "message": "成功添加 3 个 AT 账号", @@ -553,6 +579,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ 删除账号(软删除,标记为 deleted)。 **响应:** + ```json { "message": "账号已删除" @@ -564,6 +591,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ 手动刷新账号 Access Token。 **响应:** + ```json { "message": "账号刷新成功" @@ -575,6 +603,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ 测试账号连接。 **响应:** + ```json { "success": true, @@ -588,6 +617,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ 获取单个账号用量统计。 **响应:** + ```json { "id": 1, @@ -604,36 +634,39 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ 批量导入账号(支持 TXT/JSON/AT-TXT 三种格式)。 **请求:** + - Method: POST - Content-Type: multipart/form-data **Form 字段:** -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| file | file | 是 | 上传文件(最大 2MB,JSON 格式支持多文件) | -| format | string | 否 | 文件格式:`txt`(默认)、`json`、`at_txt` | -| proxy_url | string | 否 | 代理 URL | +| 字段 | 类型 | 必填 | 说明 | +| --------- | ------ | ---- | ----------------------------------------- | +| file | file | 是 | 上传文件(最大 2MB,JSON 格式支持多文件) | +| format | string | 否 | 文件格式:`txt`(默认)、`json`、`at_txt` | +| proxy_url | string | 否 | 代理 URL | **format 格式说明:** - **`txt`** — 每行一个 Refresh Token: - ``` + + ```text rt_xxxxxx1 rt_xxxxxx2 rt_xxxxxx3 ``` - **`json`** — CLIProxyAPI 凭证 JSON 格式(支持数组或单对象): + ```json [ - {"refresh_token": "rt_xxx1", "email": "user1@example.com"}, - {"refresh_token": "rt_xxx2", "email": "user2@example.com"} + { "refresh_token": "rt_xxx1", "email": "user1@example.com" }, + { "refresh_token": "rt_xxx2", "email": "user2@example.com" } ] ``` - **`at_txt`** — 每行一个 Access Token(AT-only 模式): - ``` + ```text eyJhbGciOiJSUzI1NiIs...token1 eyJhbGciOiJSUzI1NiIs...token2 ``` @@ -643,6 +676,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/at \ **curl 示例:** 导入 RT(TXT 格式): + ```bash curl -X POST http://localhost:8080/api/admin/accounts/import \ -H "X-Admin-Key: your-admin-secret" \ @@ -652,6 +686,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/import \ ``` 导入 RT(JSON 格式): + ```bash curl -X POST http://localhost:8080/api/admin/accounts/import \ -H "X-Admin-Key: your-admin-secret" \ @@ -660,6 +695,7 @@ curl -X POST http://localhost:8080/api/admin/accounts/import \ ``` 导入 AT(AT-TXT 格式): + ```bash curl -X POST http://localhost:8080/api/admin/accounts/import \ -H "X-Admin-Key: your-admin-secret" \ @@ -668,13 +704,15 @@ curl -X POST http://localhost:8080/api/admin/accounts/import \ ``` **响应:** SSE 流式进度 -``` + +```text data: {"type":"progress","current":5,"total":10,"success":3,"duplicate":1,"failed":1} data: {"type":"complete","current":10,"total":10,"success":8,"duplicate":1,"failed":1} ``` 若所有 Token 均已存在,返回普通 JSON(非 SSE): + ```json { "message": "所有 10 个 RT 已存在,无需导入", @@ -690,6 +728,7 @@ data: {"type":"complete","current":10,"total":10,"success":8,"duplicate":1,"fail 批量测试账号连接。 **请求:** + ```json { "ids": [1, 2, 3], @@ -698,6 +737,7 @@ data: {"type":"complete","current":10,"total":10,"success":8,"duplicate":1,"fail ``` **响应:** SSE 流式进度 + ``` data: {"type":"progress","current":3,"total":3,"success":2,"failed":1} @@ -709,6 +749,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 清理 Unauthorized(401)账号。 **响应:** + ```json { "message": "已清理 5 个账号", @@ -729,11 +770,13 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 导出账号(标准 JSON 格式)。 **查询参数:** + - `filter`: healthy (只导出健康账号) - `ids`: 1,2,3 (指定 ID 列表) - `remote`: true (远程迁移模式) **响应:** + ```json [ { @@ -754,6 +797,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 从远程 codex2api 实例迁移账号。 **请求:** + ```json { "url": "http://remote-instance:8080", @@ -768,16 +812,16 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取账号增删趋势。 **查询参数:** + - `start`: RFC3339 格式开始时间 - `end`: RFC3339 格式结束时间 - `bucket_minutes`: 聚合桶大小(默认 60) **响应:** + ```json { - "trend": [ - {"timestamp": "2024-01-01T00:00:00Z", "added": 5, "deleted": 0} - ] + "trend": [{ "timestamp": "2024-01-01T00:00:00Z", "added": 5, "deleted": 0 }] } ``` @@ -788,6 +832,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取使用统计。 **响应:** + ```json { "total_requests": 10000, @@ -805,6 +850,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取使用日志。 **查询参数:** + - `start`: RFC3339 开始时间 - `end`: RFC3339 结束时间 - `page`: 页码 @@ -817,6 +863,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} - `stream`: true/false (是否流式) **响应:** + ```json { "logs": [ @@ -847,11 +894,13 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取图表聚合数据。 **查询参数:** + - `start`: RFC3339 开始时间 - `end`: RFC3339 结束时间 - `bucket_minutes`: 聚合桶大小(默认 5) **响应:** + ```json { "buckets": [ @@ -872,6 +921,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 清空使用日志。 **响应:** + ```json { "message": "日志已清空" @@ -882,16 +932,23 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} #### GET /api/admin/keys -获取所有 API 密钥。 +获取所有 API 密钥。管理接口需要 `X-Admin-Key`,这些接口不属于对外 `/v1/*` 客户端 API。该接口会在 `raw_key` 返回完整密钥,只能在受信任后台使用。 **响应:** + ```json { "keys": [ { "id": 1, - "name": "default", - "key": "sk-xxxxxxxxxxxxxxxxxxxxxxxx", + "name": "Claude Code", + "key": "sk-****...abcd", + "raw_key": "sk-live-full-key", + "quota_limit": 10, + "quota_used": 1.25, + "expires_at": "2026-06-01T00:00:00Z", + "allowed_group_ids": [1], + "status": "active", "created_at": "2024-01-01T00:00:00Z" } ] @@ -903,19 +960,68 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 创建新 API 密钥。 **请求:** + ```json { "name": "production", - "key": "sk-custom-key" // 可选,不填则自动生成 + "key": "sk-custom-key", + "quota_limit": 10, + "expires_in_days": 30, + "allowed_group_ids": [1] } ``` +| 字段 | 类型 | 必填 | 说明 | +| ------------------- | --------- | ---- | -------------------------------------- | +| name | string | 是 | 显示名称 | +| key | string | 否 | 自定义密钥;省略则自动生成 | +| quota_limit / quota | number | 否 | 额度上限,0 或省略表示不限额 | +| expires_at | string | 否 | RFC3339 或本地日期时间 | +| expires_in_days | number | 否 | N 天后过期;0 表示不过期 | +| allowed_group_ids | integer[] | 否 | 允许调度的账号分组;空数组表示全部分组 | + **响应:** + ```json { "id": 2, "key": "sk-xxxxxxxxxxxxxxxxxxxxxxxx", - "name": "production" + "name": "production", + "quota_limit": 10, + "quota_used": 0, + "expires_at": "2026-06-12T00:00:00Z", + "allowed_group_ids": [1] +} +``` + +#### PATCH /api/admin/keys/:id + +编辑 API 密钥名称、额度、过期时间和允许账号分组。字段省略时保持原值。 + +**请求:** + +```json +{ + "name": "Cherry Studio", + "quota_limit": 25, + "expires_at": null, + "allowed_group_ids": [] +} +``` + +| 字段 | 类型 | 说明 | +| ------------------- | ----------- | -------------------------------------- | +| name | string | 新显示名称 | +| quota_limit / quota | number/null | 新额度上限;0 或 null 清除额度限制 | +| expires_at | string/null | 新过期时间;null 清除过期时间 | +| expires_in_days | number | N 天后过期;0 清除过期时间 | +| allowed_group_ids | integer[] | 允许调度的账号分组;空数组表示全部分组 | + +**响应:** + +```json +{ + "message": "API Key 已更新" } ``` @@ -924,12 +1030,104 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 删除 API 密钥。 **响应:** + ```json { "message": "已删除" } ``` +### 账号分组管理 + +账号分组用于把账号池划分为多个可调度集合。API Key 的 `allowed_group_ids` 可以限制下游密钥只能使用指定分组;账号自己的 `allowed_api_key_ids` 也可以反向限制哪些 API Key 能调度该账号。 + +#### GET /api/admin/account-groups + +获取账号分组。 + +**响应:** + +```json +{ + "groups": [ + { + "id": 1, + "name": "Team", + "description": "付费团队账号", + "color": "#2563eb", + "sort_order": 0, + "member_count": 8, + "created_at": "2026-05-13T00:00:00Z", + "updated_at": "2026-05-13T00:00:00Z" + } + ] +} +``` + +#### POST /api/admin/account-groups + +创建账号分组。 + +**请求:** + +```json +{ + "name": "Team", + "description": "付费团队账号", + "color": "#2563eb", + "sort_order": 0 +} +``` + +**响应:** + +```json +{ + "id": 1, + "message": "分组已创建" +} +``` + +#### PATCH /api/admin/account-groups/:id + +编辑账号分组。 + +**请求:** + +```json +{ + "name": "Team Plus", + "description": "高优先级账号", + "color": "#16a34a", + "sort_order": 10 +} +``` + +**响应:** + +```json +{ + "message": "分组已更新" +} +``` + +#### DELETE /api/admin/account-groups/:id + +删除账号分组。分组仍有成员时需要 `?force=true`;删除后会从账号关系中移除该 ID,并尽量从 API Key 允许分组中清理。若某个 API Key 仅绑定该分组,为避免权限被意外放大,会保留为缺失分组状态。 + +```bash +curl -X DELETE "http://localhost:8080/api/admin/account-groups/1?force=true" \ + -H "X-Admin-Key: your-secret" +``` + +**响应:** + +```json +{ + "message": "分组已删除" +} +``` + ### 系统设置 #### GET /api/admin/settings @@ -937,6 +1135,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取系统设置。 **响应:** + ```json { "max_concurrency": 2, @@ -968,6 +1167,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 更新系统设置。 **请求:** + ```json { "max_concurrency": 4, @@ -990,6 +1190,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取代理列表。 **响应:** + ```json { "proxies": [ @@ -1011,6 +1212,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 添加代理(支持批量)。 **请求:** + ```json { "urls": ["http://proxy1.example.com:8080", "http://proxy2.example.com:8080"], @@ -1019,6 +1221,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} ``` 或单条: + ```json { "url": "http://proxy.example.com:8080", @@ -1035,6 +1238,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 更新代理。 **请求:** + ```json { "label": "New Label", @@ -1047,6 +1251,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 批量删除代理。 **请求:** + ```json { "ids": [1, 2, 3] @@ -1058,15 +1263,17 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 测试代理连通性。 **请求:** + ```json { "url": "http://proxy.example.com:8080", - "id": 1, // 可选,用于持久化测试结果 + "id": 1, // 可选,用于持久化测试结果 "lang": "zh-CN" } ``` **响应:** + ```json { "success": true, @@ -1087,6 +1294,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取系统运维概览。 **响应:** + ```json { "updated_at": "2024-01-01T12:00:00Z", @@ -1152,6 +1360,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 获取当前启用的模型列表,并返回模型注册表元数据。 **响应:** + ```json { "models": [ @@ -1183,6 +1392,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 从 OpenAI 官方 Codex 模型页同步模型注册表。同步只新增或更新模型元数据,不会自动删除本地模型;`gpt-image-2` 始终作为内置图像模型保留。 **响应:** + ```json { "added": 0, @@ -1214,6 +1424,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 生成 OAuth 授权 URL(PKCE 模式)。 **请求:** + ```json { "proxy_url": "http://proxy.example.com:8080", @@ -1223,12 +1434,13 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| proxy_url | string | 否 | 账号使用的代理 URL | -| redirect_uri | string | 否 | 回调地址,默认为系统内置地址 | +| 参数 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | ---------------------------- | +| proxy_url | string | 否 | 账号使用的代理 URL | +| redirect_uri | string | 否 | 回调地址,默认为系统内置地址 | **响应:** + ```json { "auth_url": "https://auth.openai.com/authorize?response_type=code&client_id=...&state=...", @@ -1243,6 +1455,7 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} 用授权码兑换 Token,自动创建新账号并加入号池。 **请求:** + ```json { "session_id": "a1b2c3d4e5f6...", @@ -1255,15 +1468,16 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} **参数说明:** -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| session_id | string | 是 | `generate-auth-url` 返回的 session_id | -| code | string | 是 | 授权回调 URL 中的 `code` 参数 | -| state | string | 是 | 授权回调 URL 中的 `state` 参数 | -| name | string | 否 | 账号名称,默认使用邮箱或 `oauth-account` | -| proxy_url | string | 否 | 代理 URL,覆盖生成 URL 时的设置 | +| 参数 | 类型 | 必填 | 说明 | +| ---------- | ------ | ---- | ---------------------------------------- | +| session_id | string | 是 | `generate-auth-url` 返回的 session_id | +| code | string | 是 | 授权回调 URL 中的 `code` 参数 | +| state | string | 是 | 授权回调 URL 中的 `state` 参数 | +| name | string | 否 | 账号名称,默认使用邮箱或 `oauth-account` | +| proxy_url | string | 否 | 代理 URL,覆盖生成 URL 时的设置 | **响应:** + ```json { "message": "OAuth 账号 user@example.com 添加成功", @@ -1277,36 +1491,37 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} ## 支持模型 -| 模型 | 说明 | -|------|------| -| gpt-5.5 | 最新旗舰模型 | -| gpt-5.4 | 旗舰模型 | -| gpt-5.4-mini | 轻量版 | -| gpt-5.3-codex | 较新版本 | +| 模型 | 说明 | +| ------------------- | --------------------------------------- | +| gpt-5.5 | 最新旗舰模型 | +| gpt-5.4 | 旗舰模型 | +| gpt-5.4-mini | 轻量版 | +| gpt-5.3-codex | 较新版本 | | gpt-5.3-codex-spark | Codex Spark 模型,仅 Pro 订阅账号可调用 | -| gpt-5.2 | 兼容保留模型 | -| gpt-image-2 | GPT Image 2 图像生成模型 | +| gpt-5.2 | 兼容保留模型 | +| gpt-image-2 | GPT Image 2 图像生成模型 | > 提示:实际支持的模型以 `/v1/models` 接口返回为准,文档可能未及时更新。 + --- ## 错误码 ### HTTP 状态码 -| 状态码 | 说明 | -|--------|------| -| 200 | 请求成功 | -| 400 | 请求参数错误 | -| 401 | 认证失败 | -| 403 | 权限不足 | -| 404 | 资源不存在 | -| 429 | 请求过于频繁(限流) | -| 499 | 客户端断开连接 | -| 500 | 服务器内部错误 | -| 502 | 网关错误(上游服务异常) | -| 503 | 服务不可用(账号池耗尽) | -| 598 | 上游流中断 | +| 状态码 | 说明 | +| ------ | ------------------------ | +| 200 | 请求成功 | +| 400 | 请求参数错误 | +| 401 | 认证失败 | +| 403 | 权限不足 | +| 404 | 资源不存在 | +| 429 | 请求过于频繁(限流) | +| 499 | 客户端断开连接 | +| 500 | 服务器内部错误 | +| 502 | 网关错误(上游服务异常) | +| 503 | 服务不可用(账号池耗尽) | +| 598 | 上游流中断 | ### 错误响应格式 @@ -1322,17 +1537,17 @@ data: {"type":"complete","current":3,"total":3,"success":2,"failed":1} ### 常见错误代码 -| 代码 | 说明 | 处理建议 | -|------|------|----------| -| missing_api_key | 缺少 API Key | 添加 Authorization 请求头 | -| invalid_api_key | API Key 无效 | 检查密钥是否正确 | -| authentication_error | 认证错误 | 检查 Admin Secret 或 API Key | -| invalid_request_error | 请求参数错误 | 检查请求体格式 | -| server_error | 服务器错误 | 查看日志排查问题 | -| upstream_error | 上游服务错误 | 检查 Codex 服务状态 | -| no_available_account | 当前无可调度账号 | 稍后重试、启用账号或补充可用账号 | -| account_pool_usage_limit_reached | 账号池额度耗尽 | 等待冷却或添加新账号 | -| rate_limit_exceeded | 限流触发 | 降低请求频率 | +| 代码 | 说明 | 处理建议 | +| -------------------------------- | ---------------- | -------------------------------- | +| missing_api_key | 缺少 API Key | 添加 Authorization 请求头 | +| invalid_api_key | API Key 无效 | 检查密钥是否正确 | +| authentication_error | 认证错误 | 检查 Admin Secret 或 API Key | +| invalid_request_error | 请求参数错误 | 检查请求体格式 | +| server_error | 服务器错误 | 查看日志排查问题 | +| upstream_error | 上游服务错误 | 检查 Codex 服务状态 | +| no_available_account | 当前无可调度账号 | 稍后重试、启用账号或补充可用账号 | +| account_pool_usage_limit_reached | 账号池额度耗尽 | 等待冷却或添加新账号 | +| rate_limit_exceeded | 限流触发 | 降低请求频率 | --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 77ef7306..0aea7f3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "react-i18next": "^16.6.6", "react-router-dom": "^7.1.0", "recharts": "^3.8.0", + "shiki": "^3.23.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2" }, @@ -1801,9 +1802,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1820,9 +1818,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1839,9 +1834,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1858,9 +1850,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1877,9 +1866,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1896,9 +1882,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1981,6 +1964,73 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2323,6 +2373,24 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", @@ -2343,12 +2411,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2387,6 +2467,36 @@ "node": ">=10" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2408,6 +2518,16 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", @@ -2555,6 +2675,15 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2570,6 +2699,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2645,6 +2787,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2654,6 +2832,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/i18next": { "version": "25.10.9", "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.9.tgz", @@ -2980,6 +3168,116 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2998,6 +3296,23 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3044,6 +3359,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", @@ -3351,6 +3676,30 @@ "redux": "^5.0.0" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", @@ -3408,6 +3757,22 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3417,6 +3782,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -3468,6 +3857,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -3488,6 +3887,74 @@ "node": ">=14.17" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -3540,6 +4007,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -3647,6 +4142,16 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index df7af35d..e112835e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "react-i18next": "^16.6.6", "react-router-dom": "^7.1.0", "recharts": "^3.8.0", + "shiki": "^3.23.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fb6d03b..8466c7e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,7 @@ const OperationsErrors = lazy(() => import('./pages/OperationsErrors')) const Proxies = lazy(() => import('./pages/Proxies')) const SchedulerBoard = lazy(() => import('./pages/SchedulerBoard')) const Settings = lazy(() => import('./pages/Settings')) -const Guide = lazy(() => import('./pages/Guide')) -const ApiReference = lazy(() => import('./pages/ApiReference')) +const Docs = lazy(() => import('./pages/Docs')) const APIKeys = lazy(() => import('./pages/APIKeys')) const Usage = lazy(() => import('./pages/Usage')) const ImageStudio = lazy(() => import('./pages/ImageStudio')) @@ -42,8 +41,9 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> + } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 18501e38..5d79e9a9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -38,10 +38,15 @@ import type { CPAExportEntry, SystemSettings, UpdateAccountSchedulerRequest, + UpdateAPIKeyRequest, UpdateOpenAIResponsesAccountRequest, UsageLogsResponse, UsageLogsPagedResponse, UsageStats, + AccountGroup, + AccountGroupsResponse, + CreateAccountGroupRequest, + UpdateAccountGroupRequest, } from './types' const BASE = '/api/admin' @@ -100,6 +105,7 @@ async function request(path: string, options: RequestInit = {}): Promise { const res = await fetch(BASE + path, { ...options, + cache: options.cache ?? 'no-store', headers, }) @@ -201,6 +207,13 @@ export const api = { request(`/accounts/${id}/refresh`, { method: 'POST' }), updateAccountScheduler: (id: number, data: UpdateAccountSchedulerRequest) => request(`/accounts/${id}/scheduler`, { method: 'PATCH', body: JSON.stringify(data) }), + listAccountGroups: () => request('/account-groups'), + createAccountGroup: (data: CreateAccountGroupRequest) => + request<{ id: number; message: string }>('/account-groups', { method: 'POST', body: JSON.stringify(data) }), + updateAccountGroup: (id: number, data: UpdateAccountGroupRequest) => + request(`/account-groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteAccountGroup: (id: number, force = false) => + request(`/account-groups/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }), toggleAccountEnabled: (id: number, enabled: boolean) => request(`/accounts/${id}/enable`, { method: 'POST', body: JSON.stringify({ enabled }) }), toggleAccountLock: (id: number, locked: boolean) => @@ -311,6 +324,8 @@ export const api = { }), deleteAPIKey: (id: number) => request(`/keys/${id}`, { method: 'DELETE' }), + updateAPIKey: (id: number, data: UpdateAPIKeyRequest) => + request(`/keys/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), getImagePromptTemplates: (params: { q?: string; tag?: string } = {}) => { const sp = new URLSearchParams() if (params.q) sp.set('q', params.q) @@ -424,7 +439,7 @@ export const api = { request<{ message: string; inserted: number; total: number }>('/proxies', { method: 'POST', body: JSON.stringify(data) }), deleteProxy: (id: number) => request(`/proxies/${id}`, { method: 'DELETE' }), - updateProxy: (id: number, data: { label?: string; enabled?: boolean }) => + updateProxy: (id: number, data: { url?: string; label?: string; enabled?: boolean }) => request(`/proxies/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), batchDeleteProxies: (ids: number[]) => request<{ message: string; deleted: number }>('/proxies/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) }), diff --git a/frontend/src/components/ChipInput.tsx b/frontend/src/components/ChipInput.tsx new file mode 100644 index 00000000..79843a27 --- /dev/null +++ b/frontend/src/components/ChipInput.tsx @@ -0,0 +1,219 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ChangeEvent, +} from "react"; +import { X, ChevronDown } from "lucide-react"; + +export interface ChipInputProps { + value: string[]; + onChange: (next: string[]) => void; + /** Pre-defined options for select-from-list mode */ + options?: string[]; + placeholder?: string; + disabled?: boolean; + maxVisible?: number; + className?: string; +} + +/** + * Reusable multi-select chip input supporting: + * - Free-text tag entry (type + Enter/comma to add) + * - Select-from-list mode (with options prop) + * - Chips with X to remove + * - Max N visible chips + "+N" overflow badge + */ +export default function ChipInput({ + value, + onChange, + options, + placeholder = "", + disabled = false, + maxVisible = 3, + className = "", +}: ChipInputProps) { + const [draft, setDraft] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + const hasOptions = Array.isArray(options) && options.length > 0; + + const availableOptions = useMemo(() => { + if (!hasOptions) return []; + const selected = new Set(value.map((v) => v.toLowerCase())); + return options!.filter((opt) => !selected.has(opt.toLowerCase())); + }, [hasOptions, options, value]); + + const addChip = useCallback( + (tag: string) => { + const trimmed = tag.trim(); + if (!trimmed) return; + const lower = trimmed.toLowerCase(); + if (value.some((v) => v.toLowerCase() === lower)) return; + onChange([...value, trimmed]); + setDraft(""); + setShowDropdown(false); + }, + [value, onChange], + ); + + const removeChip = useCallback( + (index: number) => { + const next = [...value]; + next.splice(index, 1); + onChange(next); + }, + [value, onChange], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (disabled) return; + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + if (draft.trim()) { + addChip(draft); + } + } else if (e.key === "Backspace" && !draft && value.length > 0) { + removeChip(value.length - 1); + } + }, + [disabled, draft, addChip, removeChip, value.length], + ); + + const handleChange = useCallback( + (e: ChangeEvent) => { + const v = e.target.value; + if (v.includes(",")) { + const parts = v.split(","); + const existing = new Set(value.map((item) => item.toLowerCase())); + const toAdd: string[] = []; + for (let i = 0; i < parts.length - 1; i++) { + const trimmed = parts[i].trim(); + if (!trimmed) continue; + const lowered = trimmed.toLowerCase(); + if (existing.has(lowered)) continue; + existing.add(lowered); + toAdd.push(trimmed); + } + if (toAdd.length > 0) { + onChange([...value, ...toAdd]); + } + setDraft(parts[parts.length - 1]); + } else { + setDraft(v); + } + if (hasOptions) setShowDropdown(true); + }, + [hasOptions, onChange, value], + ); + + // Close dropdown on outside click + useEffect(() => { + if (!showDropdown) return; + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setShowDropdown(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showDropdown]); + + const visibleChips = value.slice(0, maxVisible); + const overflowCount = value.length - maxVisible; + + return ( +
+
inputRef.current?.focus()} + > + {visibleChips.map((chip, i) => ( + + {chip} + {!disabled && ( + + )} + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} + { + if (hasOptions) setShowDropdown(true); + }} + placeholder={value.length === 0 ? placeholder : ""} + disabled={disabled} + className="flex-1 min-w-[80px] bg-transparent outline-none text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {hasOptions && ( + + )} +
+ + {/* Dropdown for select-from-list mode */} + {hasOptions && showDropdown && availableOptions.length > 0 && ( +
+ {availableOptions.map((opt) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5894ebd0..e5f83fb1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { type PropsWithChildren, type ReactNode, useEffect, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { LayoutDashboard, Users, Activity, Settings, Server, Sun, Moon, Languages, Globe, BookOpen, FileCode2, KeyRound, Image as ImageIcon, ShieldAlert, ExternalLink } from 'lucide-react' +import { LayoutDashboard, Users, Activity, Settings, Server, Sun, Moon, Languages, Globe, BookOpen, KeyRound, Image as ImageIcon, ShieldAlert, ExternalLink } from 'lucide-react' import { useTranslation } from 'react-i18next' import { api } from '../api' import { DEFAULT_SITE_LOGO, useBranding } from '../branding' @@ -29,7 +29,6 @@ const navDefs: NavDef[] = [ { to: '/usage', labelKey: 'nav.usage', icon: }, { to: '/settings', labelKey: 'nav.settings', icon: }, { to: '/docs', labelKey: 'nav2.docs', icon: }, - { to: '/api-reference', labelKey: 'nav2.apiRef', icon: }, ] export default function Layout({ children }: PropsWithChildren) { diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 4d602b83..24b101c3 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -9,6 +9,7 @@ interface PageHeaderProps { onRefresh?: () => void refreshLabel?: string actions?: ReactNode + actionMeta?: ReactNode } export default function PageHeader({ @@ -17,9 +18,10 @@ export default function PageHeader({ onRefresh, refreshLabel, actions, + actionMeta, }: PageHeaderProps) { const { t } = useTranslation() - const hasActions = Boolean(onRefresh) || Boolean(actions) + const hasActions = Boolean(onRefresh) || Boolean(actions) || Boolean(actionMeta) const resolvedRefreshLabel = refreshLabel ?? t('common.refresh') return ( @@ -35,14 +37,21 @@ export default function PageHeader({ ) : null} {hasActions ? ( -
- {onRefresh ? ( - +
+ {actionMeta ? ( +
+ {actionMeta} +
) : null} - {actions} +
+ {actions} + {onRefresh ? ( + + ) : null} +
) : null}
diff --git a/frontend/src/components/UsageStatsSummary.tsx b/frontend/src/components/UsageStatsSummary.tsx new file mode 100644 index 00000000..4ac6b18d --- /dev/null +++ b/frontend/src/components/UsageStatsSummary.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { BarChart3, Clock, Gauge, Zap } from 'lucide-react' +import type { UsageStats } from '../types' +import { Card, CardContent } from '@/components/ui/card' + +interface UsageStatsSummaryProps { + stats: UsageStats + className?: string +} + +export default function UsageStatsSummary({ stats, className = '' }: UsageStatsSummaryProps) { + const { t, i18n } = useTranslation() + const locale = i18n.language + + return ( + + +

{t('dashboard.usageStats')}

+
+ } + iconBg="bg-blue-500/10 text-blue-500" + title={t('dashboard.trafficGroup')} + primaryLabel={t('dashboard.todayRequests')} + primaryValue={formatInteger(stats.today_requests, locale)} + > + + + + + } + iconBg="bg-purple-500/10 text-purple-500" + title={t('dashboard.tokenGroup')} + primaryLabel={t('dashboard.todayTokens')} + primaryValue={formatInteger(stats.today_tokens, locale)} + > + + + + + } + iconBg="bg-teal-500/10 text-teal-500" + title={t('dashboard.cacheGroup')} + primaryLabel={t('dashboard.todayCacheHitRate')} + primaryValue={formatPercent(stats.today_cache_rate ?? 0)} + > + + + + + } + iconBg="bg-cyan-500/10 text-cyan-500" + title={t('dashboard.healthGroup')} + primaryLabel={t('dashboard.avgFirstTokenLatency')} + primaryValue={formatLatency(stats.avg_first_token_ms)} + > + + 1 ? 'danger' : 'default'} /> + +
+
+
+ ) +} + +function MetricGroup({ + icon, + iconBg, + title, + primaryLabel, + primaryValue, + children, +}: { + icon: ReactNode + iconBg: string + title: string + primaryLabel: string + primaryValue: string + children: ReactNode +}) { + return ( +
+
+ +
+
{title}
+
{primaryLabel}
+
+
+
+ {primaryValue} +
+
+ {children} +
+
+ ) +} + +function MetricLine({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { + return ( +
+ {label} + + {value} + +
+ ) +} + +function formatInteger(value: number, locale: string): string { + return Math.round(value).toLocaleString(locale) +} + +function formatPercent(value: number): string { + return `${value.toFixed(value >= 10 ? 1 : 2)}%` +} + +function formatLatency(value?: number): string { + const ms = value ?? 0 + if (ms <= 0) return '-' + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.round(ms)}ms` +} + +function formatMoney(value: number): string { + if (value >= 100) return `$${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}` + if (value >= 1) return `$${value.toFixed(2)}` + return `$${value.toFixed(4)}` +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 2140afcb..d2536bd0 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
) {
) { return (
) @@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) diff --git a/frontend/src/hooks/useHighlighter.ts b/frontend/src/hooks/useHighlighter.ts new file mode 100644 index 00000000..209dae2f --- /dev/null +++ b/frontend/src/hooks/useHighlighter.ts @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import type { HighlighterCore } from "shiki/core"; + +let highlighterPromise: Promise | null = null; +const htmlCache = new Map(); +const MAX_CACHE_SIZE = 80; + +function getHighlighter() { + if (!highlighterPromise) { + highlighterPromise = Promise.all([ + import("shiki/core"), + import("shiki/engine/javascript"), + import("shiki/themes/dark-plus.mjs"), + import("shiki/themes/github-light-default.mjs"), + import("shiki/langs/json.mjs"), + import("shiki/langs/shellscript.mjs"), + import("shiki/langs/toml.mjs"), + ]) + .then( + ([core, engine, darkPlus, githubLight, json, shellscript, toml]) => { + return core.createHighlighterCore({ + themes: [githubLight.default, darkPlus.default], + langs: [json.default, toml.default, shellscript.default], + engine: engine.createJavaScriptRegexEngine(), + }); + }, + ) + .catch((error) => { + highlighterPromise = null; + throw error; + }); + } + return highlighterPromise; +} + +function tuneLightTheme(html: string) { + return html + .replace(/#0550AE/gi, "#075985") + .replace(/#0969DA/gi, "#075985") + .replace(/#1F2328/gi, "#1f2937") + .replace(/#953800/gi, "#9a3412") + .replace(/#0A3069/gi, "#7c2d12") + .replace(/#CF222E/gi, "#b91c1c"); +} + +export function useHighlightedHtml(code: string, lang?: string) { + const [html, setHtml] = useState(""); + const [isDark, setIsDark] = useState(() => + document.documentElement.classList.contains("dark"), + ); + + useEffect(() => { + const root = document.documentElement; + const observer = new MutationObserver(() => { + setIsDark(root.classList.contains("dark")); + }); + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + let cancelled = false; + const resolvedLang = + lang === "bash" || lang === "shell" || lang === "curl" + ? "shellscript" + : lang || "text"; + + if (!code) { + setHtml(""); + return () => { + cancelled = true; + }; + } + const cacheKey = `${isDark ? "dark" : "light"}:${resolvedLang}:${code}`; + const cached = htmlCache.get(cacheKey); + if (cached) { + setHtml(cached); + return () => { + cancelled = true; + }; + } + + getHighlighter() + .then((hl) => { + if (cancelled) return; + try { + const result = hl.codeToHtml(code, { + lang: resolvedLang, + theme: isDark ? "dark-plus" : "github-light-default", + }); + const cacheKey = `${isDark ? "dark" : "light"}:${resolvedLang}:${code}`; + const nextHtml = isDark ? result : tuneLightTheme(result); + if (htmlCache.size >= MAX_CACHE_SIZE) { + const firstKey = htmlCache.keys().next().value; + if (firstKey) htmlCache.delete(firstKey); + } + htmlCache.set(cacheKey, nextHtml); + setHtml(nextHtml); + } catch (error) { + console.warn("highlight failed", { + resolvedLang, + isDark, + codeLength: code.length, + error, + }); + setHtml(""); + } + }) + .catch((error) => { + if (cancelled) return; + console.warn("highlight failed", { + resolvedLang, + isDark, + codeLength: code.length, + error, + }); + setHtml(""); + }); + + return () => { + cancelled = true; + }; + }, [code, isDark, lang]); + + return html; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index de62f72a..064ea5ea 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,8 +4,8 @@ @theme { --font-sans: 'Inter', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; - --font-geist-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; + --font-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; + --font-geist-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; --radius-sm: 0.25rem; --radius-md: 0.375rem; @@ -120,6 +120,89 @@ .data-table-shell [data-slot="table-row"]:hover { background: color-mix(in oklab, var(--color-muted) 58%, transparent); } + + [data-slot="card"] { + @apply transition-[border-color,box-shadow,transform,background-color] duration-200; + } + + button, + a { + @apply transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-200; + } + + button:not(:disabled):active { + transform: scale(0.99); + } + + code, + pre { + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-panel { + @apply overflow-hidden rounded-lg border border-slate-200/90 bg-slate-50/95 shadow-sm dark:border-border dark:bg-[hsl(222_12%_20%)]; + } + + .code-panel-header { + @apply flex items-center justify-between border-b border-slate-200/90 bg-slate-100/80 px-4 py-1.5 dark:border-border/80 dark:bg-[hsl(222_11%_23%)]; + } + + .code-panel-label { + @apply rounded-md bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200 dark:bg-background/85 dark:text-foreground dark:ring-border/70; + font-family: var(--font-sans); + } + + .code-panel-copy { + @apply inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-all duration-200 hover:bg-muted hover:text-foreground hover:shadow-sm active:scale-95 dark:hover:bg-white/8; + } + + .code-panel-pre { + @apply overflow-x-auto p-4 text-sm leading-relaxed text-slate-900 dark:text-[hsl(170_18%_94%)]; + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-inline { + @apply rounded-md border border-border bg-muted/55 px-2 py-0.5 text-[13px] font-semibold text-foreground; + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-token-keyword { color: #c586c0; } + .code-token-string { color: #ce9178; } + .code-token-number { color: #b5cea8; } + .code-token-property { color: #9cdcfe; } + .code-token-command { color: #dcdcaa; } + + .shiki-wrapper pre { + margin: 0; + padding: 0; + background: transparent !important; + overflow-x: auto; + } + + .shiki-wrapper code { + font-family: var(--font-mono); + font-variant-ligatures: none; + font-size: inherit; + line-height: inherit; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } + + button:not(:disabled):active { + transform: none; + } + } } /* 主题切换 — View Transition 圆形扩散动画 */ diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 4dc3a9de..d4e470fa 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -107,6 +107,17 @@ "rpmTpm": "RPM / TPM", "avgLatency": "Avg Latency", "todayErrorRate": "Error Rate (Today)", + "trafficGroup": "Traffic", + "tokenGroup": "Token", + "healthGroup": "Health", + "cacheGroup": "Cache", + "billing": "Billing", + "totalCostShort": "Total", + "todayCacheHitRate": "Today's Cache Hit Rate", + "todayCachedTokens": "Today's Cached Tokens", + "totalCacheHitRate": "Total Cache Hit Rate", + "avgFirstTokenLatency": "First Token Latency", + "avgCompletionLatency": "Completion Latency", "usageCharts": "Usage Trends", "usageChartsDesc": "Auto-aggregated from {{count}} logs in the selected time range.", "liveBadge": "Live", @@ -238,6 +249,10 @@ "usage5h": "5h", "usage7d": "7d", "resetIn": "Reset: {{time}}", + "usageReqUnit": "req", + "usageTokUnit": "tok", + "accountBilledLabel": "Account", + "userBilledLabel": "User", "quotaDistributionTitle": "Quota Distribution", "quotaDistributionDesc": "Used quota distribution for non-banned Codex accounts. Sampled {{sampled}} / {{total}} accounts.", "quotaDistributionEmpty": "No non-banned Codex accounts available.", @@ -380,6 +395,42 @@ "allowedAPIKeysPlaceholder": "Choose which tokens may use this account", "allowedAPIKeysNoOptions": "No API keys yet", "allowedAPIKeysNoOptionsHint": "No API keys have been created, so this account currently allows all callers.", + "tagsLabel": "Tags", + "tagsHint": "Add searchable free-text labels to this account.", + "tagsPlaceholder": "Type a tag and press Enter", + "tagsFilter": "All tags", + "groupsLabel": "Account Groups", + "groupsHint": "Assign groups for API key routing limits.", + "groupsPlaceholder": "Select groups", + "groupsFilter": "All groups", + "groupsNone": "No account groups", + "groupManage": "Manage Groups", + "groupManageTitle": "Account Group Management", + "groupCreate": "Create Group", + "groupCreateTitle": "Create Account Group", + "groupEdit": "Edit Group", + "groupEditTitle": "Edit Account Group", + "groupName": "Group Name", + "groupNameRequired": "Enter a group name", + "groupNamePlaceholder": "e.g. Pro Pool", + "groupDescription": "Description", + "groupDescriptionPlaceholder": "Optional description", + "groupColor": "Color", + "groupColorPlaceholder": "#2563eb", + "groupMembers": "Members", + "groupNoDescription": "No description", + "groupEmpty": "No groups yet", + "groupEmptyDesc": "Create groups, then assign them in account editing.", + "groupCreated": "Group created", + "groupUpdated": "Group updated", + "groupDeleted": "Group deleted", + "groupDeleteTitle": "Delete Account Group", + "groupDeleteDesc": "Accounts will be removed from this group.", + "groupDeleteWithMembers": "This group still has accounts. Force delete it?", + "groupDeleteEmpty": "Delete this empty group?", + "groupDeleteForce": "Force delete", + "columnSettings": "Columns", + "columnReset": "Reset columns", "schedulerPreviewTitle": "Current Scheduler Preview", "schedulerPreviewRawScore": "Raw Score", "schedulerPreviewDispatchScore": "Dispatch Score", @@ -497,6 +548,11 @@ "resetStatusFailed": "Reset failed: {{error}}", "batchResetStatus": "Reset Status", "batchResetStatusDone": "Batch reset complete: {{success}} succeeded, {{fail}} failed", + "batchMetaEdit": "Tags & Groups", + "batchMetaTitle": "Batch Edit Tags and Groups", + "batchMetaDesc": "The following tags and groups will be saved to {{count}} selected accounts. Leaving a field empty clears it.", + "batchMetaDone": "Batch metadata save complete: {{success}} succeeded, {{fail}} failed", + "batchMetaFailed": "Batch metadata save failed: {{error}}", "testAutoReset": "Test passed, account status has been automatically restored to normal" }, "status": { @@ -1021,6 +1077,7 @@ "poolDisabled": "Proxy Pool Disabled", "deleteSelected": "Delete ({{count}})", "testingAll": "Testing...", + "testingAllProgress": "Testing {{current}} / {{total}}", "testAll": "Test All", "addProxy": "Add Proxy", "addProxyTitle": "Add Proxy", @@ -1045,7 +1102,22 @@ "disabled": "Inactive", "test": "Test", "testProxy": "Test Proxy", - "pagination": "{{total}} proxies, page {{page}}/{{totalPages}}" + "editProxy": "Edit proxy", + "editProxyTitle": "Edit Proxy", + "editUrlLabel": "Proxy URL", + "editLabelLabel": "Label", + "loadFailed": "Failed to load proxies", + "addFailed": "Failed to add proxy", + "deleteFailed": "Failed to delete proxy", + "batchDeleteFailed": "Batch delete failed", + "invalidProxyUrl": "Enter a valid proxy URL", + "proxyUpdated": "Proxy updated", + "testFailed": "Proxy test failed", + "testAllFailed": "Batch test failed", + "testFailedUnknown": "Unknown error", + "pagination": "{{total}} proxies, page {{page}}/{{totalPages}}", + "showProxyUrl": "Show proxy URL", + "hideProxyUrl": "Hide proxy URL" }, "apiKeys": { "title": "API Keys", @@ -1114,7 +1186,29 @@ "quota_exhausted": "Quota exhausted" }, "showKey": "Show key", - "hideKey": "Hide key" + "hideKey": "Hide key", + "allowedGroups": "Allowed Groups", + "allowedGroupsHint": "Leaving this empty allows the key to use all account groups.", + "allowedGroupsLabel": "Allowed Account Groups", + "allowedGroupsPlaceholder": "Choose allowed groups", + "allowedGroupsAll": "All groups", + "allowedGroupsNone": "No group limit", + "allowedGroupsMissing": "Group no longer exists", + "allowedGroupsSaved": "Allowed groups saved", + "allowedGroupsSaveFailed": "Failed to save allowed groups", + "permissionTitle": "Key Permissions", + "permissionDesc": "Restrict this key to specific account groups. Leaving it empty allows all groups.", + "editKey": "Edit", + "editTitle": "Edit Key", + "editDesc": "Adjust the name, quota, expiration, and account group permissions. The key value is not changed.", + "keyUpdated": "Key updated", + "updateFailed": "Failed to update", + "nameRequired": "Name is required", + "clearExpirationHint": "Saving will clear the expiration time and keep this key valid indefinitely.", + "relativeExpirationHint": "Saving will set the key to expire {{days}} days from now.", + "renameKey": "Rename key", + "keyRenamed": "Key renamed", + "renameFailed": "Rename failed" }, "nav2": { "docs": "Docs", @@ -1348,7 +1442,102 @@ "send": "Send", "sending": "Sending…", "placeholder": "Click Send to make a request", - "selectKey": "Select key:" + "selectKey": "Select key:", + "authTitle": "Authentication", + "required": "Required", + "keyPlaceholder": "Enter key", + "requestBody": "Request body", + "responseTitle": "Response" + } + }, + "docs": { + "title": "Documentation", + "description": "Complete integration guide, API endpoints and admin reference.", + "tocTitle": "Contents", + "copyMarkdown": "Copy as Markdown", + "markdownCopied": "Copied as Markdown", + "section1Eyebrow": "01 · Onboarding", + "section2Eyebrow": "02 · Clients", + "section3Eyebrow": "03 · Authentication", + "section4Eyebrow": "04 · Model API", + "section5Eyebrow": "05 · Admin API", + "toc": { + "quickStart": "Quick Start", + "qsTools": "AI Clients", + "qsCurl": "cURL Check", + "clientConfig": "Client Setup", + "modelMapping": "Model Mapping", + "authentication": "Authentication", + "modelApi": "Model API", + "adminApi": "Admin API" + }, + "quickStart": { + "title": "Quick Start", + "description": "Pick your favorite AI client. Copy config or launch the app in one click.", + "toolsTitle": "One-click client setup", + "toolsDesc": "Curated set of CLI, desktop, web, and mobile tools.", + "launch": "Launch", + "needKey": "Create a key first", + "copyConfig": "Copy config", + "copyLink": "Copy link", + "copied": "Copied", + "openClient": "Open client", + "viewFullLink": "View full link", + "collapseFullLink": "Collapse full link", + "deeplinkBadge": "deeplink", + "useKey": "Use key", + "curlTitle": "cURL quick check", + "curlDesc": "Use these commands to verify your endpoints. YOUR_API_KEY auto-fills with your first API key.", + "createKeyFirst": "Create an API key first", + "copiedToast": "Copied {{name}} config", + "launchedToast": "Launching {{name}}" + }, + "clientConfig": { + "title": "Client Configuration", + "description": "Wire this proxy into Codex CLI or Claude Code. Covers every supported protocol.", + "codexDesc": "Add the following config files to the Codex CLI config directory to route Codex traffic through this proxy.", + "defaultModel": "Default model", + "endpointLabel": "Endpoint", + "unixTerminal": "Terminal", + "windowsTerminal": "Command Prompt", + "importTarget": "Import target", + "configName": "Config name", + "providerId": "Provider ID", + "mappedTo": "Mapped to", + "backendDefaultModel": "backend default model", + "ccSwitchPreviewTitle": "CC Switch import preview", + "ccSwitchPreviewDesc": "Confirm the target app, endpoint, and models, then launch CC Switch to import directly.", + "cherryPreviewTitle": "Cherry Studio import preview", + "cherryPreviewDesc": "Generate a provider import link from the current endpoint and API key.", + "ccSwitchFields": { + "model": "Primary model", + "haikuModel": "Haiku model", + "sonnetModel": "Sonnet model", + "opusModel": "Opus model" + }, + "codexConfigHint": "Make sure the content below appears at the top of config.toml", + "codexNoteUnix": "Make sure the config directory exists — run mkdir -p ~/.codex if not.", + "codexNoteWindows": "Press Win+R and enter %userprofile%\\.codex to open the config directory — create it manually if missing.", + "claudeDesc": "Set environment variables or edit settings.json to make Claude Code use this proxy.", + "claudeEnvNote": "Environment variables only persist for the current shell session — write them to ~/.bashrc or ~/.zshrc for permanent use.", + "claudeSettingsNote": "Or edit Claude Code's settings.json directly (pick one approach).", + "mappingTitle": "Model Mapping", + "mappingDesc": "On the /v1/messages endpoint, Claude model names are auto-mapped to Codex names. Customize the mapping in System Settings." + }, + "authentication": { + "title": "Authentication", + "description": "All endpoints except /health require an API key. Admin endpoints additionally require X-Admin-Key.", + "bearerNote": "Standard (recommended)", + "xApiKeyNote": "Anthropic SDK default", + "adminNote": "Admin endpoints only" + }, + "modelApi": { + "title": "Model API", + "description": "OpenAI Responses, Chat Completions, and Anthropic Messages — three compatible surfaces sharing one upstream pool." + }, + "adminApi": { + "title": "Account Management API", + "description": "Add, delete, and list pool accounts. All endpoints require X-Admin-Key." } } } diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index bc7149ee..28e54d9a 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -107,6 +107,17 @@ "rpmTpm": "RPM / TPM", "avgLatency": "平均延迟", "todayErrorRate": "今日错误率", + "trafficGroup": "流量", + "tokenGroup": "Token", + "healthGroup": "健康", + "cacheGroup": "缓存", + "billing": "计费", + "totalCostShort": "总计", + "todayCacheHitRate": "今日缓存命中率", + "todayCachedTokens": "今日缓存 Token", + "totalCacheHitRate": "总缓存命中率", + "avgFirstTokenLatency": "首字延迟", + "avgCompletionLatency": "完成延迟", "usageCharts": "使用趋势", "usageChartsDesc": "基于选定时间范围内 {{count}} 条请求日志自动聚合。", "liveBadge": "实时中", @@ -238,6 +249,10 @@ "usage5h": "5h", "usage7d": "7d", "resetIn": "重置: {{time}}", + "usageReqUnit": "req", + "usageTokUnit": "tok", + "accountBilledLabel": "账号", + "userBilledLabel": "用户", "quotaDistributionTitle": "额度分布", "quotaDistributionDesc": "非封禁 Codex 账号的使用额度分布,已采样 {{sampled}} / {{total}} 个账号。", "quotaDistributionEmpty": "暂无非封禁 Codex 账号。", @@ -380,6 +395,42 @@ "allowedAPIKeysPlaceholder": "选择允许调用的 Token", "allowedAPIKeysNoOptions": "暂无 API Key", "allowedAPIKeysNoOptionsHint": "当前未创建 API Key,因此该账号默认允许全部调用。", + "tagsLabel": "标签", + "tagsHint": "为账号添加可搜索的自由文本标签。", + "tagsPlaceholder": "输入标签后按 Enter", + "tagsFilter": "全部标签", + "groupsLabel": "账号分组", + "groupsHint": "限制账号所属分组,用于 API Key 调度范围。", + "groupsPlaceholder": "选择分组", + "groupsFilter": "全部分组", + "groupsNone": "暂无账号分组", + "groupManage": "管理分组", + "groupManageTitle": "账号分组管理", + "groupCreate": "创建分组", + "groupCreateTitle": "创建账号分组", + "groupEdit": "编辑分组", + "groupEditTitle": "编辑账号分组", + "groupName": "分组名称", + "groupNameRequired": "请输入分组名称", + "groupNamePlaceholder": "例如 Pro 池", + "groupDescription": "分组说明", + "groupDescriptionPlaceholder": "可选说明", + "groupColor": "颜色", + "groupColorPlaceholder": "#2563eb", + "groupMembers": "成员", + "groupNoDescription": "暂无说明", + "groupEmpty": "暂无分组", + "groupEmptyDesc": "创建分组后可在账号编辑中分配。", + "groupCreated": "分组已创建", + "groupUpdated": "分组已更新", + "groupDeleted": "分组已删除", + "groupDeleteTitle": "删除账号分组", + "groupDeleteDesc": "删除后账号会从该分组移除。", + "groupDeleteWithMembers": "该分组仍有账号,确认强制删除?", + "groupDeleteEmpty": "确认删除该空分组?", + "groupDeleteForce": "强制删除", + "columnSettings": "列设置", + "columnReset": "重置列", "schedulerPreviewTitle": "当前调度预览", "schedulerPreviewRawScore": "原始分", "schedulerPreviewDispatchScore": "当前调度分", @@ -497,6 +548,11 @@ "resetStatusFailed": "重置失败:{{error}}", "batchResetStatus": "重置状态", "batchResetStatusDone": "批量重置完成:成功 {{success}},失败 {{fail}}", + "batchMetaEdit": "标签分组", + "batchMetaTitle": "批量编辑标签与分组", + "batchMetaDesc": "将为 {{count}} 个选中账号统一保存以下标签和分组。留空会清空对应字段。", + "batchMetaDone": "批量保存完成:成功 {{success}},失败 {{fail}}", + "batchMetaFailed": "批量保存失败:{{error}}", "testAutoReset": "测试成功,账号状态已自动恢复为正常" }, "status": { @@ -1021,6 +1077,7 @@ "poolDisabled": "代理池已关闭", "deleteSelected": "删除 ({{count}})", "testingAll": "测试中...", + "testingAllProgress": "测试中 {{current}} / {{total}}", "testAll": "一键测试", "addProxy": "添加代理", "addProxyTitle": "添加代理", @@ -1045,7 +1102,22 @@ "disabled": "禁用", "test": "测试", "testProxy": "测试代理", - "pagination": "共 {{total}} 个代理,第 {{page}}/{{totalPages}} 页" + "editProxy": "编辑代理", + "editProxyTitle": "编辑代理", + "editUrlLabel": "代理地址", + "editLabelLabel": "标签", + "loadFailed": "加载代理失败", + "addFailed": "添加代理失败", + "deleteFailed": "删除代理失败", + "batchDeleteFailed": "批量删除失败", + "invalidProxyUrl": "请输入有效的代理地址", + "proxyUpdated": "代理已更新", + "testFailed": "代理测试失败", + "testAllFailed": "批量测试失败", + "testFailedUnknown": "未知错误", + "pagination": "共 {{total}} 个代理,第 {{page}}/{{totalPages}} 页", + "showProxyUrl": "显示代理地址", + "hideProxyUrl": "隐藏代理地址" }, "apiKeys": { "title": "API 密钥", @@ -1114,7 +1186,29 @@ "quota_exhausted": "额度耗尽" }, "showKey": "显示密钥", - "hideKey": "隐藏密钥" + "hideKey": "隐藏密钥", + "allowedGroups": "允许分组", + "allowedGroupsHint": "不选择表示该密钥可使用全部账号分组。", + "allowedGroupsLabel": "允许账号分组", + "allowedGroupsPlaceholder": "选择允许调用的分组", + "allowedGroupsAll": "全部分组", + "allowedGroupsNone": "不限制分组", + "allowedGroupsMissing": "分组已不存在", + "allowedGroupsSaved": "允许分组已保存", + "allowedGroupsSaveFailed": "允许分组保存失败", + "permissionTitle": "密钥权限", + "permissionDesc": "限制该密钥只能调度指定账号分组;不选择则可使用全部分组。", + "editKey": "编辑", + "editTitle": "编辑密钥", + "editDesc": "可调整名称、额度、过期时间和账号分组权限。密钥值本身不会被修改。", + "keyUpdated": "密钥已更新", + "updateFailed": "更新失败", + "nameRequired": "名称不能为空", + "clearExpirationHint": "保存后将清除过期时间,密钥保持长期有效。", + "relativeExpirationHint": "保存后将从现在起 {{days}} 天后过期。", + "renameKey": "重命名密钥", + "keyRenamed": "密钥已重命名", + "renameFailed": "重命名失败" }, "nav2": { "docs": "使用文档", @@ -1344,11 +1438,106 @@ "desc": "列出所有账号及其状态、用量等信息。" }, "tryIt": { - "button": "Try it", + "button": "调试", "send": "发送请求", "sending": "发送中…", - "placeholder": "点击 Send 发送请求", - "selectKey": "选择密钥:" + "placeholder": "点击发送请求开始调试", + "selectKey": "选择密钥:", + "authTitle": "认证信息", + "required": "必填", + "keyPlaceholder": "输入密钥", + "requestBody": "请求体", + "responseTitle": "响应结果" + } + }, + "docs": { + "title": "使用文档", + "description": "完整的接入指南、API 端点和管理接口参考。", + "tocTitle": "目录", + "copyMarkdown": "复制为 Markdown", + "markdownCopied": "已复制为 Markdown", + "section1Eyebrow": "01 · 接入", + "section2Eyebrow": "02 · 客户端", + "section3Eyebrow": "03 · 认证", + "section4Eyebrow": "04 · 模型 API", + "section5Eyebrow": "05 · 管理 API", + "toc": { + "quickStart": "快速接入", + "qsTools": "AI 客户端", + "qsCurl": "cURL 验证", + "clientConfig": "客户端配置", + "modelMapping": "模型映射", + "authentication": "认证方式", + "modelApi": "模型 API", + "adminApi": "账号管理 API" + }, + "quickStart": { + "title": "快速接入", + "description": "挑选你常用的 AI 客户端,复制配置或一键唤起。", + "toolsTitle": "一键导入 AI 客户端", + "toolsDesc": "桌面 / Web / 移动端常用工具集合。", + "launch": "启动", + "needKey": "先创建密钥", + "copyConfig": "复制配置", + "copyLink": "复制链接", + "copied": "已复制", + "openClient": "打开客户端", + "viewFullLink": "查看完整链接", + "collapseFullLink": "收起完整链接", + "deeplinkBadge": "deeplink", + "useKey": "使用密钥", + "curlTitle": "cURL 快速验证", + "curlDesc": "用以下命令验证端点是否可用,YOUR_API_KEY 会自动填充为第一个 API Key。", + "createKeyFirst": "请先创建 API Key", + "copiedToast": "已复制 {{name}} 配置", + "launchedToast": "已唤起 {{name}}" + }, + "clientConfig": { + "title": "客户端配置", + "description": "在 Codex CLI 或 Claude Code 中接入本代理,覆盖所有支持的协议。", + "codexDesc": "将以下配置文件添加到 Codex CLI 配置目录中,即可通过本代理使用 Codex 模型。", + "defaultModel": "默认模型", + "endpointLabel": "端点地址", + "unixTerminal": "终端", + "windowsTerminal": "命令提示符", + "importTarget": "导入目标", + "configName": "配置名称", + "providerId": "Provider ID", + "mappedTo": "映射到", + "backendDefaultModel": "后端默认模型", + "ccSwitchPreviewTitle": "CC Switch 导入预览", + "ccSwitchPreviewDesc": "确认目标客户端、端点和模型后,直接唤起 CC Switch 完成导入。", + "cherryPreviewTitle": "Cherry Studio 导入预览", + "cherryPreviewDesc": "使用当前端点和 API Key 生成 Provider 导入链接。", + "ccSwitchFields": { + "model": "主模型", + "haikuModel": "Haiku 模型", + "sonnetModel": "Sonnet 模型", + "opusModel": "Opus 模型" + }, + "codexConfigHint": "请确保以下内容位于 config.toml 文件的开头部分", + "codexNoteUnix": "请确保配置目录存在,可运行 mkdir -p ~/.codex 创建。", + "codexNoteWindows": "按 Win+R 输入 %userprofile%\\.codex 打开配置目录;不存在时手动创建。", + "claudeDesc": "设置环境变量或编辑 settings.json,即可让 Claude Code 通过本代理工作。", + "claudeEnvNote": "环境变量仅在当前终端会话生效;如需永久使用,请写入 ~/.bashrc 或 ~/.zshrc。", + "claudeSettingsNote": "或直接编辑 Claude Code 的 settings.json 配置文件(与环境变量二选一)。", + "mappingTitle": "模型映射", + "mappingDesc": "当使用 /v1/messages 端点时,Claude 模型名会自动映射为 Codex 模型名。映射规则可在「系统设置」中自定义。" + }, + "authentication": { + "title": "认证方式", + "description": "所有端点(除 /health 外)需要密钥;管理接口额外需要 X-Admin-Key。", + "bearerNote": "标准方式(推荐)", + "xApiKeyNote": "Anthropic SDK 默认", + "adminNote": "管理接口专用" + }, + "modelApi": { + "title": "模型 API", + "description": "OpenAI Responses、Chat Completions、Anthropic Messages 三种兼容协议,复用同一组上游账号。" + }, + "adminApi": { + "title": "账号管理 API", + "description": "用于添加、删除、列出号池账号;所有接口需 X-Admin-Key。" } } } diff --git a/frontend/src/pages/APIKeys.tsx b/frontend/src/pages/APIKeys.tsx index 67557b5d..19e9faa0 100644 --- a/frontend/src/pages/APIKeys.tsx +++ b/frontend/src/pages/APIKeys.tsx @@ -1,22 +1,22 @@ -import type { ChangeEvent, FormEvent, ReactNode } from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { api } from '../api' -import Modal from '../components/Modal' -import PageHeader from '../components/PageHeader' -import StateShell from '../components/StateShell' -import ToastNotice from '../components/ToastNotice' -import { useConfirmDialog } from '../hooks/useConfirmDialog' -import { useDataLoader } from '../hooks/useDataLoader' -import { useToast } from '../hooks/useToast' -import type { APIKeyRow } from '../types' -import { getErrorMessage } from '../utils/error' -import { formatBeijingTime, formatRelativeTime } from '../utils/time' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Select } from '@/components/ui/select' +import type { ChangeEvent, FormEvent, ReactNode } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api"; +import Modal from "../components/Modal"; +import PageHeader from "../components/PageHeader"; +import StateShell from "../components/StateShell"; +import ToastNotice from "../components/ToastNotice"; +import { useConfirmDialog } from "../hooks/useConfirmDialog"; +import { useDataLoader } from "../hooks/useDataLoader"; +import { useToast } from "../hooks/useToast"; +import type { AccountGroup, APIKeyRow } from "../types"; +import { getErrorMessage } from "../utils/error"; +import { formatBeijingTime, formatRelativeTime } from "../utils/time"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; import { Table, TableBody, @@ -24,7 +24,7 @@ import { TableHead, TableHeader, TableRow, -} from '@/components/ui/table' +} from "@/components/ui/table"; import { Copy, CalendarClock, @@ -34,178 +34,279 @@ import { Fingerprint, KeyRound, LockKeyhole, + Pencil, Plus, ShieldCheck, Trash2, -} from 'lucide-react' +} from "lucide-react"; -type ExpireMode = 'never' | '7' | '30' | '90' | 'custom' +type ExpireMode = "never" | "7" | "30" | "90" | "custom"; interface CreateKeyFormState { - name: string - key: string - quotaLimit: string - expireMode: ExpireMode - expiresAt: string + name: string; + key: string; + quotaLimit: string; + expireMode: ExpireMode; + expiresAt: string; + allowedGroupIds: number[]; } -const initialCreateForm: CreateKeyFormState = { - name: '', - key: '', - quotaLimit: '', - expireMode: 'never', - expiresAt: '', +interface EditKeyFormState { + name: string; + quotaLimit: string; + expireMode: ExpireMode; + expiresAt: string; + allowedGroupIds: number[]; } +const initialCreateForm: CreateKeyFormState = { + name: "", + key: "", + quotaLimit: "", + expireMode: "never", + expiresAt: "", + allowedGroupIds: [], +}; + +const initialEditForm: EditKeyFormState = { + name: "", + quotaLimit: "", + expireMode: "never", + expiresAt: "", + allowedGroupIds: [], +}; + export default function APIKeys() { - const { t } = useTranslation() - const [createDialogOpen, setCreateDialogOpen] = useState(false) - const [createForm, setCreateForm] = useState(initialCreateForm) - const [createdKeyId, setCreatedKeyId] = useState(null) - const [visibleKeys, setVisibleKeys] = useState>(new Set()) - const [creating, setCreating] = useState(false) - const [deletingIds, setDeletingIds] = useState>(new Set()) - const { toast, showToast } = useToast() - const { confirm, confirmDialog } = useConfirmDialog() + const { t } = useTranslation(); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [createForm, setCreateForm] = + useState(initialCreateForm); + const [createdKeyId, setCreatedKeyId] = useState(null); + const [visibleKeys, setVisibleKeys] = useState>(new Set()); + const [creating, setCreating] = useState(false); + const [deletingIds, setDeletingIds] = useState>(new Set()); + const [editingKey, setEditingKey] = useState(null); + const [editForm, setEditForm] = useState(initialEditForm); + const [saving, setSaving] = useState(false); + const { toast, showToast } = useToast(); + const { confirm, confirmDialog } = useConfirmDialog(); const loadKeys = useCallback(async () => { - const response = await api.getAPIKeys() - return response.keys ?? [] - }, []) + const [keysResponse, groupsResponse] = await Promise.all([ + api.getAPIKeys(), + api.listAccountGroups().catch(() => ({ groups: [] })), + ]); + return { + keys: keysResponse.keys ?? [], + groups: groupsResponse.groups ?? [], + }; + }, []); - const { data: keys, loading, error, reload } = useDataLoader({ - initialData: [], + const { data, loading, error, reload } = useDataLoader<{ + keys: APIKeyRow[]; + groups: AccountGroup[]; + }>({ + initialData: { keys: [], groups: [] }, load: loadKeys, - }) + }); + const keys = data.keys; + const groups = data.groups; const latestKey = useMemo(() => { return keys .slice() - .sort((a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime())[0] - }, [keys]) + .sort( + (a, b) => + new Date(b.created_at || 0).getTime() - + new Date(a.created_at || 0).getTime(), + )[0]; + }, [keys]); const expireOptions = useMemo( () => [ - { label: t('apiKeys.expireNever'), value: 'never' }, - { label: t('apiKeys.expire7Days'), value: '7' }, - { label: t('apiKeys.expire30Days'), value: '30' }, - { label: t('apiKeys.expire90Days'), value: '90' }, - { label: t('apiKeys.expireCustom'), value: 'custom' }, + { label: t("apiKeys.expireNever"), value: "never" }, + { label: t("apiKeys.expire7Days"), value: "7" }, + { label: t("apiKeys.expire30Days"), value: "30" }, + { label: t("apiKeys.expire90Days"), value: "90" }, + { label: t("apiKeys.expireCustom"), value: "custom" }, ], - [t] - ) + [t], + ); const updateCreateForm = (patch: Partial) => { - setCreateForm((current) => ({ ...current, ...patch })) - } + setCreateForm((current) => ({ ...current, ...patch })); + }; const closeCreateDialog = () => { - if (creating) return - setCreateDialogOpen(false) - } + if (creating) return; + setCreateDialogOpen(false); + }; const handleCreateKey = async (event?: FormEvent) => { - event?.preventDefault() - setCreating(true) + event?.preventDefault(); + setCreating(true); try { - const quotaLimitText = createForm.quotaLimit.trim() - let quotaLimit: number | undefined + const quotaLimitText = createForm.quotaLimit.trim(); + let quotaLimit: number | undefined; if (quotaLimitText) { - quotaLimit = Number(quotaLimitText) + quotaLimit = Number(quotaLimitText); if (!Number.isFinite(quotaLimit) || quotaLimit < 0) { - showToast(t('apiKeys.quotaInvalid'), 'error') - return + showToast(t("apiKeys.quotaInvalid"), "error"); + return; } } + const expirationPayload = buildExpirationPayload(createForm, t) as { + expires_in_days?: number; + expires_at?: string; + }; const payload = { - name: createForm.name.trim() || t('apiKeys.defaultName'), + name: createForm.name.trim() || t("apiKeys.defaultName"), ...(createForm.key.trim() ? { key: createForm.key.trim() } : {}), ...(quotaLimit && quotaLimit > 0 ? { quota_limit: quotaLimit } : {}), - ...buildExpirationPayload(createForm, t), - } + allowed_group_ids: createForm.allowedGroupIds, + ...expirationPayload, + }; - const result = await api.createAPIKey(payload) - setCreatedKeyId(result.id) - setVisibleKeys((prev) => new Set(prev).add(result.id)) - setCreateForm(initialCreateForm) - setCreateDialogOpen(false) - showToast(t('apiKeys.keyCreateSuccess')) - void reload() + const result = await api.createAPIKey(payload); + setCreatedKeyId(result.id); + setVisibleKeys((prev) => new Set(prev).add(result.id)); + setCreateForm(initialCreateForm); + setCreateDialogOpen(false); + showToast(t("apiKeys.keyCreateSuccess")); + void reload(); } catch (error) { - showToast(`${t('apiKeys.createFailed')}: ${getErrorMessage(error)}`, 'error') + showToast( + `${t("apiKeys.createFailed")}: ${getErrorMessage(error)}`, + "error", + ); } finally { - setCreating(false) + setCreating(false); } - } + }; const handleDeleteKey = async (id: number) => { const confirmed = await confirm({ - title: t('apiKeys.deleteKeyTitle'), - description: t('apiKeys.deleteKeyDesc'), - confirmText: t('apiKeys.confirmDelete'), - tone: 'destructive', - confirmVariant: 'destructive', - }) - if (!confirmed) return - - setDeletingIds((prev) => new Set(prev).add(id)) + title: t("apiKeys.deleteKeyTitle"), + description: t("apiKeys.deleteKeyDesc"), + confirmText: t("apiKeys.confirmDelete"), + tone: "destructive", + confirmVariant: "destructive", + }); + if (!confirmed) return; + + setDeletingIds((prev) => new Set(prev).add(id)); try { - await api.deleteAPIKey(id) - showToast(t('apiKeys.keyDeleted')) - if (createdKeyId === id) setCreatedKeyId(null) + await api.deleteAPIKey(id); + showToast(t("apiKeys.keyDeleted")); + if (createdKeyId === id) setCreatedKeyId(null); setVisibleKeys((prev) => { - const next = new Set(prev) - next.delete(id) - return next - }) - void reload() + const next = new Set(prev); + next.delete(id); + return next; + }); + void reload(); } catch (error) { - showToast(`${t('apiKeys.deleteFailed')}: ${getErrorMessage(error)}`, 'error') + showToast( + `${t("apiKeys.deleteFailed")}: ${getErrorMessage(error)}`, + "error", + ); } finally { setDeletingIds((prev) => { - const next = new Set(prev) - next.delete(id) - return next - }) + const next = new Set(prev); + next.delete(id); + return next; + }); } - } + }; const handleCopy = async (text: string) => { try { if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text) - showToast(t('common.copied')) - return + await navigator.clipboard.writeText(text); + showToast(t("common.copied")); + return; } - const textarea = document.createElement('textarea') - textarea.value = text - textarea.setAttribute('readonly', 'true') - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - textarea.style.pointerEvents = 'none' - document.body.appendChild(textarea) - textarea.select() - textarea.setSelectionRange(0, text.length) - const copied = document.execCommand('copy') - document.body.removeChild(textarea) - - if (!copied) throw new Error('copy failed') - showToast(t('common.copied')) + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, text.length); + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + + if (!copied) throw new Error("copy failed"); + showToast(t("common.copied")); } catch { - showToast(t('common.copyFailed'), 'error') + showToast(t("common.copyFailed"), "error"); } - } + }; const toggleVisible = (id: number) => { setVisibleKeys((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const startEditing = (keyRow: APIKeyRow) => { + setEditingKey(keyRow); + setEditForm({ + name: keyRow.name, + quotaLimit: keyRow.quota_limit > 0 ? String(keyRow.quota_limit) : "", + expireMode: keyRow.expires_at ? "custom" : "never", + expiresAt: toDateTimeLocalValue(keyRow.expires_at), + allowedGroupIds: keyRow.allowed_group_ids ?? [], + }); + }; + + const closeEditDialog = () => { + if (saving) return; + setEditingKey(null); + setEditForm(initialEditForm); + }; + + const updateEditForm = (patch: Partial) => { + setEditForm((current) => ({ ...current, ...patch })); + }; + + const handleSaveEdit = async (event?: FormEvent) => { + event?.preventDefault(); + if (!editingKey) return; + const trimmed = editForm.name.trim(); + if (!trimmed) { + showToast(t("apiKeys.nameRequired"), "error"); + return; + } + setSaving(true); + try { + const quotaLimit = parseQuotaLimit(editForm.quotaLimit, t); + await api.updateAPIKey(editingKey.id, { + name: trimmed, + quota_limit: quotaLimit, + allowed_group_ids: editForm.allowedGroupIds, + ...buildExpirationPayload(editForm, t, { clearNever: true }), + }); + showToast(t("apiKeys.keyUpdated")); + setEditingKey(null); + setEditForm(initialEditForm); + void reload(); + } catch (error) { + showToast( + `${t("apiKeys.updateFailed")}: ${getErrorMessage(error)}`, + "error", + ); + } finally { + setSaving(false); + } + }; return ( void reload()} - loadingTitle={t('apiKeys.loadingTitle')} - loadingDescription={t('apiKeys.loadingDesc')} - errorTitle={t('apiKeys.errorTitle')} + loadingTitle={t("apiKeys.loadingTitle")} + loadingDescription={t("apiKeys.loadingDesc")} + errorTitle={t("apiKeys.errorTitle")} > <> void reload()} actions={ - } /> @@ -233,77 +337,118 @@ export default function APIKeys() {
} - label={t('apiKeys.totalKeys')} + label={t("apiKeys.totalKeys")} value={String(keys.length)} - sub={keys.length > 0 ? t('apiKeys.totalKeysDesc') : t('apiKeys.noKeysShort')} + sub={ + keys.length > 0 + ? t("apiKeys.totalKeysDesc") + : t("apiKeys.noKeysShort") + } tone="info" /> } - label={t('apiKeys.authMode')} - value={keys.length > 0 ? t('apiKeys.authEnabled') : t('apiKeys.authDisabled')} - sub={keys.length > 0 ? t('apiKeys.authEnabledDesc') : t('apiKeys.authDisabledDesc')} - tone={keys.length > 0 ? 'success' : 'warning'} + label={t("apiKeys.authMode")} + value={ + keys.length > 0 + ? t("apiKeys.authEnabled") + : t("apiKeys.authDisabled") + } + sub={ + keys.length > 0 + ? t("apiKeys.authEnabledDesc") + : t("apiKeys.authDisabledDesc") + } + tone={keys.length > 0 ? "success" : "warning"} /> } - label={t('apiKeys.newestKey')} - value={latestKey?.name || '-'} - sub={latestKey ? formatRelativeTime(latestKey.created_at, { variant: 'compact' }) : t('apiKeys.noLatest')} + label={t("apiKeys.newestKey")} + value={latestKey?.name || "-"} + sub={ + latestKey + ? formatRelativeTime(latestKey.created_at, { + variant: "compact", + }) + : t("apiKeys.noLatest") + } tone="neutral" />
- -
+ +
-

{t('apiKeys.tableTitle')}

-

{t('apiKeys.tableDesc')}

+

+ {t("apiKeys.tableTitle")} +

+

+ {t("apiKeys.tableDesc")} +

- 0 ? 'default' : 'secondary'}> - {t('apiKeys.keyCount', { count: keys.length })} + 0 ? "default" : "secondary"}> + {t("apiKeys.keyCount", { count: keys.length })}
- {t('common.name')} - {t('apiKeys.keyColumn')} - {t('apiKeys.quotaColumn')} - {t('apiKeys.expiresColumn')} - {t('common.createdAt')} - {t('common.actions')} + {t("common.name")} + {t("apiKeys.keyColumn")} + {t("apiKeys.allowedGroups")} + {t("apiKeys.quotaColumn")} + {t("apiKeys.expiresColumn")} + {t("common.createdAt")} + + {t("common.actions")} + {keys.map((keyRow) => { - const isVisible = visibleKeys.has(keyRow.id) - const isNew = createdKeyId === keyRow.id - const displayKey = isVisible ? keyRow.raw_key : keyRow.key - const copyValue = keyRow.raw_key || keyRow.key - const status = getAPIKeyStatus(keyRow) + const isVisible = visibleKeys.has(keyRow.id); + const isNew = createdKeyId === keyRow.id; + const displayKey = isVisible + ? keyRow.raw_key || keyRow.key + : keyRow.key; + const copyValue = keyRow.raw_key || keyRow.key; + const status = getAPIKeyStatus(keyRow); return ( - +
{keyRow.name} {isNew ? ( - - {t('apiKeys.newBadge')} + + {t("apiKeys.newBadge")} ) : null} - {status !== 'active' ? ( - + {status !== "active" ? ( + {t(`apiKeys.status.${status}`)} ) : null} @@ -311,35 +456,58 @@ export default function APIKeys() {
- + {displayKey}
+ + +
-
{formatQuotaLimit(keyRow, t)}
+
+ {formatQuotaLimit(keyRow, t)} +
{keyRow.quota_limit > 0 ? (
) : null} @@ -349,23 +517,35 @@ export default function APIKeys() { {formatExpiration(keyRow, t)} - {formatRelativeTime(keyRow.created_at, { variant: 'compact' })} + {formatRelativeTime(keyRow.created_at, { + variant: "compact", + })} -
+
+
- ) + ); })}
@@ -381,13 +561,21 @@ export default function APIKeys() {
-
{t('apiKeys.securityTitle')}
-

{t('apiKeys.keyAuthNote')}

+
+ {t("apiKeys.securityTitle")} +
+

+ {t("apiKeys.keyAuthNote")} +

-
@@ -395,162 +583,494 @@ export default function APIKeys() { - - } > -
void handleCreateKey(event)}> + void handleCreateKey(event)} + >
-

{t('apiKeys.createDesc')}

+

+ {t("apiKeys.createDesc")} +

- + ) => updateCreateForm({ name: event.target.value })} + onChange={(event: ChangeEvent) => + updateCreateForm({ name: event.target.value }) + } /> - + ) => updateCreateForm({ key: event.target.value })} + onChange={(event: ChangeEvent) => + updateCreateForm({ key: event.target.value }) + } />
- }> + } + > ) => updateCreateForm({ quotaLimit: event.target.value })} + onChange={(event: ChangeEvent) => + updateCreateForm({ quotaLimit: event.target.value }) + } /> - }> + } + > ) => updateCreateForm({ expiresAt: event.target.value })} + onChange={(event: ChangeEvent) => + updateCreateForm({ expiresAt: event.target.value }) + } /> ) : null} + + } + as="div" + > + + updateCreateForm({ allowedGroupIds }) + } + allLabel={t("apiKeys.allowedGroupsAll")} + placeholder={t("apiKeys.allowedGroupsPlaceholder")} + emptyLabel={t("accounts.groupsNone")} + /> +

+ {t("apiKeys.allowedGroupsHint")} +

+
+ + + + + } + > + {editingKey ? ( +
void handleSaveEdit(event)} + > +
+
+ +
+
+
+ {editingKey.name} +
+

+ {t("apiKeys.editDesc")} +

+
+
+ +
+ + ) => + updateEditForm({ name: event.target.value }) + } + autoFocus + /> + + } + > + ) => + updateEditForm({ quotaLimit: event.target.value }) + } + /> + +
+ +
+ } + > + ) => + updateEditForm({ expiresAt: event.target.value }) + } + /> + + ) : editForm.expireMode === "never" ? ( +
+ {t("apiKeys.clearExpirationHint")} +
+ ) : ( +
+ {t("apiKeys.relativeExpirationHint", { + days: editForm.expireMode, + })} +
+ )} +
+ + } + as="div" + > + + updateEditForm({ allowedGroupIds }) + } + allLabel={t("apiKeys.allowedGroupsAll")} + placeholder={t("apiKeys.allowedGroupsPlaceholder")} + emptyLabel={t("accounts.groupsNone")} + /> +

+ {t("apiKeys.allowedGroupsHint")} +

+
+
+ ) : null} +
+ {confirmDialog} - ) + ); } -type Translator = (key: string, options?: Record) => string +type Translator = (key: string, options?: Record) => string; -function buildExpirationPayload(form: CreateKeyFormState, t: Translator): { expires_in_days?: number; expires_at?: string } { - if (form.expireMode === 'never') return {} - if (form.expireMode !== 'custom') { - return { expires_in_days: Number(form.expireMode) } +function parseQuotaLimit(raw: string, t: Translator): number { + const quotaLimitText = raw.trim(); + if (!quotaLimitText) return 0; + const quotaLimit = Number(quotaLimitText); + if (!Number.isFinite(quotaLimit) || quotaLimit < 0) { + throw new Error(t("apiKeys.quotaInvalid")); + } + return quotaLimit; +} + +function buildExpirationPayload( + form: Pick, + t: Translator, + options: { clearNever?: boolean } = {}, +): { expires_in_days?: number; expires_at?: string | null } { + if (form.expireMode === "never") + return options.clearNever ? { expires_at: null } : {}; + if (form.expireMode !== "custom") { + return { expires_in_days: Number(form.expireMode) }; } if (!form.expiresAt) { - throw new Error(t('apiKeys.expiresAtRequired')) + throw new Error(t("apiKeys.expiresAtRequired")); } - const date = new Date(form.expiresAt) + const date = new Date(form.expiresAt); if (!Number.isFinite(date.getTime())) { - throw new Error(t('apiKeys.expiresAtInvalid')) + throw new Error(t("apiKeys.expiresAtInvalid")); } if (date.getTime() <= Date.now()) { - throw new Error(t('apiKeys.expiresAtPast')) + throw new Error(t("apiKeys.expiresAtPast")); } - return { expires_at: date.toISOString() } + return { expires_at: date.toISOString() }; +} + +function toDateTimeLocalValue(value?: string | null) { + if (!value) return ""; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return ""; + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 16); } -function getAPIKeyStatus(keyRow: APIKeyRow): 'active' | 'expired' | 'quota_exhausted' { - if (keyRow.status === 'expired' || keyRow.status === 'quota_exhausted') { - return keyRow.status +function getAPIKeyStatus( + keyRow: APIKeyRow, +): "active" | "expired" | "quota_exhausted" { + if (keyRow.status === "expired" || keyRow.status === "quota_exhausted") { + return keyRow.status; } - if (keyRow.expires_at && new Date(keyRow.expires_at).getTime() <= Date.now()) { - return 'expired' + if ( + keyRow.expires_at && + new Date(keyRow.expires_at).getTime() <= Date.now() + ) { + return "expired"; } if (keyRow.quota_limit > 0 && keyRow.quota_used >= keyRow.quota_limit) { - return 'quota_exhausted' + return "quota_exhausted"; } - return 'active' + return "active"; } function formatQuotaLimit(keyRow: APIKeyRow, t: Translator) { if (!keyRow.quota_limit || keyRow.quota_limit <= 0) { - return t('apiKeys.unlimited') + return t("apiKeys.unlimited"); } - return t('apiKeys.quotaUsedOfLimit', { + return t("apiKeys.quotaUsedOfLimit", { used: formatUSD(keyRow.quota_used), limit: formatUSD(keyRow.quota_limit), - }) + }); } function formatExpiration(keyRow: APIKeyRow, t: Translator) { if (!keyRow.expires_at) { - return t('apiKeys.neverExpires') + return t("apiKeys.neverExpires"); } - return formatBeijingTime(keyRow.expires_at) + return formatBeijingTime(keyRow.expires_at); } function formatUSD(value: number) { - if (!Number.isFinite(value)) return '$0' - if (value >= 1) return `$${value.toFixed(2)}` - if (value >= 0.01) return `$${value.toFixed(4)}` - return `$${value.toFixed(6)}` + if (!Number.isFinite(value)) return "$0"; + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(4)}`; + return `$${value.toFixed(6)}`; +} + +function AllowedGroupsDisplay({ + ids, + groups, + t, +}: { + ids: number[]; + groups: AccountGroup[]; + t: Translator; +}) { + const selected = resolveGroups(ids, groups); + if (ids.length === 0) { + return {t("apiKeys.allowedGroupsAll")}; + } + if (selected.length === 0) { + return ( + {t("apiKeys.allowedGroupsMissing")} + ); + } + return ( +
+ {selected.slice(0, 2).map((group) => ( + + {group.name} + + ))} + {selected.length > 2 ? ( + + +{selected.length - 2} + + ) : null} +
+ ); +} + +function resolveGroups(ids: number[], groups: AccountGroup[]): AccountGroup[] { + const byID = new Map(groups.map((group) => [group.id, group])); + return ids.map((id) => byID.get(id)).filter(Boolean) as AccountGroup[]; +} + +function GroupMultiSelect({ + groups, + value, + onChange, + allLabel, + placeholder, + emptyLabel, +}: { + groups: AccountGroup[]; + value: number[]; + onChange: (value: number[]) => void; + allLabel: string; + placeholder: string; + emptyLabel: string; +}) { + const selected = resolveGroups(value, groups); + const summary = + value.length === 0 + ? allLabel + : selected.length > 0 + ? selected.map((group) => group.name).join(", ") + : placeholder; + + return ( +
+
+ {summary} +
+ {groups.length === 0 ? ( +
+ {emptyLabel} +
+ ) : ( +
+ + {groups.map((group) => { + const active = value.includes(group.id); + return ( + + ); + })} +
+ )} +
+ ); } function FormField({ label, icon, children, + as = "label", }: { - label: string - icon?: ReactNode - children: ReactNode + label: string; + icon?: ReactNode; + children: ReactNode; + as?: "label" | "div"; }) { + const Component = as; return ( - - ) + + ); } function KeySummaryCard({ @@ -560,31 +1080,39 @@ function KeySummaryCard({ sub, tone, }: { - icon: ReactNode - label: string - value: string - sub: string - tone: 'neutral' | 'info' | 'success' | 'warning' + icon: ReactNode; + label: string; + value: string; + sub: string; + tone: "neutral" | "info" | "success" | "warning"; }) { const toneClassName = { - neutral: 'bg-muted text-muted-foreground', - info: 'bg-primary/10 text-primary', - success: 'bg-[hsl(var(--success-bg))] text-[hsl(var(--success))]', - warning: 'bg-[hsl(var(--warning-bg))] text-[hsl(var(--warning))]', - }[tone] + neutral: "bg-muted text-muted-foreground", + info: "bg-primary/10 text-primary", + success: "bg-[hsl(var(--success-bg))] text-[hsl(var(--success))]", + warning: "bg-[hsl(var(--warning-bg))] text-[hsl(var(--warning))]", + }[tone]; return (
-
{label}
-
{value}
-
{sub}
+
+ {label} +
+
+ {value} +
+
+ {sub} +
-
+
{icon}
- ) + ); } diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 7a632d7a..cd6e5076 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -1,23 +1,33 @@ -import type { ChangeEvent, DragEvent, ReactNode } from 'react' -import { useCallback, useEffect, useRef, useState, useMemo } from 'react' -import { api, getAdminKey } from '../api' -import Modal from '../components/Modal' -import PageHeader from '../components/PageHeader' -import Pagination from '../components/Pagination' -import StateShell from '../components/StateShell' -import StatusBadge from '../components/StatusBadge' -import ToastNotice from '../components/ToastNotice' -import { useDataLoader } from '../hooks/useDataLoader' -import { useConfirmDialog } from '../hooks/useConfirmDialog' -import { useToast } from '../hooks/useToast' -import type { AccountRow, AddAccountRequest, AddATAccountRequest, AddOpenAIResponsesAccountRequest, UpdateOpenAIResponsesAccountRequest, APIKeyRow, OpsOverviewResponse } from '../types' -import { getErrorMessage } from '../utils/error' -import { formatCompactEmail } from '../lib/utils' -import { formatRelativeTime, formatBeijingTime } from '../utils/time' -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Select } from '@/components/ui/select' +import type { ChangeEvent, DragEvent, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, useMemo } from "react"; +import { api, getAdminKey } from "../api"; +import Modal from "../components/Modal"; +import PageHeader from "../components/PageHeader"; +import Pagination from "../components/Pagination"; +import StateShell from "../components/StateShell"; +import StatusBadge from "../components/StatusBadge"; +import ToastNotice from "../components/ToastNotice"; +import { useDataLoader } from "../hooks/useDataLoader"; +import { useConfirmDialog } from "../hooks/useConfirmDialog"; +import { useToast } from "../hooks/useToast"; +import type { + AccountRow, + AddAccountRequest, + AddATAccountRequest, + AddOpenAIResponsesAccountRequest, + UpdateOpenAIResponsesAccountRequest, + APIKeyRow, + OpsOverviewResponse, + AccountGroup, +} from "../types"; +import { getErrorMessage } from "../utils/error"; +import { formatCompactEmail } from "../lib/utils"; +import { formatRelativeTime, formatBeijingTime } from "../utils/time"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select } from "@/components/ui/select"; import { Table, TableBody, @@ -25,267 +35,507 @@ import { TableHead, TableHeader, TableRow, -} from '@/components/ui/table' -import { Plus, RefreshCw, Trash2, Zap, FlaskConical, Ban, Timer, AlertTriangle, Upload, Download, ArrowDownToLine, KeyRound, ExternalLink, FileText, FileJson, BarChart3, Search, Fingerprint, FolderOpen, Lock, Unlock, RotateCcw, Pencil, Check, ChevronDown, Copy, Power, PowerOff, Hourglass, X } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import AccountUsageModal from '../components/AccountUsageModal' -import AccountQuotaDistributionChart from '../components/AccountQuotaDistributionChart' -import AccountRateLimitRecoveryChart from '../components/AccountRateLimitRecoveryChart' +} from "@/components/ui/table"; +import { + Plus, + RefreshCw, + Trash2, + Zap, + FlaskConical, + Ban, + Timer, + AlertTriangle, + Upload, + Download, + ArrowDownToLine, + KeyRound, + ExternalLink, + FileText, + FileJson, + BarChart3, + Search, + Fingerprint, + FolderOpen, + Lock, + Unlock, + RotateCcw, + Pencil, + Check, + ChevronDown, + Copy, + Power, + PowerOff, + Hourglass, + X, + SlidersHorizontal, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import AccountUsageModal from "../components/AccountUsageModal"; +import AccountQuotaDistributionChart from "../components/AccountQuotaDistributionChart"; +import AccountRateLimitRecoveryChart from "../components/AccountRateLimitRecoveryChart"; +import ChipInput from "../components/ChipInput"; + +const ACCOUNT_BATCH_CONCURRENCY = 6; +const ACCOUNT_REFRESH_BATCH_CONCURRENCY = 4; +const ACCOUNT_ANALYSIS_VISIBILITY_KEY = "codex2api:accounts:analysis-visible"; +const ACCOUNT_VISIBLE_COLUMNS_KEY = "codex2api:accounts:visible-columns"; +const ACCOUNT_TABLE_COLUMNS = [ + "sequence", + "email", + "tags", + "groups", + "plan", + "status", + "requests", + "usage", + "importTime", + "updatedAt", + "actions", +] as const; +const ACCOUNT_GROUP_COLORS = [ + "#2563eb", + "#16a34a", + "#d97706", + "#dc2626", + "#7c3aed", + "#0891b2", + "#64748b", +] as const; +type AccountTableColumn = (typeof ACCOUNT_TABLE_COLUMNS)[number]; +type AccountGroupDraft = { + id: number | null; + name: string; + description: string; + color: string; +}; + +function getDefaultAccountVisibleColumns(): Record< + AccountTableColumn, + boolean +> { + return Object.fromEntries( + ACCOUNT_TABLE_COLUMNS.map((column) => [ + column, + column !== "tags" && column !== "groups", + ]), + ) as Record; +} + +function getInitialAccountVisibleColumns(): Record< + AccountTableColumn, + boolean +> { + const fallback = getDefaultAccountVisibleColumns(); + try { + const raw = window.localStorage.getItem(ACCOUNT_VISIBLE_COLUMNS_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw) as Partial< + Record + >; + return Object.fromEntries( + ACCOUNT_TABLE_COLUMNS.map((column) => [ + column, + column === "tags" || column === "groups" + ? parsed[column] === true + : parsed[column] !== false, + ]), + ) as Record; + } catch { + return fallback; + } +} -const ACCOUNT_BATCH_CONCURRENCY = 6 -const ACCOUNT_REFRESH_BATCH_CONCURRENCY = 4 -const ACCOUNT_ANALYSIS_VISIBILITY_KEY = 'codex2api:accounts:analysis-visible' +function persistAccountVisibleColumns( + columns: Record, +) { + try { + window.localStorage.setItem( + ACCOUNT_VISIBLE_COLUMNS_KEY, + JSON.stringify(columns), + ); + } catch { + // Keep the in-memory preference working when localStorage is unavailable. + } +} function getInitialAnalysisVisibility(): boolean { try { - return window.localStorage.getItem(ACCOUNT_ANALYSIS_VISIBILITY_KEY) !== 'false' + return ( + window.localStorage.getItem(ACCOUNT_ANALYSIS_VISIBILITY_KEY) !== "false" + ); } catch { - return true + return true; } } function persistAnalysisVisibility(visible: boolean) { try { - window.localStorage.setItem(ACCOUNT_ANALYSIS_VISIBILITY_KEY, visible ? 'true' : 'false') + window.localStorage.setItem( + ACCOUNT_ANALYSIS_VISIBILITY_KEY, + visible ? "true" : "false", + ); } catch { // Local storage can be unavailable in restricted browser modes; keep the in-memory toggle working. } } function parseModelTokens(value: string): string[] { - const seen = new Set() + const seen = new Set(); return value .split(/[\n,\t ]+/) - .map(item => item.trim()) - .filter(item => { - if (!item) return false - const key = item.toLowerCase() - if (seen.has(key)) return false - seen.add(key) - return true - }) + .map((item) => item.trim()) + .filter((item) => { + if (!item) return false; + const key = item.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } function mergeModelLists(current: string[], incoming: string[]): string[] { - const seen = new Set() - const result: string[] = [] + const seen = new Set(); + const result: string[] = []; for (const item of [...current, ...incoming]) { - const value = item.trim() - if (!value) continue - const key = value.toLowerCase() - if (seen.has(key)) continue - seen.add(key) - result.push(value) + const value = item.trim(); + if (!value) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(value); } - return result + return result; } function formatAccountName(account: AccountRow): string { if (account.openai_responses_api) { - return account.name?.trim() || `ID ${account.id}` + return account.name?.trim() || `ID ${account.id}`; } - return account.email || account.name || `ID ${account.id}` + return account.email || account.name || `ID ${account.id}`; } -async function runAccountBatch(ids: number[], action: (id: number) => Promise, concurrency = ACCOUNT_BATCH_CONCURRENCY) { - let success = 0 - let fail = 0 - let cursor = 0 - const workerCount = Math.min(concurrency, ids.length) - - await Promise.all(Array.from({ length: workerCount }, async () => { - while (cursor < ids.length) { - const id = ids[cursor] - cursor += 1 - try { - await action(id) - success += 1 - } catch { - fail += 1 +async function runAccountBatch( + ids: number[], + action: (id: number) => Promise, + concurrency = ACCOUNT_BATCH_CONCURRENCY, +) { + let success = 0; + let fail = 0; + let cursor = 0; + const workerCount = Math.min(concurrency, ids.length); + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (cursor < ids.length) { + const id = ids[cursor]; + cursor += 1; + try { + await action(id); + success += 1; + } catch { + fail += 1; + } } - } - })) + }), + ); - return { success, fail } + return { success, fail }; } export default function Accounts() { - const { t } = useTranslation() - const pageSizeOptions = [10, 20, 50, 100] - const [showAdd, setShowAdd] = useState(false) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [statusFilter, setStatusFilter] = useState<'all' | 'normal' | 'rate_limited' | 'banned' | 'error' | 'disabled' | 'locked'>('all') - const [searchQuery, setSearchQuery] = useState('') - const [planFilter, setPlanFilter] = useState<'all' | 'pro' | 'prolite' | 'plus' | 'team' | 'free'>('all') - const [sortKey, setSortKey] = useState<'requests' | 'usage' | 'importTime' | null>(null) - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + const { t } = useTranslation(); + const pageSizeOptions = [10, 20, 50, 100]; + const [showAdd, setShowAdd] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [statusFilter, setStatusFilter] = useState< + | "all" + | "normal" + | "rate_limited" + | "banned" + | "error" + | "disabled" + | "locked" + >("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [planFilter, setPlanFilter] = useState< + "all" | "pro" | "prolite" | "plus" | "team" | "free" + >("all"); + const [sortKey, setSortKey] = useState< + "requests" | "usage" | "importTime" | null + >(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [addForm, setAddForm] = useState({ - refresh_token: '', - proxy_url: '', - }) - const [submitting, setSubmitting] = useState(false) - const [selected, setSelected] = useState>(new Set()) - const [refreshingIds, setRefreshingIds] = useState>(new Set()) - const [authJsonExportingIds, setAuthJsonExportingIds] = useState>(new Set()) - const [authJsonModal, setAuthJsonModal] = useState<{ account: AccountRow; json: string } | null>(null) - const [batchLoading, setBatchLoading] = useState(false) - const [batchRefreshing, setBatchRefreshing] = useState(false) - const [batchTesting, setBatchTesting] = useState(false) - const [lockingSubscriptionAccounts, setLockingSubscriptionAccounts] = useState(false) - const [cleaningBanned, setCleaningBanned] = useState(false) - const [cleaningRateLimited, setCleaningRateLimited] = useState(false) - const [cleaningError, setCleaningError] = useState(false) - const [testingAccount, setTestingAccount] = useState(null) - const [usageAccount, setUsageAccount] = useState(null) - const [editingAccount, setEditingAccount] = useState(null) - const [editSubmitting, setEditSubmitting] = useState(false) - const [editTab, setEditTab] = useState<'scheduler' | 'account'>('scheduler') - const [scoreMode, setScoreMode] = useState<'default' | 'custom'>('default') - const [scoreInput, setScoreInput] = useState('') - const [concurrencyMode, setConcurrencyMode] = useState<'default' | 'custom'>('default') - const [concurrencyInput, setConcurrencyInput] = useState('') - const [allowedAPIKeySelection, setAllowedAPIKeySelection] = useState([]) - const [editOpenAIForm, setEditOpenAIForm] = useState({ - name: '', - base_url: 'https://api.openai.com', - api_key: '', - models: [], - proxy_url: '', - }) - const [openAIModelDraft, setOpenAIModelDraft] = useState('') - const [editOpenAIModelDraft, setEditOpenAIModelDraft] = useState('') - const [editOpenAIModelsLoading, setEditOpenAIModelsLoading] = useState(false) - const [importing, setImporting] = useState(false) - const [showImportPicker, setShowImportPicker] = useState(false) - const [dragging, setDragging] = useState(false) - const dragCounter = useRef(0) - const [showExportPicker, setShowExportPicker] = useState(false) - const [exporting, setExporting] = useState(false) - const [showMigrate, setShowMigrate] = useState(false) - const [showAnalysisCharts, setShowAnalysisCharts] = useState(getInitialAnalysisVisibility) - const [migrateUrl, setMigrateUrl] = useState('') - const [migrateKey, setMigrateKey] = useState('') - const [migrating, setMigrating] = useState(false) - const [importProgress, setImportProgress] = useState<{ show: boolean; current: number; total: number; success: number; duplicate: number; failed: number; done: boolean }>({ show: false, current: 0, total: 0, success: 0, duplicate: 0, failed: 0, done: false }) - const [addMethod, setAddMethod] = useState<'rt' | 'at' | 'openai' | 'oauth'>('rt') + refresh_token: "", + proxy_url: "", + }); + const [submitting, setSubmitting] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [refreshingIds, setRefreshingIds] = useState>(new Set()); + const [authJsonExportingIds, setAuthJsonExportingIds] = useState>( + new Set(), + ); + const [authJsonModal, setAuthJsonModal] = useState<{ + account: AccountRow; + json: string; + } | null>(null); + const [batchLoading, setBatchLoading] = useState(false); + const [batchRefreshing, setBatchRefreshing] = useState(false); + const [batchTesting, setBatchTesting] = useState(false); + const [lockingSubscriptionAccounts, setLockingSubscriptionAccounts] = + useState(false); + const [cleaningBanned, setCleaningBanned] = useState(false); + const [cleaningRateLimited, setCleaningRateLimited] = useState(false); + const [cleaningError, setCleaningError] = useState(false); + const [testingAccount, setTestingAccount] = useState(null); + const [usageAccount, setUsageAccount] = useState(null); + const [editingAccount, setEditingAccount] = useState(null); + const [editSubmitting, setEditSubmitting] = useState(false); + const [editTab, setEditTab] = useState<"scheduler" | "account">("scheduler"); + const [scoreMode, setScoreMode] = useState<"default" | "custom">("default"); + const [scoreInput, setScoreInput] = useState(""); + const [concurrencyMode, setConcurrencyMode] = useState<"default" | "custom">( + "default", + ); + const [concurrencyInput, setConcurrencyInput] = useState(""); + const [allowedAPIKeySelection, setAllowedAPIKeySelection] = useState< + number[] + >([]); + const [editProxyUrl, setEditProxyUrl] = useState(""); + const [editOpenAIForm, setEditOpenAIForm] = + useState({ + name: "", + base_url: "https://api.openai.com", + api_key: "", + models: [], + proxy_url: "", + }); + const [openAIModelDraft, setOpenAIModelDraft] = useState(""); + const [editOpenAIModelDraft, setEditOpenAIModelDraft] = useState(""); + const [editOpenAIModelsLoading, setEditOpenAIModelsLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [showImportPicker, setShowImportPicker] = useState(false); + const [dragging, setDragging] = useState(false); + const dragCounter = useRef(0); + const [showExportPicker, setShowExportPicker] = useState(false); + const [exporting, setExporting] = useState(false); + const [showMigrate, setShowMigrate] = useState(false); + const [showAnalysisCharts, setShowAnalysisCharts] = useState( + getInitialAnalysisVisibility, + ); + const [migrateUrl, setMigrateUrl] = useState(""); + const [migrateKey, setMigrateKey] = useState(""); + const [migrating, setMigrating] = useState(false); + const [importProgress, setImportProgress] = useState<{ + show: boolean; + current: number; + total: number; + success: number; + duplicate: number; + failed: number; + done: boolean; + }>({ + show: false, + current: 0, + total: 0, + success: 0, + duplicate: 0, + failed: 0, + done: false, + }); + const [addMethod, setAddMethod] = useState<"rt" | "at" | "openai" | "oauth">( + "rt", + ); const [atForm, setAtForm] = useState({ - access_token: '', - proxy_url: '', - }) - const [openAIForm, setOpenAIForm] = useState({ - base_url: 'https://api.openai.com', - api_key: '', - models: [], - proxy_url: '', - }) - const [openAIModelsLoading, setOpenAIModelsLoading] = useState(false) - const [oauthStep, setOauthStep] = useState<'generate' | 'exchange'>('generate') - const [oauthSession, setOauthSession] = useState<{ session_id: string; auth_url: string } | null>(null) - const [oauthProxyUrl, setOauthProxyUrl] = useState('') - const [oauthCallbackUrl, setOauthCallbackUrl] = useState('') - const [oauthName, setOauthName] = useState('') - const [oauthGenerating, setOauthGenerating] = useState(false) - const [oauthCompleting, setOauthCompleting] = useState(false) - const fileInputRef = useRef(null) - const jsonInputRef = useRef(null) - const atFileInputRef = useRef(null) - const folderInputRef = useRef(null) - const selectAllRef = useRef(null) - const { toast, showToast } = useToast() - const { confirm, confirmDialog } = useConfirmDialog() + access_token: "", + proxy_url: "", + }); + const [openAIForm, setOpenAIForm] = + useState({ + base_url: "https://api.openai.com", + api_key: "", + models: [], + proxy_url: "", + }); + const [openAIModelsLoading, setOpenAIModelsLoading] = useState(false); + const [oauthStep, setOauthStep] = useState<"generate" | "exchange">( + "generate", + ); + const [oauthSession, setOauthSession] = useState<{ + session_id: string; + auth_url: string; + } | null>(null); + const [oauthProxyUrl, setOauthProxyUrl] = useState(""); + const [oauthCallbackUrl, setOauthCallbackUrl] = useState(""); + const [oauthName, setOauthName] = useState(""); + const [oauthGenerating, setOauthGenerating] = useState(false); + const [oauthCompleting, setOauthCompleting] = useState(false); + const [editTags, setEditTags] = useState([]); + const [editGroupIds, setEditGroupIds] = useState([]); + const [tagFilter, setTagFilter] = useState(""); + const [groupFilter, setGroupFilter] = useState(null); + const [allGroups, setAllGroups] = useState([]); + const [showGroupManager, setShowGroupManager] = useState(false); + const [groupDraft, setGroupDraft] = useState({ + id: null, + name: "", + description: "", + color: ACCOUNT_GROUP_COLORS[0], + }); + const [groupSubmitting, setGroupSubmitting] = useState(false); + const [showBatchMetaEditor, setShowBatchMetaEditor] = useState(false); + const [batchTags, setBatchTags] = useState([]); + const [batchGroupIds, setBatchGroupIds] = useState([]); + const [batchMetaSubmitting, setBatchMetaSubmitting] = useState(false); + const [visibleColumns, setVisibleColumns] = useState< + Record + >(getInitialAccountVisibleColumns); + const fileInputRef = useRef(null); + const jsonInputRef = useRef(null); + const atFileInputRef = useRef(null); + const folderInputRef = useRef(null); + const selectAllRef = useRef(null); + const { toast, showToast } = useToast(); + const { confirm, confirmDialog } = useConfirmDialog(); const loadAccounts = useCallback(async () => { - const [accountsResponse, apiKeysResponse, opsOverview] = await Promise.all([ - api.getAccounts(), - api.getAPIKeys(), - api.getOpsOverview().catch((): OpsOverviewResponse | null => null), - ]) + const [accountsResponse, apiKeysResponse, opsOverview, groupsResponse] = + await Promise.all([ + api.getAccounts(), + api.getAPIKeys(), + api.getOpsOverview().catch((): OpsOverviewResponse | null => null), + api.listAccountGroups().catch(() => ({ groups: [] })), + ]); + setAllGroups(groupsResponse.groups ?? []); return { accounts: accountsResponse.accounts ?? [], apiKeys: apiKeysResponse.keys ?? [], opsOverview, - } - }, []) - - const { data, loading, error, reload, reloadSilently } = useDataLoader<{ accounts: AccountRow[]; apiKeys: APIKeyRow[]; opsOverview: OpsOverviewResponse | null }>({ + }; + }, []); + + const { data, loading, error, reload, reloadSilently } = useDataLoader<{ + accounts: AccountRow[]; + apiKeys: APIKeyRow[]; + opsOverview: OpsOverviewResponse | null; + }>({ initialData: { accounts: [], apiKeys: [], opsOverview: null, }, load: loadAccounts, - }) - const accounts = data.accounts - const apiKeys = data.apiKeys - const opsOverview = data.opsOverview - const usageReloadAttemptsRef = useRef>(new Map()) + }); + const accounts = data.accounts; + const apiKeys = data.apiKeys; + const opsOverview = data.opsOverview; + const usageReloadAttemptsRef = useRef>(new Map()); + + useEffect(() => { + persistAnalysisVisibility(showAnalysisCharts); + }, [showAnalysisCharts]); + + useEffect(() => { + persistAccountVisibleColumns(visibleColumns); + }, [visibleColumns]); useEffect(() => { - persistAnalysisVisibility(showAnalysisCharts) - }, [showAnalysisCharts]) + if (groupFilter === null) return; + if (!allGroups.some((group) => group.id === groupFilter)) { + setGroupFilter(null); + } + }, [allGroups, groupFilter]); useEffect(() => { const needsUsageReload = (account: AccountRow) => { - if (account.status !== 'active' && account.status !== 'ready') { - return false + if (account.status !== "active" && account.status !== "ready") { + return false; } - const plan = normalizePlanType(account.plan_type) - const has7d = account.usage_percent_7d !== null && account.usage_percent_7d !== undefined - const has5h = account.usage_percent_5h !== null && account.usage_percent_5h !== undefined + const plan = normalizePlanType(account.plan_type); + const has7d = + account.usage_percent_7d !== null && + account.usage_percent_7d !== undefined; + const has5h = + account.usage_percent_5h !== null && + account.usage_percent_5h !== undefined; - if (plan === 'free') { - return !has7d + if (plan === "free") { + return !has7d; } - if (plan === 'pro' || plan === 'team' || plan === 'plus' || plan === 'teamplus') { - return !has5h || !has7d + if ( + plan === "pro" || + plan === "team" || + plan === "plus" || + plan === "teamplus" + ) { + return !has5h || !has7d; } - return !has7d - } + return !has7d; + }; - const missingUsageIds = accounts.filter(needsUsageReload).map((account) => account.id) - const missingUsageIdSet = new Set(missingUsageIds) + const missingUsageIds = accounts + .filter(needsUsageReload) + .map((account) => account.id); + const missingUsageIdSet = new Set(missingUsageIds); for (const id of Array.from(usageReloadAttemptsRef.current.keys())) { if (!missingUsageIdSet.has(id)) { - usageReloadAttemptsRef.current.delete(id) + usageReloadAttemptsRef.current.delete(id); } } - const retryIds = missingUsageIds.filter((id) => (usageReloadAttemptsRef.current.get(id) ?? 0) < 6) + const retryIds = missingUsageIds.filter( + (id) => (usageReloadAttemptsRef.current.get(id) ?? 0) < 6, + ); if (retryIds.length === 0) { - return + return; } for (const id of retryIds) { - usageReloadAttemptsRef.current.set(id, (usageReloadAttemptsRef.current.get(id) ?? 0) + 1) + usageReloadAttemptsRef.current.set( + id, + (usageReloadAttemptsRef.current.get(id) ?? 0) + 1, + ); } const timer = window.setTimeout(() => { - void reloadSilently() - }, 2500) + void reloadSilently(); + }, 2500); - return () => window.clearTimeout(timer) - }, [accounts, reloadSilently]) + return () => window.clearTimeout(timer); + }, [accounts, reloadSilently]); const accountSummary = useMemo(() => { - const rateLimitedWindowStats = getRateLimitedWindowStats(accounts) + const rateLimitedWindowStats = getRateLimitedWindowStats(accounts); return { totalAccounts: accounts.length, - normalAccounts: accounts.filter((account) => account.status === 'active' || account.status === 'ready').length, + normalAccounts: accounts.filter( + (account) => account.status === "active" || account.status === "ready", + ).length, rateLimitedAccounts: rateLimitedWindowStats.total, rateLimited5hAccounts: rateLimitedWindowStats.fiveHour, rateLimited7dAccounts: rateLimitedWindowStats.sevenDay, - bannedAccounts: accounts.filter((account) => account.status === 'unauthorized').length, - errorAccounts: accounts.filter((account) => account.status === 'error').length, - disabledAccounts: accounts.filter((account) => account.enabled === false).length, + bannedAccounts: accounts.filter( + (account) => account.status === "unauthorized", + ).length, + errorAccounts: accounts.filter((account) => account.status === "error") + .length, + disabledAccounts: accounts.filter((account) => account.enabled === false) + .length, lockedAccounts: accounts.filter((account) => account.locked).length, - subscriptionAccountsToLock: accounts.filter((account) => isSubscriptionPlan(account.plan_type) && !account.locked), - healthyAccounts: accounts.filter((account) => account.health_tier === 'healthy').length, - warmAccounts: accounts.filter((account) => account.health_tier === 'warm').length, - riskyAccounts: accounts.filter((account) => account.health_tier === 'risky').length, - } - }, [accounts]) + subscriptionAccountsToLock: accounts.filter( + (account) => isSubscriptionPlan(account.plan_type) && !account.locked, + ), + healthyAccounts: accounts.filter( + (account) => account.health_tier === "healthy", + ).length, + warmAccounts: accounts.filter((account) => account.health_tier === "warm") + .length, + riskyAccounts: accounts.filter( + (account) => account.health_tier === "risky", + ).length, + }; + }, [accounts]); const { totalAccounts, normalAccounts, @@ -300,288 +550,378 @@ export default function Accounts() { healthyAccounts, warmAccounts, riskyAccounts, - } = accountSummary + } = accountSummary; + + const allTags = useMemo(() => { + const tags = new Set(); + for (const account of accounts) { + for (const tag of account.tags ?? []) { + tags.add(tag); + } + } + return Array.from(tags).sort(); + }, [accounts]); const filteredAccounts = useMemo(() => { - const query = searchQuery.toLowerCase() + const query = searchQuery.toLowerCase(); return accounts.filter((account) => { switch (statusFilter) { - case 'normal': - if (account.status !== 'active' && account.status !== 'ready') return false - break - case 'rate_limited': - if (!isRateLimitedAccount(account)) return false - break - case 'banned': - if (account.status !== 'unauthorized') return false - break - case 'error': - if (account.status !== 'error') return false - break - case 'disabled': - if (account.enabled !== false) return false - break - case 'locked': - if (!account.locked) return false - break + case "normal": + if (account.status !== "active" && account.status !== "ready") + return false; + break; + case "rate_limited": + if (!isRateLimitedAccount(account)) return false; + break; + case "banned": + if (account.status !== "unauthorized") return false; + break; + case "error": + if (account.status !== "error") return false; + break; + case "disabled": + if (account.enabled !== false) return false; + break; + case "locked": + if (!account.locked) return false; + break; } - if (planFilter !== 'all') { - const plan = (account.plan_type || '').toLowerCase().trim() - if (plan !== planFilter) return false + if (planFilter !== "all") { + const plan = (account.plan_type || "").toLowerCase().trim(); + if (plan !== planFilter) return false; } if (query) { - const email = (account.email || '').toLowerCase() - const name = (account.name || '').toLowerCase() - if (!email.includes(query) && !name.includes(query)) return false + const email = (account.email || "").toLowerCase(); + const name = (account.name || "").toLowerCase(); + if (!email.includes(query) && !name.includes(query)) return false; } - return true - }) - }, [accounts, planFilter, searchQuery, statusFilter]) + if (tagFilter && !(account.tags ?? []).includes(tagFilter)) return false; + if ( + groupFilter !== null && + !(account.group_ids ?? []).includes(groupFilter) + ) + return false; + return true; + }); + }, [accounts, groupFilter, planFilter, searchQuery, statusFilter, tagFilter]); const sortedAccounts = useMemo(() => { - if (!sortKey) return filteredAccounts + if (!sortKey) return filteredAccounts; return [...filteredAccounts].sort((a, b) => { - let diff = 0 - if (sortKey === 'requests') { - diff = ((a.success_requests ?? 0) + (a.error_requests ?? 0)) - ((b.success_requests ?? 0) + (b.error_requests ?? 0)) - } else if (sortKey === 'usage') { - diff = (a.usage_percent_7d ?? -1) - (b.usage_percent_7d ?? -1) - } else if (sortKey === 'importTime') { - diff = new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime() + let diff = 0; + if (sortKey === "requests") { + diff = + (a.success_requests ?? 0) + + (a.error_requests ?? 0) - + ((b.success_requests ?? 0) + (b.error_requests ?? 0)); + } else if (sortKey === "usage") { + diff = (a.usage_percent_7d ?? -1) - (b.usage_percent_7d ?? -1); + } else if (sortKey === "importTime") { + diff = + new Date(a.created_at || 0).getTime() - + new Date(b.created_at || 0).getTime(); } - return sortDir === 'asc' ? diff : -diff - }) - }, [filteredAccounts, sortDir, sortKey]) + return sortDir === "asc" ? diff : -diff; + }); + }, [filteredAccounts, sortDir, sortKey]); - const totalPages = Math.max(1, Math.ceil(sortedAccounts.length / pageSize)) - const currentPage = Math.min(page, totalPages) + const totalPages = Math.max(1, Math.ceil(sortedAccounts.length / pageSize)); + const currentPage = Math.min(page, totalPages); const pagedAccounts = useMemo( - () => sortedAccounts.slice((currentPage - 1) * pageSize, currentPage * pageSize), + () => + sortedAccounts.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ), [currentPage, pageSize, sortedAccounts], - ) - const pagedAccountIds = useMemo(() => pagedAccounts.map((account) => account.id), [pagedAccounts]) + ); + const pagedAccountIds = useMemo( + () => pagedAccounts.map((account) => account.id), + [pagedAccounts], + ); const pageSelectedCount = useMemo( - () => pagedAccountIds.reduce((count, id) => count + (selected.has(id) ? 1 : 0), 0), + () => + pagedAccountIds.reduce( + (count, id) => count + (selected.has(id) ? 1 : 0), + 0, + ), [pagedAccountIds, selected], - ) - const allPageSelected = pagedAccountIds.length > 0 && pageSelectedCount === pagedAccountIds.length - const somePageSelected = pageSelectedCount > 0 && !allPageSelected + ); + const allPageSelected = + pagedAccountIds.length > 0 && pageSelectedCount === pagedAccountIds.length; + const somePageSelected = pageSelectedCount > 0 && !allPageSelected; useEffect(() => { if (page > totalPages) { - setPage(totalPages) + setPage(totalPages); } - }, [page, totalPages]) + }, [page, totalPages]); useEffect(() => { - if (!accounts.some((account) => account.status === 'refreshing')) { - return + if (!accounts.some((account) => account.status === "refreshing")) { + return; } const timer = window.setTimeout(() => { - void reloadSilently() - }, 2000) + void reloadSilently(); + }, 2000); - return () => window.clearTimeout(timer) - }, [accounts, reloadSilently]) + return () => window.clearTimeout(timer); + }, [accounts, reloadSilently]); useEffect(() => { if (selectAllRef.current) { - selectAllRef.current.indeterminate = somePageSelected + selectAllRef.current.indeterminate = somePageSelected; } - }, [somePageSelected]) + }, [somePageSelected]); const toggleSelect = useCallback((id: number) => { setSelected((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - }, []) + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); const toggleSelectAll = useCallback(() => { if (allPageSelected) { setSelected((prev) => { - const next = new Set(prev) - for (const id of pagedAccountIds) next.delete(id) - return next - }) + const next = new Set(prev); + for (const id of pagedAccountIds) next.delete(id); + return next; + }); } else { setSelected((prev) => { - const next = new Set(prev) - for (const id of pagedAccountIds) next.add(id) - return next - }) + const next = new Set(prev); + for (const id of pagedAccountIds) next.add(id); + return next; + }); } - }, [allPageSelected, pagedAccountIds]) + }, [allPageSelected, pagedAccountIds]); const handleAdd = async () => { - if (!addForm.refresh_token.trim()) return - setSubmitting(true) + if (!addForm.refresh_token.trim()) return; + setSubmitting(true); try { - await api.addAccount(addForm) - showToast(t('accounts.addSuccess')) - setShowAdd(false) - setAddForm({ refresh_token: '', proxy_url: '' }) - void reload() + await api.addAccount(addForm); + showToast(t("accounts.addSuccess")); + setShowAdd(false); + setAddForm({ refresh_token: "", proxy_url: "" }); + void reload(); } catch (error) { - showToast(t('accounts.addFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.addFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setSubmitting(false) + setSubmitting(false); } - } + }; const handleAddAT = async () => { - if (!atForm.access_token.trim()) return - setSubmitting(true) + if (!atForm.access_token.trim()) return; + setSubmitting(true); try { - await api.addATAccount(atForm) - showToast(t('accounts.addSuccess')) - setShowAdd(false) - setAtForm({ access_token: '', proxy_url: '' }) - void reload() + await api.addATAccount(atForm); + showToast(t("accounts.addSuccess")); + setShowAdd(false); + setAtForm({ access_token: "", proxy_url: "" }); + void reload(); } catch (error) { - showToast(t('accounts.addFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.addFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setSubmitting(false) + setSubmitting(false); } - } + }; const addOpenAIModelValues = useCallback((raw: string) => { - const nextModels = parseModelTokens(raw) - if (nextModels.length === 0) return - setOpenAIForm((form) => ({ ...form, models: mergeModelLists(form.models, nextModels) })) - setOpenAIModelDraft('') - }, []) + const nextModels = parseModelTokens(raw); + if (nextModels.length === 0) return; + setOpenAIForm((form) => ({ + ...form, + models: mergeModelLists(form.models, nextModels), + })); + setOpenAIModelDraft(""); + }, []); const removeOpenAIModel = useCallback((model: string) => { - setOpenAIForm((form) => ({ ...form, models: form.models.filter(item => item !== model) })) - }, []) + setOpenAIForm((form) => ({ + ...form, + models: form.models.filter((item) => item !== model), + })); + }, []); const addEditOpenAIModelValues = useCallback((raw: string) => { - const nextModels = parseModelTokens(raw) - if (nextModels.length === 0) return - setEditOpenAIForm((form) => ({ ...form, models: mergeModelLists(form.models, nextModels) })) - setEditOpenAIModelDraft('') - }, []) + const nextModels = parseModelTokens(raw); + if (nextModels.length === 0) return; + setEditOpenAIForm((form) => ({ + ...form, + models: mergeModelLists(form.models, nextModels), + })); + setEditOpenAIModelDraft(""); + }, []); const removeEditOpenAIModel = useCallback((model: string) => { - setEditOpenAIForm((form) => ({ ...form, models: form.models.filter(item => item !== model) })) - }, []) + setEditOpenAIForm((form) => ({ + ...form, + models: form.models.filter((item) => item !== model), + })); + }, []); const handleFetchOpenAIModels = async () => { - if (!openAIForm.api_key.trim()) return - setOpenAIModelsLoading(true) + if (!openAIForm.api_key.trim()) return; + setOpenAIModelsLoading(true); try { const result = await api.fetchOpenAIResponsesModels({ base_url: openAIForm.base_url, api_key: openAIForm.api_key, proxy_url: openAIForm.proxy_url, - }) - const models = result.models ?? [] - setOpenAIForm((form) => ({ ...form, base_url: result.base_url || form.base_url, models })) - showToast(t('accounts.openaiModelsFetchSuccess', { count: models.length })) + }); + const models = result.models ?? []; + setOpenAIForm((form) => ({ + ...form, + base_url: result.base_url || form.base_url, + models, + })); + showToast( + t("accounts.openaiModelsFetchSuccess", { count: models.length }), + ); } catch (error) { - showToast(t('accounts.openaiModelsFetchFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.openaiModelsFetchFailed", { + error: getErrorMessage(error), + }), + "error", + ); } finally { - setOpenAIModelsLoading(false) + setOpenAIModelsLoading(false); } - } + }; const handleAddOpenAIResponses = async () => { - const models = openAIForm.models - if (!openAIForm.api_key.trim() || models.length === 0) return - setSubmitting(true) + const models = openAIForm.models; + if (!openAIForm.api_key.trim() || models.length === 0) return; + setSubmitting(true); try { - await api.addOpenAIResponsesAccount({ ...openAIForm, models }) - showToast(t('accounts.addSuccess')) - setShowAdd(false) - setOpenAIForm({ base_url: 'https://api.openai.com', api_key: '', models: [], proxy_url: '' }) - setOpenAIModelDraft('') - void reload() + await api.addOpenAIResponsesAccount({ ...openAIForm, models }); + showToast(t("accounts.addSuccess")); + setShowAdd(false); + setOpenAIForm({ + base_url: "https://api.openai.com", + api_key: "", + models: [], + proxy_url: "", + }); + setOpenAIModelDraft(""); + void reload(); } catch (error) { - showToast(t('accounts.addFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.addFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setSubmitting(false) + setSubmitting(false); } - } + }; const handleFetchEditOpenAIModels = async () => { - if (!editingAccount?.openai_responses_api) return - setEditOpenAIModelsLoading(true) + if (!editingAccount?.openai_responses_api) return; + setEditOpenAIModelsLoading(true); try { const result = await api.fetchOpenAIResponsesModels({ account_id: editingAccount.id, base_url: editOpenAIForm.base_url, - api_key: editOpenAIForm.api_key ?? '', + api_key: editOpenAIForm.api_key ?? "", proxy_url: editOpenAIForm.proxy_url, - }) - const models = result.models ?? [] - setEditOpenAIForm((form) => ({ ...form, base_url: result.base_url || form.base_url, models })) - showToast(t('accounts.openaiModelsFetchSuccess', { count: models.length })) + }); + const models = result.models ?? []; + setEditOpenAIForm((form) => ({ + ...form, + base_url: result.base_url || form.base_url, + models, + })); + showToast( + t("accounts.openaiModelsFetchSuccess", { count: models.length }), + ); } catch (error) { - showToast(t('accounts.openaiModelsFetchFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.openaiModelsFetchFailed", { + error: getErrorMessage(error), + }), + "error", + ); } finally { - setEditOpenAIModelsLoading(false) + setEditOpenAIModelsLoading(false); } - } + }; const handleSaveOpenAIAccountSettings = async () => { - if (!editingAccount?.openai_responses_api) return + if (!editingAccount?.openai_responses_api) return; if (!editOpenAIForm.base_url.trim() || editOpenAIForm.models.length === 0) { - showToast(t('accounts.openaiAccountInvalid'), 'error') - return + showToast(t("accounts.openaiAccountInvalid"), "error"); + return; } - setEditSubmitting(true) + setEditSubmitting(true); try { await api.updateOpenAIResponsesAccount(editingAccount.id, { ...editOpenAIForm, api_key: editOpenAIForm.api_key?.trim() || undefined, - }) - showToast(t('accounts.openaiAccountSaveSuccess')) - await reload() - closeSchedulerEditor(true) + }); + showToast(t("accounts.openaiAccountSaveSuccess")); + await reload(); + closeSchedulerEditor(true); } catch (error) { - showToast(t('accounts.openaiAccountSaveFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.openaiAccountSaveFailed", { + error: getErrorMessage(error), + }), + "error", + ); } finally { - setEditSubmitting(false) + setEditSubmitting(false); } - } + }; const handleOAuthGenerate = async () => { - setOauthGenerating(true) + setOauthGenerating(true); try { - const result = await api.generateOAuthURL({ proxy_url: oauthProxyUrl }) - setOauthSession(result) - setOauthStep('exchange') + const result = await api.generateOAuthURL({ proxy_url: oauthProxyUrl }); + setOauthSession(result); + setOauthStep("exchange"); } catch (error) { - showToast(t('accounts.oauthFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.oauthFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setOauthGenerating(false) + setOauthGenerating(false); } - } + }; const handleOAuthComplete = async () => { - if (!oauthSession) return - let code = '' - let state = '' - const raw = oauthCallbackUrl.trim() + if (!oauthSession) return; + let code = ""; + let state = ""; + const raw = oauthCallbackUrl.trim(); try { - const url = new URL(raw) - code = url.searchParams.get('code') ?? '' - state = url.searchParams.get('state') ?? '' + const url = new URL(raw); + code = url.searchParams.get("code") ?? ""; + state = url.searchParams.get("state") ?? ""; } catch { - const qs = raw.includes('?') ? raw.split('?')[1] : raw - const params = new URLSearchParams(qs) - code = params.get('code') ?? '' - state = params.get('state') ?? '' + const qs = raw.includes("?") ? raw.split("?")[1] : raw; + const params = new URLSearchParams(qs); + code = params.get("code") ?? ""; + state = params.get("state") ?? ""; } if (!code || !state) { - showToast(t('accounts.oauthParseError'), 'error') - return + showToast(t("accounts.oauthParseError"), "error"); + return; } - setOauthCompleting(true) + setOauthCompleting(true); try { const result = await api.exchangeOAuthCode({ session_id: oauthSession.session_id, @@ -589,685 +929,1037 @@ export default function Accounts() { state, name: oauthName.trim() || undefined, proxy_url: oauthProxyUrl.trim() || undefined, - }) - showToast(result.email ? t('accounts.oauthSuccess', { email: result.email }) : t('accounts.oauthSuccessNoEmail')) - setShowAdd(false) - setAddMethod('rt') - setOauthStep('generate') - setOauthSession(null) - setOauthCallbackUrl('') - setOauthName('') - void reload() + }); + showToast( + result.email + ? t("accounts.oauthSuccess", { email: result.email }) + : t("accounts.oauthSuccessNoEmail"), + ); + setShowAdd(false); + setAddMethod("rt"); + setOauthStep("generate"); + setOauthSession(null); + setOauthCallbackUrl(""); + setOauthName(""); + void reload(); } catch (error) { - showToast(t('accounts.oauthFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.oauthFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setOauthCompleting(false) + setOauthCompleting(false); } - } + }; const readImportSSE = async (res: Response) => { - setImportProgress({ show: true, current: 0, total: 0, success: 0, duplicate: 0, failed: 0, done: false }) - const reader = res.body?.getReader() - if (!reader) return - const decoder = new TextDecoder() - let buffer = '' + setImportProgress({ + show: true, + current: 0, + total: 0, + success: 0, + duplicate: 0, + failed: 0, + done: false, + }); + const reader = res.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buffer = ""; for (;;) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; for (const line of lines) { - if (!line.startsWith('data: ')) continue + if (!line.startsWith("data: ")) continue; try { - const event = JSON.parse(line.slice(6)) as { type: string; current: number; total: number; success: number; duplicate: number; failed: number } - setImportProgress(p => ({ ...p, current: event.current, total: event.total, success: event.success, duplicate: event.duplicate, failed: event.failed, done: event.type === 'complete' })) - if (event.type === 'complete') void reload() - } catch { /* 忽略解析异常 */ } + const event = JSON.parse(line.slice(6)) as { + type: string; + current: number; + total: number; + success: number; + duplicate: number; + failed: number; + }; + setImportProgress((p) => ({ + ...p, + current: event.current, + total: event.total, + success: event.success, + duplicate: event.duplicate, + failed: event.failed, + done: event.type === "complete", + })); + if (event.type === "complete") void reload(); + } catch { + /* 忽略解析异常 */ + } } } - } + }; - const importFiles = async (files: File[], format: 'txt' | 'json' | 'at_txt') => { - setImporting(true) + const importFiles = async ( + files: File[], + format: "txt" | "json" | "at_txt", + ) => { + setImporting(true); try { - const formData = new FormData() - if (format !== 'txt') formData.append('format', format) - for (const f of files) formData.append('file', f) - const res = await fetch('/api/admin/accounts/import', { method: 'POST', body: formData, headers: getAdminKey() ? { 'X-Admin-Key': getAdminKey() } : {} }) - if (res.headers.get('content-type')?.includes('text/event-stream')) { - await readImportSSE(res) + const formData = new FormData(); + if (format !== "txt") formData.append("format", format); + for (const f of files) formData.append("file", f); + const res = await fetch("/api/admin/accounts/import", { + method: "POST", + body: formData, + headers: getAdminKey() ? { "X-Admin-Key": getAdminKey() } : {}, + }); + if (res.headers.get("content-type")?.includes("text/event-stream")) { + await readImportSSE(res); } else { - const data = await res.json() + const data = await res.json(); if (!res.ok) { - showToast(data.error ? t('accounts.importFailedWithReason', { error: data.error }) : t('accounts.importFailed'), 'error') + showToast( + data.error + ? t("accounts.importFailedWithReason", { error: data.error }) + : t("accounts.importFailed"), + "error", + ); } else { - showToast(t('accounts.importCompleted')) - void reload() + showToast(t("accounts.importCompleted")); + void reload(); } } } catch (error) { - showToast(t('accounts.importFailedWithReason', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.importFailedWithReason", { error: getErrorMessage(error) }), + "error", + ); } finally { - setImporting(false) + setImporting(false); } - } + }; const handleDragEnter = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current++ - if (dragCounter.current === 1) setDragging(true) - } + e.preventDefault(); + e.stopPropagation(); + dragCounter.current++; + if (dragCounter.current === 1) setDragging(true); + }; const handleDragOver = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - } + e.preventDefault(); + e.stopPropagation(); + }; const handleDragLeave = (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current-- - if (dragCounter.current === 0) setDragging(false) - } - - const readAllEntriesFromDirectory = (dirEntry: FileSystemDirectoryEntry): Promise => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current--; + if (dragCounter.current === 0) setDragging(false); + }; + + const readAllEntriesFromDirectory = ( + dirEntry: FileSystemDirectoryEntry, + ): Promise => { return new Promise((resolve) => { - const files: File[] = [] + const files: File[] = []; const readEntries = (reader: FileSystemDirectoryReader) => { reader.readEntries(async (entries) => { - if (entries.length === 0) { resolve(files); return } + if (entries.length === 0) { + resolve(files); + return; + } for (const entry of entries) { if (entry.isFile) { - const file = await new Promise((res) => (entry as FileSystemFileEntry).file(res)) - files.push(file) + const file = await new Promise((res) => + (entry as FileSystemFileEntry).file(res), + ); + files.push(file); } else if (entry.isDirectory) { - const subFiles = await readAllEntriesFromDirectory(entry as FileSystemDirectoryEntry) - files.push(...subFiles) + const subFiles = await readAllEntriesFromDirectory( + entry as FileSystemDirectoryEntry, + ); + files.push(...subFiles); } } - readEntries(reader) - }) - } - readEntries(dirEntry.createReader()) - }) - } + readEntries(reader); + }); + }; + readEntries(dirEntry.createReader()); + }); + }; const handleDrop = async (e: DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounter.current = 0 - setDragging(false) - if (importing) return + e.preventDefault(); + e.stopPropagation(); + dragCounter.current = 0; + setDragging(false); + if (importing) return; // 检测是否拖入了文件夹 - const items = e.dataTransfer.items - const hasDirectories = items && Array.from(items).some( - item => item.webkitGetAsEntry?.()?.isDirectory - ) + const items = e.dataTransfer.items; + const hasDirectories = + items && + Array.from(items).some((item) => item.webkitGetAsEntry?.()?.isDirectory); if (hasDirectories) { - const allFiles: File[] = [] + const allFiles: File[] = []; for (const item of Array.from(items)) { - const entry = item.webkitGetAsEntry?.() - if (!entry) continue + const entry = item.webkitGetAsEntry?.(); + if (!entry) continue; if (entry.isDirectory) { - const dirFiles = await readAllEntriesFromDirectory(entry as FileSystemDirectoryEntry) - allFiles.push(...dirFiles) + const dirFiles = await readAllEntriesFromDirectory( + entry as FileSystemDirectoryEntry, + ); + allFiles.push(...dirFiles); } else if (entry.isFile) { - const file = await new Promise((res) => (entry as FileSystemFileEntry).file(res)) - allFiles.push(file) + const file = await new Promise((res) => + (entry as FileSystemFileEntry).file(res), + ); + allFiles.push(file); } } - const validFiles = allFiles.filter(f => { - const ext = f.name.split('.').pop()?.toLowerCase() - return (ext === 'txt' || ext === 'json') && f.size > 0 - }) + const validFiles = allFiles.filter((f) => { + const ext = f.name.split(".").pop()?.toLowerCase(); + return (ext === "txt" || ext === "json") && f.size > 0; + }); if (validFiles.length === 0) { - showToast(t('accounts.folderNoValidFiles'), 'error') - return + showToast(t("accounts.folderNoValidFiles"), "error"); + return; } - const txtFiles = validFiles.filter(f => f.name.split('.').pop()?.toLowerCase() === 'txt') - const jsonFiles = validFiles.filter(f => f.name.split('.').pop()?.toLowerCase() === 'json') + const txtFiles = validFiles.filter( + (f) => f.name.split(".").pop()?.toLowerCase() === "txt", + ); + const jsonFiles = validFiles.filter( + (f) => f.name.split(".").pop()?.toLowerCase() === "json", + ); if (jsonFiles.length > 0) { - await importFiles(jsonFiles, 'json') + await importFiles(jsonFiles, "json"); } if (txtFiles.length > 0) { - await importFiles(txtFiles, 'txt') + await importFiles(txtFiles, "txt"); } - return + return; } // 原有的文件拖放逻辑 - const files = Array.from(e.dataTransfer.files).filter(f => f.size > 0) - if (files.length === 0) return + const files = Array.from(e.dataTransfer.files).filter((f) => f.size > 0); + if (files.length === 0) return; - const txtFiles: File[] = [] - const jsonFiles: File[] = [] + const txtFiles: File[] = []; + const jsonFiles: File[] = []; for (const f of files) { - const ext = f.name.split('.').pop()?.toLowerCase() - if (ext === 'txt') txtFiles.push(f) - else if (ext === 'json') jsonFiles.push(f) + const ext = f.name.split(".").pop()?.toLowerCase(); + if (ext === "txt") txtFiles.push(f); + else if (ext === "json") jsonFiles.push(f); else { - showToast(t('accounts.unsupportedFileType', { name: f.name }), 'error') - return + showToast(t("accounts.unsupportedFileType", { name: f.name }), "error"); + return; } } if (jsonFiles.length > 0) { - await importFiles(jsonFiles, 'json') + await importFiles(jsonFiles, "json"); } if (txtFiles.length > 0) { - await importFiles(txtFiles, 'txt') + await importFiles(txtFiles, "txt"); } - } + }; const handleFileImport = async (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []) - if (files.length === 0) return - if (files.some((file) => !file.name.toLowerCase().endsWith('.txt'))) { - showToast(t('accounts.selectTxtFile'), 'error') - return + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + if (files.some((file) => !file.name.toLowerCase().endsWith(".txt"))) { + showToast(t("accounts.selectTxtFile"), "error"); + return; } - setShowImportPicker(false) - await importFiles(files, 'txt') - if (fileInputRef.current) fileInputRef.current.value = '' - } + setShowImportPicker(false); + await importFiles(files, "txt"); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; const handleJsonImport = async (event: ChangeEvent) => { - const files = event.target.files - if (!files || files.length === 0) return - setShowImportPicker(false) - await importFiles(Array.from(files), 'json') - if (jsonInputRef.current) jsonInputRef.current.value = '' - } + const files = event.target.files; + if (!files || files.length === 0) return; + setShowImportPicker(false); + await importFiles(Array.from(files), "json"); + if (jsonInputRef.current) jsonInputRef.current.value = ""; + }; const handleAtFileImport = async (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []) - if (files.length === 0) return - if (files.some((file) => !file.name.toLowerCase().endsWith('.txt'))) { - showToast(t('accounts.selectTxtFile'), 'error') - return + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + if (files.some((file) => !file.name.toLowerCase().endsWith(".txt"))) { + showToast(t("accounts.selectTxtFile"), "error"); + return; } - setShowImportPicker(false) - await importFiles(files, 'at_txt') - if (atFileInputRef.current) atFileInputRef.current.value = '' - } + setShowImportPicker(false); + await importFiles(files, "at_txt"); + if (atFileInputRef.current) atFileInputRef.current.value = ""; + }; const handleFolderImport = async (event: ChangeEvent) => { - const files = event.target.files - if (!files || files.length === 0) return - setShowImportPicker(false) + const files = event.target.files; + if (!files || files.length === 0) return; + setShowImportPicker(false); - const validFiles = Array.from(files).filter(f => { - const ext = f.name.split('.').pop()?.toLowerCase() - return (ext === 'txt' || ext === 'json') && f.size > 0 - }) + const validFiles = Array.from(files).filter((f) => { + const ext = f.name.split(".").pop()?.toLowerCase(); + return (ext === "txt" || ext === "json") && f.size > 0; + }); if (validFiles.length === 0) { - showToast(t('accounts.folderNoValidFiles'), 'error') - if (folderInputRef.current) folderInputRef.current.value = '' - return + showToast(t("accounts.folderNoValidFiles"), "error"); + if (folderInputRef.current) folderInputRef.current.value = ""; + return; } - const txtFiles = validFiles.filter(f => f.name.split('.').pop()?.toLowerCase() === 'txt') - const jsonFiles = validFiles.filter(f => f.name.split('.').pop()?.toLowerCase() === 'json') + const txtFiles = validFiles.filter( + (f) => f.name.split(".").pop()?.toLowerCase() === "txt", + ); + const jsonFiles = validFiles.filter( + (f) => f.name.split(".").pop()?.toLowerCase() === "json", + ); if (jsonFiles.length > 0) { - await importFiles(jsonFiles, 'json') + await importFiles(jsonFiles, "json"); } if (txtFiles.length > 0) { - await importFiles(txtFiles, 'txt') + await importFiles(txtFiles, "txt"); } - if (folderInputRef.current) folderInputRef.current.value = '' - } + if (folderInputRef.current) folderInputRef.current.value = ""; + }; - const handleExport = async (format: 'json' | 'txt', scope: 'healthy' | 'selected') => { - setExporting(true) - setShowExportPicker(false) + const handleExport = async ( + format: "json" | "txt", + scope: "healthy" | "selected", + ) => { + setExporting(true); + setShowExportPicker(false); try { - const params: { filter: 'healthy' | 'all'; ids?: number[] } = { - filter: scope === 'healthy' ? 'healthy' : 'all', + const params: { filter: "healthy" | "all"; ids?: number[] } = { + filter: scope === "healthy" ? "healthy" : "all", + }; + if (scope === "selected") { + params.ids = Array.from(selected); + params.filter = "all"; } - if (scope === 'selected') { - params.ids = Array.from(selected) - params.filter = 'all' - } - const data = await api.exportAccounts(params) + const data = await api.exportAccounts(params); if (data.length === 0) { - showToast(t('accounts.exportNoAccounts'), 'error') - return + showToast(t("accounts.exportNoAccounts"), "error"); + return; } - const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) - if (format === 'json') { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) - downloadBlob(blob, `cpa-${ts}-${data.length}.json`) + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + if (format === "json") { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + downloadBlob(blob, `cpa-${ts}-${data.length}.json`); } else { - const text = data.map(e => e.refresh_token).join('\n') - const blob = new Blob([text], { type: 'text/plain' }) - downloadBlob(blob, `rt-${ts}-${data.length}.txt`) + const text = data.map((e) => e.refresh_token).join("\n"); + const blob = new Blob([text], { type: "text/plain" }); + downloadBlob(blob, `rt-${ts}-${data.length}.txt`); } - showToast(t('accounts.exportSuccess', { count: data.length })) + showToast(t("accounts.exportSuccess", { count: data.length })); } catch (error) { - showToast(`${t('accounts.exportFailed')}: ${getErrorMessage(error)}`, 'error') + showToast( + `${t("accounts.exportFailed")}: ${getErrorMessage(error)}`, + "error", + ); } finally { - setExporting(false) + setExporting(false); } - } + }; const handleGenerateAuthJSON = async (account: AccountRow) => { - setAuthJsonExportingIds((prev) => new Set(prev).add(account.id)) + setAuthJsonExportingIds((prev) => new Set(prev).add(account.id)); try { - const blob = await api.downloadAccountAuthJSON(account.id) - const json = formatJSONText(await blob.text()) - setAuthJsonModal({ account, json }) - showToast(t('accounts.authJsonGenerated')) + const blob = await api.downloadAccountAuthJSON(account.id); + const json = formatJSONText(await blob.text()); + setAuthJsonModal({ account, json }); + showToast(t("accounts.authJsonGenerated")); } catch (error) { - showToast(t('accounts.authJsonFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.authJsonFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { setAuthJsonExportingIds((prev) => { - const next = new Set(prev) - next.delete(account.id) - return next - }) + const next = new Set(prev); + next.delete(account.id); + return next; + }); } - } + }; const handleCopyAuthJSON = async () => { - if (!authJsonModal) return + if (!authJsonModal) return; try { - await copyTextToClipboard(authJsonModal.json) - showToast(t('accounts.authJsonCopied')) + await copyTextToClipboard(authJsonModal.json); + showToast(t("accounts.authJsonCopied")); } catch (error) { - showToast(t('accounts.authJsonCopyFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.authJsonCopyFailed", { error: getErrorMessage(error) }), + "error", + ); } - } + }; const handleExportAuthJSON = () => { - if (!authJsonModal) return - const blob = new Blob([`${authJsonModal.json}\n`], { type: 'application/json' }) - downloadBlob(blob, 'auth.json') - showToast(t('accounts.authJsonExported')) - } + if (!authJsonModal) return; + const blob = new Blob([`${authJsonModal.json}\n`], { + type: "application/json", + }); + downloadBlob(blob, "auth.json"); + showToast(t("accounts.authJsonExported")); + }; const handleMigrate = async () => { - setMigrating(true) - setShowMigrate(false) + setMigrating(true); + setShowMigrate(false); try { - const res = await fetch('/api/admin/accounts/migrate', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...(getAdminKey() ? { 'X-Admin-Key': getAdminKey() } : {}) }, - body: JSON.stringify({ url: migrateUrl.trim(), admin_key: migrateKey.trim() }), - }) - if (res.headers.get('content-type')?.includes('text/event-stream')) { - await readImportSSE(res) + const res = await fetch("/api/admin/accounts/migrate", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(getAdminKey() ? { "X-Admin-Key": getAdminKey() } : {}), + }, + body: JSON.stringify({ + url: migrateUrl.trim(), + admin_key: migrateKey.trim(), + }), + }); + if (res.headers.get("content-type")?.includes("text/event-stream")) { + await readImportSSE(res); } else { - const data = await res.json() + const data = await res.json(); if (!res.ok) { - showToast(data.error ? `${t('accounts.migrateFailed')}: ${data.error}` : t('accounts.migrateFailed'), 'error') + showToast( + data.error + ? `${t("accounts.migrateFailed")}: ${data.error}` + : t("accounts.migrateFailed"), + "error", + ); } else { - showToast(t('accounts.migrateSuccess', { imported: data.imported ?? 0, duplicate: data.duplicate ?? 0, failed: data.failed ?? 0 })) - void reload() + showToast( + t("accounts.migrateSuccess", { + imported: data.imported ?? 0, + duplicate: data.duplicate ?? 0, + failed: data.failed ?? 0, + }), + ); + void reload(); } } } catch (error) { - showToast(`${t('accounts.migrateFailed')}: ${getErrorMessage(error)}`, 'error') + showToast( + `${t("accounts.migrateFailed")}: ${getErrorMessage(error)}`, + "error", + ); } finally { - setMigrating(false) - setMigrateUrl('') - setMigrateKey('') + setMigrating(false); + setMigrateUrl(""); + setMigrateKey(""); } - } + }; const handleDelete = async (account: AccountRow) => { const confirmed = await confirm({ - title: t('accounts.deleteTitle'), - description: t('accounts.deleteDesc', { account: account.email || `ID ${account.id}` }), - confirmText: t('accounts.deleteConfirm'), - tone: 'destructive', - confirmVariant: 'destructive', - }) - if (!confirmed) return + title: t("accounts.deleteTitle"), + description: t("accounts.deleteDesc", { + account: account.email || `ID ${account.id}`, + }), + confirmText: t("accounts.deleteConfirm"), + tone: "destructive", + confirmVariant: "destructive", + }); + if (!confirmed) return; try { - await api.deleteAccount(account.id) - showToast(t('accounts.deleted')) - void reload() + await api.deleteAccount(account.id); + showToast(t("accounts.deleted")); + void reload(); } catch (error) { - showToast(t('accounts.deleteFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.deleteFailed", { error: getErrorMessage(error) }), + "error", + ); } - } + }; const handleRefresh = async (account: AccountRow) => { - setRefreshingIds((prev) => new Set(prev).add(account.id)) + setRefreshingIds((prev) => new Set(prev).add(account.id)); try { - const result = await api.refreshAccount(account.id) - showToast(result.message || t('accounts.refreshRequested')) - void reloadSilently() + const result = await api.refreshAccount(account.id); + showToast(result.message || t("accounts.refreshRequested")); + void reloadSilently(); } catch (error) { - showToast(t('accounts.refreshFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.refreshFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { setRefreshingIds((prev) => { - const next = new Set(prev) - next.delete(account.id) - return next - }) + const next = new Set(prev); + next.delete(account.id); + return next; + }); } - } + }; const handleToggleLock = async (account: AccountRow) => { - const newLocked = !account.locked + const newLocked = !account.locked; try { - await api.toggleAccountLock(account.id, newLocked) - showToast(newLocked ? t('accounts.lockSuccess') : t('accounts.unlockSuccess')) - void reload() + await api.toggleAccountLock(account.id, newLocked); + showToast( + newLocked ? t("accounts.lockSuccess") : t("accounts.unlockSuccess"), + ); + void reload(); } catch (error) { - showToast(t('accounts.lockFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.lockFailed", { error: getErrorMessage(error) }), + "error", + ); } - } + }; const handleLockSubscriptionAccounts = async () => { - const candidates = subscriptionAccountsToLock + const candidates = subscriptionAccountsToLock; if (candidates.length === 0) { - showToast(t('accounts.noSubscriptionAccountsToLock')) - return + showToast(t("accounts.noSubscriptionAccountsToLock")); + return; } - setBatchLoading(true) - setLockingSubscriptionAccounts(true) + setBatchLoading(true); + setLockingSubscriptionAccounts(true); try { const { success, fail } = await runAccountBatch( candidates.map((account) => account.id), (id) => api.toggleAccountLock(id, true), - ) - showToast(t('accounts.lockSubscriptionAccountsDone', { success, fail })) - void reload() + ); + showToast(t("accounts.lockSubscriptionAccountsDone", { success, fail })); + void reload(); } finally { - setBatchLoading(false) - setLockingSubscriptionAccounts(false) + setBatchLoading(false); + setLockingSubscriptionAccounts(false); } - } + }; const handleToggleEnabled = async (account: AccountRow) => { - const nextEnabled = account.enabled === false + const nextEnabled = account.enabled === false; try { - await api.toggleAccountEnabled(account.id, nextEnabled) - showToast(nextEnabled ? t('accounts.enableSuccess') : t('accounts.disableSuccess')) - void reload() + await api.toggleAccountEnabled(account.id, nextEnabled); + showToast( + nextEnabled + ? t("accounts.enableSuccess") + : t("accounts.disableSuccess"), + ); + void reload(); } catch (error) { - showToast(t('accounts.enableFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.enableFailed", { error: getErrorMessage(error) }), + "error", + ); } - } + }; const handleBatchDelete = async () => { - const ids = Array.from(selected) - if (ids.length === 0) return + const ids = Array.from(selected); + if (ids.length === 0) return; const confirmed = await confirm({ - title: t('accounts.batchDeleteTitle'), - description: t('accounts.batchDeleteDesc', { count: ids.length }), - confirmText: t('accounts.deleteConfirm'), - tone: 'destructive', - confirmVariant: 'destructive', - }) - if (!confirmed) return - setBatchLoading(true) + title: t("accounts.batchDeleteTitle"), + description: t("accounts.batchDeleteDesc", { count: ids.length }), + confirmText: t("accounts.deleteConfirm"), + tone: "destructive", + confirmVariant: "destructive", + }); + if (!confirmed) return; + setBatchLoading(true); try { - const { success, fail } = await runAccountBatch(ids, api.deleteAccount) - showToast(t('accounts.batchDeleteDone', { success, fail })) - setSelected(new Set()) - void reload() + const { success, fail } = await runAccountBatch(ids, api.deleteAccount); + showToast(t("accounts.batchDeleteDone", { success, fail })); + setSelected(new Set()); + void reload(); } finally { - setBatchLoading(false) + setBatchLoading(false); } - } + }; const handleBatchRefresh = async (ids?: number[]) => { - const targetIds = ids ?? Array.from(selected) - if (targetIds.length === 0) return - setBatchLoading(true) - setBatchRefreshing(true) + const targetIds = ids ?? Array.from(selected); + if (targetIds.length === 0) return; + setBatchLoading(true); + setBatchRefreshing(true); try { - const { success, fail } = await runAccountBatch(targetIds, api.refreshAccount, ACCOUNT_REFRESH_BATCH_CONCURRENCY) - showToast(t('accounts.batchRefreshDone', { success, fail })) - void reload() + const { success, fail } = await runAccountBatch( + targetIds, + api.refreshAccount, + ACCOUNT_REFRESH_BATCH_CONCURRENCY, + ); + showToast(t("accounts.batchRefreshDone", { success, fail })); + void reload(); } finally { - setBatchLoading(false) - setBatchRefreshing(false) + setBatchLoading(false); + setBatchRefreshing(false); } - } + }; const handleBatchLock = async (locked: boolean) => { - const ids = Array.from(selected) - if (ids.length === 0) return - setBatchLoading(true) + const ids = Array.from(selected); + if (ids.length === 0) return; + setBatchLoading(true); try { - const { success, fail } = await runAccountBatch(ids, (id) => api.toggleAccountLock(id, locked)) - showToast(t(locked ? 'accounts.batchLockDone' : 'accounts.batchUnlockDone', { success, fail })) - setSelected(new Set()) - void reload() + const { success, fail } = await runAccountBatch(ids, (id) => + api.toggleAccountLock(id, locked), + ); + showToast( + t(locked ? "accounts.batchLockDone" : "accounts.batchUnlockDone", { + success, + fail, + }), + ); + setSelected(new Set()); + void reload(); } finally { - setBatchLoading(false) + setBatchLoading(false); } - } + }; const handleBatchEnabled = async (enabled: boolean) => { - const ids = Array.from(selected) - if (ids.length === 0) return - setBatchLoading(true) + const ids = Array.from(selected); + if (ids.length === 0) return; + setBatchLoading(true); try { - const { success, fail } = await runAccountBatch(ids, (id) => api.toggleAccountEnabled(id, enabled)) - showToast(t(enabled ? 'accounts.batchEnableDone' : 'accounts.batchDisableDone', { success, fail })) - setSelected(new Set()) - void reload() + const { success, fail } = await runAccountBatch(ids, (id) => + api.toggleAccountEnabled(id, enabled), + ); + showToast( + t(enabled ? "accounts.batchEnableDone" : "accounts.batchDisableDone", { + success, + fail, + }), + ); + setSelected(new Set()); + void reload(); } finally { - setBatchLoading(false) + setBatchLoading(false); } - } + }; const handleResetStatus = async (account: AccountRow) => { try { - await api.resetAccountStatus(account.id) - showToast(t('accounts.resetStatusSuccess')) - void reload() + await api.resetAccountStatus(account.id); + showToast(t("accounts.resetStatusSuccess")); + void reload(); } catch (error) { - showToast(t('accounts.resetStatusFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.resetStatusFailed", { error: getErrorMessage(error) }), + "error", + ); } - } + }; const handleBatchResetStatus = async () => { - const ids = Array.from(selected) - if (ids.length === 0) return - setBatchLoading(true) + const ids = Array.from(selected); + if (ids.length === 0) return; + setBatchLoading(true); try { - const result = await api.batchResetStatus(ids) - showToast(t('accounts.batchResetStatusDone', { success: result.success, fail: result.failed })) - setSelected(new Set()) - void reload() + const result = await api.batchResetStatus(ids); + showToast( + t("accounts.batchResetStatusDone", { + success: result.success, + fail: result.failed, + }), + ); + setSelected(new Set()); + void reload(); } catch (error) { - showToast(t('accounts.resetStatusFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.resetStatusFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setBatchLoading(false) + setBatchLoading(false); } - } + }; + + const openBatchMetaEditor = () => { + const selectedAccounts = accounts.filter((account) => + selected.has(account.id), + ); + const tagSet = new Set(); + const groupSet = new Set(); + for (const account of selectedAccounts) { + for (const tag of account.tags ?? []) tagSet.add(tag); + for (const id of account.group_ids ?? []) groupSet.add(id); + } + setBatchTags(Array.from(tagSet).sort()); + setBatchGroupIds(Array.from(groupSet).sort((a, b) => a - b)); + setShowBatchMetaEditor(true); + }; + + const handleBatchSaveMeta = async () => { + const ids = Array.from(selected); + if (ids.length === 0) return; + setBatchMetaSubmitting(true); + try { + const { success, fail } = await runAccountBatch(ids, (id) => + api.updateAccountScheduler(id, { + tags: batchTags, + group_ids: batchGroupIds, + }), + ); + showToast(t("accounts.batchMetaDone", { success, fail })); + setShowBatchMetaEditor(false); + await Promise.all([reload(), reloadGroups()]); + } catch (error) { + showToast( + t("accounts.batchMetaFailed", { error: getErrorMessage(error) }), + "error", + ); + } finally { + setBatchMetaSubmitting(false); + } + }; const handleBatchTest = async (ids?: number[]) => { - if (ids && ids.length === 0) return - setBatchTesting(true) + if (ids && ids.length === 0) return; + setBatchTesting(true); try { - const result = await api.batchTestAccounts(ids) - showToast(t('accounts.batchTestDone', { - success: result.success, - banned: result.banned, - rateLimited: result.rate_limited, - failed: result.failed, - })) - void reload() + const result = await api.batchTestAccounts(ids); + showToast( + t("accounts.batchTestDone", { + success: result.success, + banned: result.banned, + rateLimited: result.rate_limited, + failed: result.failed, + }), + ); + void reload(); } catch (error) { - showToast(t('accounts.batchTestFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.batchTestFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setBatchTesting(false) + setBatchTesting(false); } - } + }; const handleCleanBanned = async () => { const confirmed = await confirm({ - title: t('accounts.cleanBannedTitle'), - description: t('accounts.cleanBannedDesc'), - confirmText: t('accounts.cleanConfirm'), - tone: 'warning', - }) - if (!confirmed) return - setCleaningBanned(true) + title: t("accounts.cleanBannedTitle"), + description: t("accounts.cleanBannedDesc"), + confirmText: t("accounts.cleanConfirm"), + tone: "warning", + }); + if (!confirmed) return; + setCleaningBanned(true); try { - await api.cleanBanned() - showToast(t('accounts.cleanBannedSuccess')) - void reload() + await api.cleanBanned(); + showToast(t("accounts.cleanBannedSuccess")); + void reload(); } catch (error) { - showToast(t('accounts.cleanBannedFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.cleanBannedFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setCleaningBanned(false) + setCleaningBanned(false); } - } + }; const handleCleanRateLimited = async () => { const confirmed = await confirm({ - title: t('accounts.cleanRateLimitedTitle'), - description: t('accounts.cleanRateLimitedDesc'), - confirmText: t('accounts.cleanConfirm'), - tone: 'warning', - }) - if (!confirmed) return - setCleaningRateLimited(true) + title: t("accounts.cleanRateLimitedTitle"), + description: t("accounts.cleanRateLimitedDesc"), + confirmText: t("accounts.cleanConfirm"), + tone: "warning", + }); + if (!confirmed) return; + setCleaningRateLimited(true); try { - await api.cleanRateLimited() - showToast(t('accounts.cleanRateLimitedSuccess')) - void reload() + await api.cleanRateLimited(); + showToast(t("accounts.cleanRateLimitedSuccess")); + void reload(); } catch (error) { - showToast(t('accounts.cleanRateLimitedFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.cleanRateLimitedFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setCleaningRateLimited(false) + setCleaningRateLimited(false); } - } + }; const handleCleanError = async () => { const confirmed = await confirm({ - title: t('accounts.cleanErrorTitle'), - description: t('accounts.cleanErrorDesc'), - confirmText: t('accounts.cleanConfirm'), - tone: 'warning', - }) - if (!confirmed) return - setCleaningError(true) + title: t("accounts.cleanErrorTitle"), + description: t("accounts.cleanErrorDesc"), + confirmText: t("accounts.cleanConfirm"), + tone: "warning", + }); + if (!confirmed) return; + setCleaningError(true); try { - await api.cleanError() - showToast(t('accounts.cleanErrorSuccess')) - void reload() + await api.cleanError(); + showToast(t("accounts.cleanErrorSuccess")); + void reload(); } catch (error) { - showToast(t('accounts.cleanErrorFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.cleanErrorFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setCleaningError(false) + setCleaningError(false); } - } + }; const openSchedulerEditor = (account: AccountRow) => { - setEditingAccount(account) - setEditTab('scheduler') - setScoreMode(account.score_bias_override === null || account.score_bias_override === undefined ? 'default' : 'custom') - setScoreInput(account.score_bias_override === null || account.score_bias_override === undefined ? '' : String(account.score_bias_override)) - setConcurrencyMode(account.base_concurrency_override === null || account.base_concurrency_override === undefined ? 'default' : 'custom') - setConcurrencyInput(account.base_concurrency_override === null || account.base_concurrency_override === undefined ? '' : String(account.base_concurrency_override)) - setAllowedAPIKeySelection(filterExistingAPIKeyIDs(account.allowed_api_key_ids ?? [], apiKeys)) + setEditingAccount(account); + setEditTab("scheduler"); + setScoreMode( + account.score_bias_override === null || + account.score_bias_override === undefined + ? "default" + : "custom", + ); + setScoreInput( + account.score_bias_override === null || + account.score_bias_override === undefined + ? "" + : String(account.score_bias_override), + ); + setConcurrencyMode( + account.base_concurrency_override === null || + account.base_concurrency_override === undefined + ? "default" + : "custom", + ); + setConcurrencyInput( + account.base_concurrency_override === null || + account.base_concurrency_override === undefined + ? "" + : String(account.base_concurrency_override), + ); + setAllowedAPIKeySelection( + filterExistingAPIKeyIDs(account.allowed_api_key_ids ?? [], apiKeys), + ); + setEditProxyUrl(account.proxy_url ?? ""); + setEditTags(account.tags ?? []); + setEditGroupIds(account.group_ids ?? []); setEditOpenAIForm({ - name: account.name ?? '', - base_url: account.base_url || 'https://api.openai.com', - api_key: '', + name: account.name ?? "", + base_url: account.base_url || "https://api.openai.com", + api_key: "", models: account.models ?? [], - proxy_url: account.proxy_url ?? '', - }) - setEditOpenAIModelDraft('') - } + proxy_url: account.proxy_url ?? "", + }); + setEditOpenAIModelDraft(""); + }; const closeSchedulerEditor = (force = false) => { - if (editSubmitting && !force) return - setEditingAccount(null) - setEditTab('scheduler') - setScoreMode('default') - setScoreInput('') - setConcurrencyMode('default') - setConcurrencyInput('') - setAllowedAPIKeySelection([]) - setEditOpenAIForm({ name: '', base_url: 'https://api.openai.com', api_key: '', models: [], proxy_url: '' }) - setEditOpenAIModelDraft('') - } - - const parsedScoreBias = scoreMode === 'custom' ? parseIntegerInput(scoreInput) : null - const parsedBaseConcurrency = concurrencyMode === 'custom' ? parseIntegerInput(concurrencyInput) : null - const scoreInputInvalid = scoreMode === 'custom' && (parsedScoreBias === null || parsedScoreBias < -200 || parsedScoreBias > 200) - const concurrencyInputInvalid = concurrencyMode === 'custom' && (parsedBaseConcurrency === null || parsedBaseConcurrency < 1 || parsedBaseConcurrency > 50) + if (editSubmitting && !force) return; + setEditingAccount(null); + setEditTab("scheduler"); + setScoreMode("default"); + setScoreInput(""); + setConcurrencyMode("default"); + setConcurrencyInput(""); + setAllowedAPIKeySelection([]); + setEditProxyUrl(""); + setEditTags([]); + setEditGroupIds([]); + setEditOpenAIForm({ + name: "", + base_url: "https://api.openai.com", + api_key: "", + models: [], + proxy_url: "", + }); + setEditOpenAIModelDraft(""); + }; + + const parsedScoreBias = + scoreMode === "custom" ? parseIntegerInput(scoreInput) : null; + const parsedBaseConcurrency = + concurrencyMode === "custom" ? parseIntegerInput(concurrencyInput) : null; + const scoreInputInvalid = + scoreMode === "custom" && + (parsedScoreBias === null || + parsedScoreBias < -200 || + parsedScoreBias > 200); + const concurrencyInputInvalid = + concurrencyMode === "custom" && + (parsedBaseConcurrency === null || + parsedBaseConcurrency < 1 || + parsedBaseConcurrency > 50); const openAIAccountInputInvalid = Boolean( editingAccount?.openai_responses_api && - editTab === 'account' && - (!editOpenAIForm.base_url.trim() || editOpenAIForm.models.length === 0) - ) + editTab === "account" && + (!editOpenAIForm.base_url.trim() || editOpenAIForm.models.length === 0), + ); const editPreview = useMemo(() => { - if (!editingAccount) return null - - const rawScore = Math.round(editingAccount.scheduler_score ?? 0) - const appliedBias = scoreMode === 'custom' - ? (parsedScoreBias ?? getEffectiveScoreBias(editingAccount)) - : getDefaultScoreBias(editingAccount.plan_type) - const baseConcurrency = concurrencyMode === 'custom' - ? (parsedBaseConcurrency ?? getEffectiveBaseConcurrency(editingAccount)) - : getEffectiveBaseConcurrency(editingAccount) + if (!editingAccount) return null; + + const rawScore = Math.round(editingAccount.scheduler_score ?? 0); + const appliedBias = + scoreMode === "custom" + ? (parsedScoreBias ?? getEffectiveScoreBias(editingAccount)) + : getDefaultScoreBias(editingAccount.plan_type); + const baseConcurrency = + concurrencyMode === "custom" + ? (parsedBaseConcurrency ?? getEffectiveBaseConcurrency(editingAccount)) + : getEffectiveBaseConcurrency(editingAccount); return { rawScore, - dispatchScore: computePreviewDispatchScore(editingAccount, rawScore, appliedBias), + dispatchScore: computePreviewDispatchScore( + editingAccount, + rawScore, + appliedBias, + ), healthTier: editingAccount.health_tier, - dynamicConcurrency: computePreviewDynamicConcurrency(editingAccount, baseConcurrency), + dynamicConcurrency: computePreviewDynamicConcurrency( + editingAccount, + baseConcurrency, + ), appliedBias, baseConcurrency, - } - }, [editingAccount, scoreMode, parsedScoreBias, concurrencyMode, parsedBaseConcurrency]) + }; + }, [ + editingAccount, + scoreMode, + parsedScoreBias, + concurrencyMode, + parsedBaseConcurrency, + ]); const handleSaveScheduler = async () => { - if (!editingAccount) return + if (!editingAccount) return; if (scoreInputInvalid || concurrencyInputInvalid) { - showToast(t('accounts.schedulerInvalidInput'), 'error') - return + showToast(t("accounts.schedulerInvalidInput"), "error"); + return; } - setEditSubmitting(true) + setEditSubmitting(true); try { const payload = { - score_bias_override: scoreMode === 'custom' ? parsedScoreBias : null, - base_concurrency_override: concurrencyMode === 'custom' ? parsedBaseConcurrency : null, + score_bias_override: scoreMode === "custom" ? parsedScoreBias : null, + base_concurrency_override: + concurrencyMode === "custom" ? parsedBaseConcurrency : null, allowed_api_key_ids: allowedAPIKeySelection, - } - await api.updateAccountScheduler(editingAccount.id, payload) - showToast(t('accounts.schedulerSaveSuccess')) - await reload() - closeSchedulerEditor(true) + proxy_url: editProxyUrl.trim() || null, + tags: editTags, + group_ids: editGroupIds, + }; + await api.updateAccountScheduler(editingAccount.id, payload); + showToast(t("accounts.schedulerSaveSuccess")); + await Promise.all([reload(), reloadGroups()]); + closeSchedulerEditor(true); } catch (error) { - showToast(t('accounts.schedulerSaveFailed', { error: getErrorMessage(error) }), 'error') + showToast( + t("accounts.schedulerSaveFailed", { error: getErrorMessage(error) }), + "error", + ); } finally { - setEditSubmitting(false) + setEditSubmitting(false); } - } + }; const handleSaveAccountEditor = async () => { - if (editingAccount?.openai_responses_api && editTab === 'account') { - await handleSaveOpenAIAccountSettings() - return + if (editingAccount?.openai_responses_api && editTab === "account") { + await handleSaveOpenAIAccountSettings(); + return; } - await handleSaveScheduler() - } + await handleSaveScheduler(); + }; + + const reloadGroups = async () => { + const res = await api.listAccountGroups(); + setAllGroups(res.groups ?? []); + }; + + const resetGroupDraft = () => { + setGroupDraft({ + id: null, + name: "", + description: "", + color: ACCOUNT_GROUP_COLORS[0], + }); + }; + + const startEditGroup = (group: AccountGroup) => { + setGroupDraft({ + id: group.id, + name: group.name, + description: group.description ?? "", + color: group.color || ACCOUNT_GROUP_COLORS[0], + }); + }; + + const handleSaveGroup = async () => { + const name = groupDraft.name.trim(); + if (!name) { + showToast(t("accounts.groupNameRequired"), "error"); + return; + } + setGroupSubmitting(true); + try { + const payload = { + name, + description: groupDraft.description.trim(), + color: groupDraft.color.trim() || ACCOUNT_GROUP_COLORS[0], + }; + if (groupDraft.id === null) { + await api.createAccountGroup(payload); + showToast(t("accounts.groupCreated")); + } else { + await api.updateAccountGroup(groupDraft.id, payload); + showToast(t("accounts.groupUpdated")); + } + await reloadGroups(); + resetGroupDraft(); + } catch (error) { + showToast(getErrorMessage(error), "error"); + } finally { + setGroupSubmitting(false); + } + }; + + const handleDeleteGroup = async (group: AccountGroup) => { + const force = group.member_count > 0; + const confirmed = await confirm({ + title: t("accounts.groupDeleteTitle"), + description: force + ? t("accounts.groupDeleteWithMembers") + : t("accounts.groupDeleteEmpty"), + confirmText: force ? t("accounts.groupDeleteForce") : t("common.delete"), + tone: "destructive", + confirmVariant: "destructive", + }); + if (!confirmed) return; + setGroupSubmitting(true); + try { + await api.deleteAccountGroup(group.id, force); + showToast(t("accounts.groupDeleted")); + setEditGroupIds((current) => current.filter((id) => id !== group.id)); + setBatchGroupIds((current) => current.filter((id) => id !== group.id)); + if (groupFilter === group.id) setGroupFilter(null); + if (groupDraft.id === group.id) resetGroupDraft(); + await Promise.all([reload(), reloadGroups()]); + } catch (error) { + showToast(getErrorMessage(error), "error"); + } finally { + setGroupSubmitting(false); + } + }; return (
- {t('accounts.dropToImport')} - {t('accounts.dropHint')} + + {t("accounts.dropToImport")} + + + {t("accounts.dropHint")} +
)} - void reload()} - loadingTitle={t('accounts.loadingTitle')} - loadingDescription={t('accounts.loadingDesc')} - errorTitle={t('accounts.errorTitle')} - > - <> - void reload()} - actions={( -
- - } - items={[ - { - key: 'refresh-tokens', - label: t('accounts.refreshTokens'), - icon: , - disabled: batchLoading || batchTesting || accounts.length === 0, - onSelect: () => void handleBatchRefresh(accounts.map((account) => account.id)), - }, - { - key: 'test-connection', - label: batchTesting ? t('accounts.batchTesting') : t('accounts.testConnection'), - icon: , - disabled: batchLoading || batchTesting || accounts.length === 0, - onSelect: () => void handleBatchTest(), - }, - { - key: 'lock-subscription', - label: lockingSubscriptionAccounts ? t('accounts.lockingSubscriptionAccounts') : t('accounts.lockSubscriptionAccounts'), - icon: , - disabled: batchLoading || batchTesting || lockingSubscriptionAccounts || accounts.length === 0, - title: t('accounts.lockSubscriptionAccountsHint', { count: subscriptionAccountsToLock.length }), - onSelect: () => void handleLockSubscriptionAccounts(), - }, - ]} - /> - } - items={[ - { - key: 'clean-banned', - label: cleaningBanned ? t('accounts.cleaning') : t('accounts.cleanBanned'), - icon: , - disabled: cleaningBanned, - onSelect: () => void handleCleanBanned(), - }, - { - key: 'clean-rate-limited', - label: cleaningRateLimited ? t('accounts.cleaning') : t('accounts.cleanRateLimited'), - icon: , - disabled: cleaningRateLimited, - onSelect: () => void handleCleanRateLimited(), - }, - { - key: 'clean-error', - label: cleaningError ? t('accounts.cleaning') : t('accounts.cleanError'), - icon: , - disabled: cleaningError, - onSelect: () => void handleCleanError(), - }, - ]} + void reload()} + loadingTitle={t("accounts.loadingTitle")} + loadingDescription={t("accounts.loadingDesc")} + errorTitle={t("accounts.errorTitle")} + > + <> + void reload()} + actions={ +
+ + } + items={[ + { + key: "refresh-tokens", + label: t("accounts.refreshTokens"), + icon: ( + + ), + disabled: + batchLoading || batchTesting || accounts.length === 0, + onSelect: () => + void handleBatchRefresh( + accounts.map((account) => account.id), + ), + }, + { + key: "test-connection", + label: batchTesting + ? t("accounts.batchTesting") + : t("accounts.testConnection"), + icon: , + disabled: + batchLoading || batchTesting || accounts.length === 0, + onSelect: () => void handleBatchTest(), + }, + { + key: "lock-subscription", + label: lockingSubscriptionAccounts + ? t("accounts.lockingSubscriptionAccounts") + : t("accounts.lockSubscriptionAccounts"), + icon: , + disabled: + batchLoading || + batchTesting || + lockingSubscriptionAccounts || + accounts.length === 0, + title: t("accounts.lockSubscriptionAccountsHint", { + count: subscriptionAccountsToLock.length, + }), + onSelect: () => void handleLockSubscriptionAccounts(), + }, + ]} + /> + } + items={[ + { + key: "clean-banned", + label: cleaningBanned + ? t("accounts.cleaning") + : t("accounts.cleanBanned"), + icon: , + disabled: cleaningBanned, + onSelect: () => void handleCleanBanned(), + }, + { + key: "clean-rate-limited", + label: cleaningRateLimited + ? t("accounts.cleaning") + : t("accounts.cleanRateLimited"), + icon: , + disabled: cleaningRateLimited, + onSelect: () => void handleCleanRateLimited(), + }, + { + key: "clean-error", + label: cleaningError + ? t("accounts.cleaning") + : t("accounts.cleanError"), + icon: , + disabled: cleaningError, + onSelect: () => void handleCleanError(), + }, + ]} + /> + } + items={[ + { + key: "import", + label: importing + ? t("accounts.importing") + : t("accounts.importFile"), + icon: , + disabled: importing, + onSelect: () => setShowImportPicker(true), + }, + { + key: "export", + label: exporting + ? t("accounts.exporting") + : t("accounts.export"), + icon: , + disabled: exporting, + onSelect: () => setShowExportPicker(true), + }, + { + key: "migrate", + label: migrating + ? t("accounts.migrating") + : t("accounts.migrateImport"), + icon: , + disabled: migrating, + onSelect: () => setShowMigrate(true), + }, + ]} + /> + + void handleFileImport(e)} + /> + void handleJsonImport(e)} + /> + void handleAtFileImport(e)} + /> + void handleFolderImport(e)} + {...({ + webkitdirectory: "", + directory: "", + } as React.InputHTMLAttributes)} + /> +
+ } + /> + +
+ + + + + +
+ + {showAnalysisCharts ? ( +
+ - } - items={[ - { - key: 'import', - label: importing ? t('accounts.importing') : t('accounts.importFile'), - icon: , - disabled: importing, - onSelect: () => setShowImportPicker(true), - }, - { - key: 'export', - label: exporting ? t('accounts.exporting') : t('accounts.export'), - icon: , - disabled: exporting, - onSelect: () => setShowExportPicker(true), - }, - { - key: 'migrate', - label: migrating ? t('accounts.migrating') : t('accounts.migrateImport'), - icon: , - disabled: migrating, - onSelect: () => setShowMigrate(true), - }, - ]} + - - void handleFileImport(e)} +
+ ) : null} + +
+
+ + {t("accounts.filter")} + + {( + [ + ["all", t("accounts.filterAll")], + ["normal", t("accounts.filterNormal")], + ["rate_limited", t("accounts.filterRateLimited")], + ["banned", t("accounts.filterBanned")], + ["error", t("accounts.filterError")], + ["disabled", t("accounts.filterDisabled")], + ["locked", t("accounts.filterLocked")], + ] as const + ).map(([key, label]) => ( + + ))} +
+ +
+ + {t("accounts.schedulerView")} + + - void handleJsonImport(e)} + - void handleAtFileImport(e)} + - void handleFolderImport(e)} - {...({ webkitdirectory: '', directory: '' } as React.InputHTMLAttributes)} +
- )} - /> - -
- - - - - -
+
- {showAnalysisCharts ? ( -
- - +
+ + ) => { + setSearchQuery(e.target.value); + setPage(1); + }} + /> +
+
+ {(["all", "pro", "prolite", "plus", "team", "free"] as const).map( + (key) => ( + + ), + )} +
+ ) => { setSearchQuery(e.target.value); setPage(1) }} + - - {t('accounts.sequence')} - {t('accounts.email')} - {t('accounts.plan')} - {t('accounts.status')} - { if (sortKey === 'requests') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('requests'); setSortDir('desc') }; setPage(1) }} - > - {t('accounts.requests')} {sortKey === 'requests' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - { if (sortKey === 'usage') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('usage'); setSortDir('desc') }; setPage(1) }} - > - {t('accounts.usage')} {sortKey === 'usage' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - { if (sortKey === 'importTime') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('importTime'); setSortDir('desc') }; setPage(1) }} - > - {t('accounts.importTime')} {sortKey === 'importTime' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - {t('accounts.updatedAt')} - {t('accounts.actions')} - - - - {pagedAccounts.map((account, index) => { - const isSelected = selected.has(account.id) - return ( - - - toggleSelect(account.id)} - /> - - - {(currentPage - 1) * pageSize + index + 1} - - - {account.openai_responses_api ? formatAccountName(account) : formatCompactEmail(account.email)} - {account.at_only && ( - - AT - - )} - {account.openai_responses_api && ( - - Responses API - - )} - {account.enabled === false && ( - - {t('accounts.disabled')} - - )} - {account.locked && ( - - {t('accounts.lock')} - - )} - - - - - -
-
- - -
- {account.status === 'error' && account.error_message && ( -
- {account.error_message} -
- )} - {(account.model_cooldowns?.length ?? 0) > 0 && ( -
- model {account.model_cooldowns?.[0]?.model} - {(account.model_cooldowns?.length ?? 0) > 1 ? ` +${(account.model_cooldowns?.length ?? 1) - 1}` : ''} -
- )} -
- {t('accounts.healthSummary', { - health: formatHealthTier(account.health_tier, t), - score: Math.round(getDispatchScore(account)), - concurrency: account.dynamic_concurrency_limit ?? '-', - })} -
-
-
- -
-
- {account.success_requests ?? 0} - / - {account.error_requests ?? 0} -
- {((account.retry_error_requests ?? 0) > 0 || (account.rate_limit_attempts ?? 0) > 0) && ( -
- retry {account.retry_error_requests ?? 0} · 429 {account.rate_limit_attempts ?? 0} -
- )} -
-
- - - - {formatBeijingTime(account.created_at)} - {formatRelativeTime(account.updated_at)} - -
- - - - - - - - - -
-
-
- ) - })} -
- -
- { - setPageSize(nextPageSize) - setPage(1) + + {t("accounts.groupManage")} + +
+ + setVisibleColumns((current) => ({ + ...current, + [column]: !current[column], + })) + } + onReset={() => + setVisibleColumns(getDefaultAccountVisibleColumns()) + } + resetTitle={t("accounts.columnReset")} + labels={{ + sequence: t("accounts.sequence"), + email: t("accounts.email"), + plan: t("accounts.plan"), + tags: t("accounts.tagsLabel"), + groups: t("accounts.groupsLabel"), + status: t("accounts.status"), + requests: t("accounts.requests"), + usage: t("accounts.usage"), + importTime: t("accounts.importTime"), + updatedAt: t("accounts.updatedAt"), + actions: t("accounts.actions"), }} + title={t("accounts.columnSettings")} /> - - - - - { - setShowAdd(false) - setAddMethod('rt') - setOauthStep('generate') - setOauthSession(null) - setOauthCallbackUrl('') - setOauthName('') - setOpenAIForm({ base_url: 'https://api.openai.com', api_key: '', models: [], proxy_url: '' }) - setOpenAIModelDraft('') - }} - footer={( - <> - - {addMethod === 'rt' ? ( -
+
+ + {selected.size > 0 && ( +
+ {t("common.selected", { count: selected.size })} +
+ - ) : addMethod === 'at' ? ( - - ) : addMethod === 'openai' ? ( - ) : oauthStep === 'generate' ? ( - - ) : ( - )} - + + + + + +
+
)} - > - {/* Tab switcher */} -
- - - - -
- {addMethod === 'rt' ? ( -
-
- -