feat: Implement the ChatGPT Owner Demotion Console with new frontend and backend components.
This commit is contained in:
@@ -166,6 +166,9 @@ func startServer(cfg *config.Config) {
|
|||||||
mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies))
|
mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies))
|
||||||
mux.HandleFunc("/api/codex-proxy/test", api.CORS(api.HandleCodexProxies))
|
mux.HandleFunc("/api/codex-proxy/test", api.CORS(api.HandleCodexProxies))
|
||||||
|
|
||||||
|
// Owner 降级 API
|
||||||
|
mux.HandleFunc("/api/demote/owner", api.CORS(api.HandleDemoteOwner))
|
||||||
|
|
||||||
// 嵌入的前端静态文件
|
// 嵌入的前端静态文件
|
||||||
if web.IsEmbedded() {
|
if web.IsEmbedded() {
|
||||||
webFS := web.GetFileSystem()
|
webFS := web.GetFileSystem()
|
||||||
@@ -237,6 +240,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"team_reg_proxy_test_ip": getTeamRegProxyTestIP(),
|
"team_reg_proxy_test_ip": getTeamRegProxyTestIP(),
|
||||||
"site_name": config.Global.SiteName,
|
"site_name": config.Global.SiteName,
|
||||||
"auth_method": config.Global.AuthMethod,
|
"auth_method": config.Global.AuthMethod,
|
||||||
|
"demote_after_use": config.Global.DemoteAfterUse,
|
||||||
"mail_services_count": len(config.Global.MailServices),
|
"mail_services_count": len(config.Global.MailServices),
|
||||||
"mail_services": config.Global.MailServices,
|
"mail_services": config.Global.MailServices,
|
||||||
})
|
})
|
||||||
@@ -254,6 +258,7 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
TeamRegProxy *string `json:"team_reg_proxy"`
|
TeamRegProxy *string `json:"team_reg_proxy"`
|
||||||
SiteName *string `json:"site_name"`
|
SiteName *string `json:"site_name"`
|
||||||
AuthMethod *string `json:"auth_method"`
|
AuthMethod *string `json:"auth_method"`
|
||||||
|
DemoteAfterUse *bool `json:"demote_after_use"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
api.Error(w, http.StatusBadRequest, "请求格式错误")
|
||||||
@@ -296,6 +301,9 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.AuthMethod != nil {
|
if req.AuthMethod != nil {
|
||||||
config.Global.AuthMethod = *req.AuthMethod
|
config.Global.AuthMethod = *req.AuthMethod
|
||||||
}
|
}
|
||||||
|
if req.DemoteAfterUse != nil {
|
||||||
|
config.Global.DemoteAfterUse = *req.DemoteAfterUse
|
||||||
|
}
|
||||||
|
|
||||||
// 保存到数据库 (实时生效)
|
// 保存到数据库 (实时生效)
|
||||||
if err := config.Update(config.Global); err != nil {
|
if err := config.Update(config.Global); err != nil {
|
||||||
|
|||||||
36
backend/internal/api/demote.go
Normal file
36
backend/internal/api/demote.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codex-pool/internal/demote"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleDemoteOwner POST /api/demote/owner - Owner 降级
|
||||||
|
func HandleDemoteOwner(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req demote.DemoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
Error(w, http.StatusBadRequest, "无效的请求: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AccessToken == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "access_token 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认角色
|
||||||
|
if req.Role == "" {
|
||||||
|
req.Role = "standard-user"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := demote.DemoteOwner(req)
|
||||||
|
|
||||||
|
Success(w, result)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"codex-pool/internal/auth"
|
"codex-pool/internal/auth"
|
||||||
"codex-pool/internal/config"
|
"codex-pool/internal/config"
|
||||||
"codex-pool/internal/database"
|
"codex-pool/internal/database"
|
||||||
|
"codex-pool/internal/demote"
|
||||||
"codex-pool/internal/invite"
|
"codex-pool/internal/invite"
|
||||||
"codex-pool/internal/logger"
|
"codex-pool/internal/logger"
|
||||||
"codex-pool/internal/mail"
|
"codex-pool/internal/mail"
|
||||||
@@ -460,6 +461,24 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
|
|||||||
database.Instance.MarkOwnerAsUsed(owner.Email)
|
database.Instance.MarkOwnerAsUsed(owner.Email)
|
||||||
database.Instance.DeleteTeamOwnerByEmail(owner.Email)
|
database.Instance.DeleteTeamOwnerByEmail(owner.Email)
|
||||||
logger.Info(fmt.Sprintf("%s 母号已使用并删除: %s", logPrefix, owner.Email), owner.Email, "team")
|
logger.Info(fmt.Sprintf("%s 母号已使用并删除: %s", logPrefix, owner.Email), owner.Email, "team")
|
||||||
|
|
||||||
|
// 如果开启了母号降级,尝试降级
|
||||||
|
if config.Global != nil && config.Global.DemoteAfterUse {
|
||||||
|
go func() {
|
||||||
|
logger.Info(fmt.Sprintf("%s 尝试降级母号...", logPrefix), owner.Email, "team")
|
||||||
|
result := demote.DemoteOwner(demote.DemoteRequest{
|
||||||
|
AccessToken: owner.Token,
|
||||||
|
AccountID: owner.AccountID,
|
||||||
|
Role: "standard-user",
|
||||||
|
Proxy: req.Proxy,
|
||||||
|
})
|
||||||
|
if result.Success {
|
||||||
|
logger.Success(fmt.Sprintf("%s 母号已降级为普通成员", logPrefix), owner.Email, "team")
|
||||||
|
} else {
|
||||||
|
logger.Warning(fmt.Sprintf("%s 母号降级失败: %s", logPrefix, result.Error), owner.Email, "team")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 失败时恢复为 valid,允许重试
|
// 失败时恢复为 valid,允许重试
|
||||||
database.Instance.MarkOwnerAsFailed(owner.Email)
|
database.Instance.MarkOwnerAsFailed(owner.Email)
|
||||||
@@ -658,7 +677,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
|
|||||||
config.Global.S2AAdminKey,
|
config.Global.S2AAdminKey,
|
||||||
s2aResp.Data.SessionID,
|
s2aResp.Data.SessionID,
|
||||||
code,
|
code,
|
||||||
memberEmail,
|
"team-"+memberEmail,
|
||||||
config.Global.Concurrency,
|
config.Global.Concurrency,
|
||||||
config.Global.Priority,
|
config.Global.Priority,
|
||||||
config.Global.GroupIDs,
|
config.Global.GroupIDs,
|
||||||
@@ -907,7 +926,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul
|
|||||||
config.Global.S2AAdminKey,
|
config.Global.S2AAdminKey,
|
||||||
s2aResp.Data.SessionID,
|
s2aResp.Data.SessionID,
|
||||||
code,
|
code,
|
||||||
owner.Email,
|
"team-"+owner.Email,
|
||||||
config.Global.Concurrency,
|
config.Global.Concurrency,
|
||||||
config.Global.Priority,
|
config.Global.Priority,
|
||||||
config.Global.GroupIDs,
|
config.Global.GroupIDs,
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ type Config struct {
|
|||||||
// 站点配置
|
// 站点配置
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
|
|
||||||
|
// 母号降级配置
|
||||||
|
DemoteAfterUse bool `json:"demote_after_use"` // 母号使用后自动降级
|
||||||
|
|
||||||
// 邮箱服务
|
// 邮箱服务
|
||||||
MailServices []MailServiceConfig `json:"mail_services"`
|
MailServices []MailServiceConfig `json:"mail_services"`
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,9 @@ func InitFromDB() *Config {
|
|||||||
} else {
|
} else {
|
||||||
cfg.AuthMethod = "browser" // 默认使用浏览器模式
|
cfg.AuthMethod = "browser" // 默认使用浏览器模式
|
||||||
}
|
}
|
||||||
|
if v, _ := configDB.GetConfig("demote_after_use"); v == "true" {
|
||||||
|
cfg.DemoteAfterUse = true
|
||||||
|
}
|
||||||
|
|
||||||
Global = cfg
|
Global = cfg
|
||||||
return cfg
|
return cfg
|
||||||
@@ -234,6 +240,8 @@ func SaveToDB() error {
|
|||||||
configDB.SetConfig("auth_method", cfg.AuthMethod)
|
configDB.SetConfig("auth_method", cfg.AuthMethod)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configDB.SetConfig("demote_after_use", strconv.FormatBool(cfg.DemoteAfterUse))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
292
backend/internal/demote/demote.go
Normal file
292
backend/internal/demote/demote.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package demote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codex-pool/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DemoteRequest 降级请求
|
||||||
|
type DemoteRequest struct {
|
||||||
|
AccessToken string `json:"access_token"` // JWT Token 或完整的 session JSON
|
||||||
|
AccountID string `json:"account_id"` // 可选,Team ID
|
||||||
|
Role string `json:"role"` // 目标角色: standard-user 或 account-admin
|
||||||
|
Proxy string `json:"proxy"` // 可选,代理
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoteResult 降级结果
|
||||||
|
type DemoteResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
OriginalRole string `json:"original_role,omitempty"`
|
||||||
|
NewRole string `json:"new_role,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionData ChatGPT session 数据结构
|
||||||
|
type SessionData struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"user"`
|
||||||
|
Account struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTPayload JWT 解析结构
|
||||||
|
type JWTPayload struct {
|
||||||
|
Auth struct {
|
||||||
|
AccountUserID string `json:"chatgpt_account_user_id"`
|
||||||
|
} `json:"https://api.openai.com/auth"`
|
||||||
|
Profile struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"https://api.openai.com/profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJWTPayload 解码 JWT 的 payload 部分
|
||||||
|
func decodeJWTPayload(token string) (*JWTPayload, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid JWT format")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := parts[1]
|
||||||
|
// 补齐 base64 padding
|
||||||
|
if m := len(payload) % 4; m != 0 {
|
||||||
|
payload += strings.Repeat("=", 4-m)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
// 尝试标准 base64
|
||||||
|
decoded, err = base64.StdEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode JWT payload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result JWTPayload
|
||||||
|
if err := json.Unmarshal(decoded, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JWT payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserInfo 从 Token 和 Session 中提取用户信息
|
||||||
|
func extractUserInfo(token string, session *SessionData) (userID, accountID, email string) {
|
||||||
|
// 优先从 session 获取(更准确)
|
||||||
|
if session != nil {
|
||||||
|
if session.User.ID != "" {
|
||||||
|
userID = session.User.ID
|
||||||
|
}
|
||||||
|
if session.Account.ID != "" {
|
||||||
|
accountID = session.Account.ID
|
||||||
|
}
|
||||||
|
if session.User.Email != "" {
|
||||||
|
email = session.User.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经获取到必要信息,直接返回
|
||||||
|
if userID != "" && accountID != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备选:从 JWT 解析
|
||||||
|
payload, err := decodeJWTPayload(token)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 chatgpt_account_user_id,格式可能是 user-xxx__account-id
|
||||||
|
accountUserID := payload.Auth.AccountUserID
|
||||||
|
if accountUserID != "" {
|
||||||
|
if strings.Contains(accountUserID, "__") {
|
||||||
|
parts := strings.Split(accountUserID, "__")
|
||||||
|
if userID == "" {
|
||||||
|
userID = parts[0]
|
||||||
|
}
|
||||||
|
if accountID == "" && len(parts) > 1 {
|
||||||
|
accountID = parts[1]
|
||||||
|
}
|
||||||
|
} else if userID == "" {
|
||||||
|
userID = accountUserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if email == "" {
|
||||||
|
email = payload.Profile.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoteOwner 执行 Owner 降级
|
||||||
|
func DemoteOwner(req DemoteRequest) *DemoteResult {
|
||||||
|
// 验证角色
|
||||||
|
validRoles := map[string]bool{
|
||||||
|
"standard-user": true,
|
||||||
|
"account-admin": true,
|
||||||
|
}
|
||||||
|
if !validRoles[req.Role] {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "无效的角色,必须是 standard-user 或 account-admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := strings.TrimSpace(req.AccessToken)
|
||||||
|
var session *SessionData
|
||||||
|
|
||||||
|
// 检测是否是完整的 session JSON
|
||||||
|
if strings.HasPrefix(accessToken, "{") {
|
||||||
|
if err := json.Unmarshal([]byte(accessToken), &session); err != nil {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "无法解析 session JSON: " + err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accessToken = session.AccessToken
|
||||||
|
if accessToken == "" {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "session JSON 中没有 accessToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取用户信息
|
||||||
|
userID, accountID, email := extractUserInfo(accessToken, session)
|
||||||
|
|
||||||
|
// 如果请求中指定了 account_id,优先使用
|
||||||
|
if req.AccountID != "" {
|
||||||
|
accountID = req.AccountID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "无法获取 user_id,请提供完整的 session JSON",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accountID == "" {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "无法获取 account_id,请提供完整的 session JSON 或指定 account_id",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 TLS 客户端
|
||||||
|
var tlsClient *client.TLSClient
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
// 403 重试机制 - 最多 3 次
|
||||||
|
for retry := 0; retry < 3; retry++ {
|
||||||
|
var err error
|
||||||
|
tlsClient, err = client.New(req.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化会话 - 先访问 chatgpt.com 通过 CF 验证
|
||||||
|
resp, err := tlsClient.Get("https://chatgpt.com")
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
tlsClient.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 403 {
|
||||||
|
lastErr = fmt.Errorf("Cloudflare 403")
|
||||||
|
tlsClient.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
tlsClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Email: email,
|
||||||
|
Error: fmt.Sprintf("初始化会话失败(已重试3次): %v", lastErr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer tlsClient.Close()
|
||||||
|
|
||||||
|
// 构建降级 API 请求
|
||||||
|
apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", accountID, userID)
|
||||||
|
|
||||||
|
payload := map[string]string{"role": req.Role}
|
||||||
|
jsonBody, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
patchReq, err := http.NewRequest("PATCH", apiURL, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Email: email,
|
||||||
|
Error: "创建请求失败: " + err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patchReq.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
patchReq.Header.Set("Content-Type", "application/json")
|
||||||
|
patchReq.Header.Set("Accept", "*/*")
|
||||||
|
patchReq.Header.Set("Origin", "https://chatgpt.com")
|
||||||
|
patchReq.Header.Set("Referer", "https://chatgpt.com/")
|
||||||
|
|
||||||
|
resp, err := tlsClient.Do(patchReq)
|
||||||
|
if err != nil {
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Email: email,
|
||||||
|
Error: "请求失败: " + err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := client.ReadBodyString(resp)
|
||||||
|
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
roleDisplay := "普通成员"
|
||||||
|
if req.Role == "account-admin" {
|
||||||
|
roleDisplay = "管理员"
|
||||||
|
}
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: true,
|
||||||
|
Email: email,
|
||||||
|
NewRole: req.Role,
|
||||||
|
Message: fmt.Sprintf("成功降级为%s", roleDisplay),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DemoteResult{
|
||||||
|
Success: false,
|
||||||
|
Email: email,
|
||||||
|
Error: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateStr(body, 200)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Zap,
|
Zap,
|
||||||
Monitor,
|
Monitor,
|
||||||
Network
|
Network,
|
||||||
|
UserMinus
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||||
import { useConfig } from '../hooks/useConfig'
|
import { useConfig } from '../hooks/useConfig'
|
||||||
@@ -25,6 +26,8 @@ export default function Config() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser')
|
const [authMethod, setAuthMethod] = useState<'api' | 'browser'>('browser')
|
||||||
const [savingAuthMethod, setSavingAuthMethod] = useState(false)
|
const [savingAuthMethod, setSavingAuthMethod] = useState(false)
|
||||||
|
const [demoteAfterUse, setDemoteAfterUse] = useState(false)
|
||||||
|
const [savingDemote, setSavingDemote] = useState(false)
|
||||||
const [proxyPoolCount, setProxyPoolCount] = useState<number>(0)
|
const [proxyPoolCount, setProxyPoolCount] = useState<number>(0)
|
||||||
const { toasts, toast, removeToast } = useToast()
|
const { toasts, toast, removeToast } = useToast()
|
||||||
|
|
||||||
@@ -40,6 +43,8 @@ export default function Config() {
|
|||||||
if (data.data.auth_method) {
|
if (data.data.auth_method) {
|
||||||
setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser')
|
setAuthMethod(data.data.auth_method === 'api' ? 'api' : 'browser')
|
||||||
}
|
}
|
||||||
|
// 加载母号降级开关
|
||||||
|
setDemoteAfterUse(data.data.demote_after_use === true)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch config:', error)
|
console.error('Failed to fetch config:', error)
|
||||||
@@ -113,6 +118,33 @@ export default function Config() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存母号降级开关
|
||||||
|
const handleToggleDemote = async () => {
|
||||||
|
setSavingDemote(true)
|
||||||
|
const newValue = !demoteAfterUse
|
||||||
|
setDemoteAfterUse(newValue) // 立即更新 UI
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ demote_after_use: newValue }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code === 0) {
|
||||||
|
toast.success(newValue ? '母号降级已开启' : '母号降级已关闭')
|
||||||
|
refreshConfig()
|
||||||
|
} else {
|
||||||
|
setDemoteAfterUse(!newValue) // 回滚
|
||||||
|
toast.error(data.message || '保存失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDemoteAfterUse(!newValue) // 回滚
|
||||||
|
toast.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
setSavingDemote(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const configItems = [
|
const configItems = [
|
||||||
{
|
{
|
||||||
to: '/config/s2a',
|
to: '/config/s2a',
|
||||||
@@ -287,6 +319,52 @@ export default function Config() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 母号降级开关 */}
|
||||||
|
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${demoteAfterUse
|
||||||
|
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
|
||||||
|
}`}>
|
||||||
|
<UserMinus className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
母号使用后自动降级
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
母号入库完成后自动降级为普通成员
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleDemote}
|
||||||
|
disabled={savingDemote}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 ${demoteAfterUse
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: 'bg-slate-300 dark:bg-slate-600'
|
||||||
|
} ${savingDemote ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${demoteAfterUse ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{demoteAfterUse && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserMinus className="h-4 w-4" />
|
||||||
|
<span className="font-medium">母号降级已开启</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-orange-100 mt-1">
|
||||||
|
母号入库完成后会自动调用 API 将其降级为普通成员,可以防止母号被滥用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user