From 7629ad553a3cd80fb53c0a0c18c069683e4102a3 Mon Sep 17 00:00:00 2001
From: feitianbubu
Date: Wed, 27 Aug 2025 12:22:05 +0800
Subject: [PATCH 01/64] fix: prevent loop auto-migrate with bool default false
---
model/twofa.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/model/twofa.go b/model/twofa.go
index 8e97289f9..2a3d33530 100644
--- a/model/twofa.go
+++ b/model/twofa.go
@@ -16,7 +16,7 @@ type TwoFA struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"unique;not null;index"`
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端
- IsEnabled bool `json:"is_enabled" gorm:"default:false"`
+ IsEnabled bool `json:"is_enabled"`
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
LockedUntil *time.Time `json:"locked_until,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
@@ -30,7 +30,7 @@ type TwoFABackupCode struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;index"`
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
- IsUsed bool `json:"is_used" gorm:"default:false"`
+ IsUsed bool `json:"is_used"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
From 81e29aaa3db696a180077f3960d04a23ecde0157 Mon Sep 17 00:00:00 2001
From: Sh1n3zZ
Date: Tue, 26 Aug 2025 08:29:26 +0800
Subject: [PATCH 02/64] feat: vertex veo (#1450)
---
common/database.go | 2 +-
controller/setup.go | 2 +-
controller/task_video.go | 42 ++-
main.go | 2 +-
middleware/distributor.go | 2 +-
relay/channel/task/vertex/adaptor.go | 344 ++++++++++++++++++++++++
relay/channel/vertex/adaptor.go | 1 +
relay/channel/vertex/relay-vertex.go | 5 +-
relay/channel/vertex/service_account.go | 47 +++-
relay/relay_adaptor.go | 6 +-
relay/relay_task.go | 96 ++++++-
11 files changed, 534 insertions(+), 15 deletions(-)
create mode 100644 relay/channel/task/vertex/adaptor.go
diff --git a/common/database.go b/common/database.go
index 71dbd94d5..38a54d5e6 100644
--- a/common/database.go
+++ b/common/database.go
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
-var SQLitePath = "one-api.db?_busy_timeout=30000"
+var SQLitePath = "one-api.db?_busy_timeout=30000"
\ No newline at end of file
diff --git a/controller/setup.go b/controller/setup.go
index 8943a1a02..44a7b3a73 100644
--- a/controller/setup.go
+++ b/controller/setup.go
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
return "true"
}
return "false"
-}
+}
\ No newline at end of file
diff --git a/controller/task_video.go b/controller/task_video.go
index ffb6728ba..73d5c39b1 100644
--- a/controller/task_video.go
+++ b/controller/task_video.go
@@ -94,7 +94,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
} else {
- task.Data = responseBody
+ task.Data = redactVideoResponseBody(responseBody)
}
now := time.Now().Unix()
@@ -113,11 +113,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.StartTime = now
}
case model.TaskStatusSuccess:
- task.Progress = "100%"
+ task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
- task.FailReason = taskResult.Url
+ if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
+ task.FailReason = taskResult.Url
+ }
case model.TaskStatusFailure:
task.Status = model.TaskStatusFailure
task.Progress = "100%"
@@ -146,3 +148,37 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return nil
}
+
+func redactVideoResponseBody(body []byte) []byte {
+ var m map[string]any
+ if err := json.Unmarshal(body, &m); err != nil {
+ return body
+ }
+ resp, _ := m["response"].(map[string]any)
+ if resp != nil {
+ delete(resp, "bytesBase64Encoded")
+ if v, ok := resp["video"].(string); ok {
+ resp["video"] = truncateBase64(v)
+ }
+ if vs, ok := resp["videos"].([]any); ok {
+ for i := range vs {
+ if vm, ok := vs[i].(map[string]any); ok {
+ delete(vm, "bytesBase64Encoded")
+ }
+ }
+ }
+ }
+ b, err := json.Marshal(m)
+ if err != nil {
+ return body
+ }
+ return b
+}
+
+func truncateBase64(s string) string {
+ const maxKeep = 256
+ if len(s) <= maxKeep {
+ return s
+ }
+ return s[:maxKeep] + "..."
+}
diff --git a/main.go b/main.go
index 2dfddaccf..91311b867 100644
--- a/main.go
+++ b/main.go
@@ -208,4 +208,4 @@ func InitResources() error {
return err
}
return nil
-}
+}
\ No newline at end of file
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 1e6df872d..7fefeda49 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -166,9 +166,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
- err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
+ err = common.UnmarshalBodyReusable(c, &modelRequest)
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go
new file mode 100644
index 000000000..d2ab826d0
--- /dev/null
+++ b/relay/channel/task/vertex/adaptor.go
@@ -0,0 +1,344 @@
+package vertex
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "one-api/common"
+ "one-api/constant"
+ "one-api/dto"
+ "one-api/relay/channel"
+ vertexcore "one-api/relay/channel/vertex"
+ relaycommon "one-api/relay/common"
+ "one-api/service"
+)
+
+type requestPayload struct {
+ Instances []map[string]any `json:"instances"`
+ Parameters map[string]any `json:"parameters,omitempty"`
+}
+
+type submitResponse struct {
+ Name string `json:"name"`
+}
+
+type operationVideo struct {
+ MimeType string `json:"mimeType"`
+ BytesBase64Encoded string `json:"bytesBase64Encoded"`
+ Encoding string `json:"encoding"`
+}
+
+type operationResponse struct {
+ Name string `json:"name"`
+ Done bool `json:"done"`
+ Response struct {
+ Type string `json:"@type"`
+ RaiMediaFilteredCount int `json:"raiMediaFilteredCount"`
+ Videos []operationVideo `json:"videos"`
+ BytesBase64Encoded string `json:"bytesBase64Encoded"`
+ Encoding string `json:"encoding"`
+ Video string `json:"video"`
+ } `json:"response"`
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+type TaskAdaptor struct{}
+
+func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {}
+
+func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
+ info.Action = constant.TaskActionTextGenerate
+
+ req := relaycommon.TaskSubmitReq{}
+ if err := common.UnmarshalBodyReusable(c, &req); err != nil {
+ return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
+ }
+ if strings.TrimSpace(req.Prompt) == "" {
+ return service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
+ }
+ c.Set("task_request", req)
+ return nil
+}
+
+func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return "", fmt.Errorf("failed to decode credentials: %w", err)
+ }
+ modelName := info.OriginModelName
+ if v, ok := getRequestModelFromContext(info); ok {
+ modelName = v
+ }
+ if modelName == "" {
+ modelName = "veo-3.0-generate-001"
+ }
+
+ region := vertexcore.GetModelRegion(info.ApiVersion, modelName)
+ if strings.TrimSpace(region) == "" {
+ region = "global"
+ }
+ if region == "global" {
+ return fmt.Sprintf(
+ "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning",
+ adc.ProjectID,
+ modelName,
+ ), nil
+ }
+ return fmt.Sprintf(
+ "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
+ region,
+ adc.ProjectID,
+ region,
+ modelName,
+ ), nil
+}
+
+func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil {
+ return fmt.Errorf("failed to decode credentials: %w", err)
+ }
+
+ token, err := vertexcore.AcquireAccessToken(*adc, info.ChannelSetting.Proxy)
+ if err != nil {
+ return fmt.Errorf("failed to acquire access token: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("x-goog-user-project", adc.ProjectID)
+ return nil
+}
+
+func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.TaskRelayInfo) (io.Reader, error) {
+ v, ok := c.Get("task_request")
+ if !ok {
+ return nil, fmt.Errorf("request not found in context")
+ }
+ req := v.(relaycommon.TaskSubmitReq)
+
+ body := requestPayload{
+ Instances: []map[string]any{{"prompt": req.Prompt}},
+ Parameters: map[string]any{},
+ }
+ if req.Metadata != nil {
+ if v, ok := req.Metadata["storageUri"]; ok {
+ body.Parameters["storageUri"] = v
+ }
+ if v, ok := req.Metadata["sampleCount"]; ok {
+ body.Parameters["sampleCount"] = v
+ }
+ }
+ if _, ok := body.Parameters["sampleCount"]; !ok {
+ body.Parameters["sampleCount"] = 1
+ }
+
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewReader(data), nil
+}
+
+func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
+ return channel.DoTaskApiRequest(a, c, info, requestBody)
+}
+
+func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
+ }
+ _ = resp.Body.Close()
+
+ var s submitResponse
+ if err := json.Unmarshal(responseBody, &s); err != nil {
+ return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError)
+ }
+ if strings.TrimSpace(s.Name) == "" {
+ return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError)
+ }
+ localID := encodeLocalTaskID(s.Name)
+ c.JSON(http.StatusOK, gin.H{"task_id": localID})
+ return localID, responseBody, nil
+}
+
+func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} }
+func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
+
+func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
+ taskID, ok := body["task_id"].(string)
+ if !ok {
+ return nil, fmt.Errorf("invalid task_id")
+ }
+ upstreamName, err := decodeLocalTaskID(taskID)
+ if err != nil {
+ return nil, fmt.Errorf("decode task_id failed: %w", err)
+ }
+ region := extractRegionFromOperationName(upstreamName)
+ if region == "" {
+ region = "us-central1"
+ }
+ project := extractProjectFromOperationName(upstreamName)
+ model := extractModelFromOperationName(upstreamName)
+ if project == "" || model == "" {
+ return nil, fmt.Errorf("cannot extract project/model from operation name")
+ }
+ var url string
+ if region == "global" {
+ url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, model)
+ } else {
+ url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, model)
+ }
+ payload := map[string]string{"operationName": upstreamName}
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ adc := &vertexcore.Credentials{}
+ if err := json.Unmarshal([]byte(key), adc); err != nil {
+ return nil, fmt.Errorf("failed to decode credentials: %w", err)
+ }
+ token, err := vertexcore.AcquireAccessToken(*adc, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to acquire access token: %w", err)
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("x-goog-user-project", adc.ProjectID)
+ return service.GetHttpClient().Do(req)
+}
+
+func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
+ var op operationResponse
+ if err := json.Unmarshal(respBody, &op); err != nil {
+ return nil, fmt.Errorf("unmarshal operation response failed: %w", err)
+ }
+ ti := &relaycommon.TaskInfo{}
+ if op.Error.Message != "" {
+ ti.Status = "FAILURE"
+ ti.Reason = op.Error.Message
+ ti.Progress = "100%"
+ return ti, nil
+ }
+ if !op.Done {
+ ti.Status = "IN_PROGRESS"
+ ti.Progress = "50%"
+ return ti, nil
+ }
+ ti.Status = "SUCCESS"
+ ti.Progress = "100%"
+ if len(op.Response.Videos) > 0 {
+ v0 := op.Response.Videos[0]
+ if v0.BytesBase64Encoded != "" {
+ mime := strings.TrimSpace(v0.MimeType)
+ if mime == "" {
+ enc := strings.TrimSpace(v0.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ if strings.Contains(enc, "/") {
+ mime = enc
+ } else {
+ mime = "video/" + enc
+ }
+ }
+ ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded
+ return ti, nil
+ }
+ }
+ if op.Response.BytesBase64Encoded != "" {
+ enc := strings.TrimSpace(op.Response.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ mime := enc
+ if !strings.Contains(enc, "/") {
+ mime = "video/" + enc
+ }
+ ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded
+ return ti, nil
+ }
+ if op.Response.Video != "" { // some variants use `video` as base64
+ enc := strings.TrimSpace(op.Response.Encoding)
+ if enc == "" {
+ enc = "mp4"
+ }
+ mime := enc
+ if !strings.Contains(enc, "/") {
+ mime = "video/" + enc
+ }
+ ti.Url = "data:" + mime + ";base64," + op.Response.Video
+ return ti, nil
+ }
+ return ti, nil
+}
+
+func getRequestModelFromContext(info *relaycommon.TaskRelayInfo) (string, bool) {
+ return info.OriginModelName, info.OriginModelName != ""
+}
+
+func encodeLocalTaskID(name string) string {
+ return base64.RawURLEncoding.EncodeToString([]byte(name))
+}
+
+func decodeLocalTaskID(local string) (string, error) {
+ b, err := base64.RawURLEncoding.DecodeString(local)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`)
+
+func extractRegionFromOperationName(name string) string {
+ m := regionRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return ""
+}
+
+var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`)
+
+func extractModelFromOperationName(name string) string {
+ m := modelRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ idx := strings.Index(name, "models/")
+ if idx >= 0 {
+ s := name[idx+len("models/"):]
+ if p := strings.Index(s, "/operations/"); p > 0 {
+ return s[:p]
+ }
+ }
+ return ""
+}
+
+var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`)
+
+func extractProjectFromOperationName(name string) string {
+ m := projectRe.FindStringSubmatch(name)
+ if len(m) == 2 {
+ return m[1]
+ }
+ return ""
+}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 0b6b26743..d15592bf8 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -174,6 +174,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
return err
}
req.Set("Authorization", "Bearer "+accessToken)
+ req.Set("x-goog-user-project", a.AccountCredentials.ProjectID)
return nil
}
diff --git a/relay/channel/vertex/relay-vertex.go b/relay/channel/vertex/relay-vertex.go
index 5ed876654..f0b84906a 100644
--- a/relay/channel/vertex/relay-vertex.go
+++ b/relay/channel/vertex/relay-vertex.go
@@ -12,7 +12,10 @@ func GetModelRegion(other string, localModelName string) string {
if m[localModelName] != nil {
return m[localModelName].(string)
} else {
- return m["default"].(string)
+ if v, ok := m["default"]; ok {
+ return v.(string)
+ }
+ return "global"
}
}
return other
diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go
index 9a4650d98..f90d5454d 100644
--- a/relay/channel/vertex/service_account.go
+++ b/relay/channel/vertex/service_account.go
@@ -6,14 +6,15 @@ import (
"encoding/json"
"encoding/pem"
"errors"
- "github.com/bytedance/gopkg/cache/asynccache"
- "github.com/golang-jwt/jwt"
"net/http"
"net/url"
relaycommon "one-api/relay/common"
"one-api/service"
"strings"
+ "github.com/bytedance/gopkg/cache/asynccache"
+ "github.com/golang-jwt/jwt"
+
"fmt"
"time"
)
@@ -137,3 +138,45 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
return "", fmt.Errorf("failed to get access token: %v", result)
}
+
+func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
+ signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to create signed JWT: %w", err)
+ }
+ return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
+}
+
+func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
+ authURL := "https://www.googleapis.com/oauth2/v4/token"
+ data := url.Values{}
+ data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
+ data.Set("assertion", signedJWT)
+
+ var client *http.Client
+ var err error
+ if proxy != "" {
+ client, err = service.NewProxyHttpClient(proxy)
+ if err != nil {
+ return "", fmt.Errorf("new proxy http client failed: %w", err)
+ }
+ } else {
+ client = service.GetHttpClient()
+ }
+
+ resp, err := client.PostForm(authURL, data)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ var result map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", err
+ }
+
+ if accessToken, ok := result["access_token"].(string); ok {
+ return accessToken, nil
+ }
+ return "", fmt.Errorf("failed to get access token: %v", result)
+}
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index 1ee85986c..0c271210b 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -1,7 +1,6 @@
package relay
import (
- "github.com/gin-gonic/gin"
"one-api/constant"
"one-api/relay/channel"
"one-api/relay/channel/ali"
@@ -28,6 +27,7 @@ import (
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
+ taskvertex "one-api/relay/channel/task/vertex"
taskVidu "one-api/relay/channel/task/vidu"
"one-api/relay/channel/tencent"
"one-api/relay/channel/vertex"
@@ -37,6 +37,8 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
+
+ "github.com/gin-gonic/gin"
)
func GetAdaptor(apiType int) channel.Adaptor {
@@ -126,6 +128,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
return &kling.TaskAdaptor{}
case constant.ChannelTypeJimeng:
return &taskjimeng.TaskAdaptor{}
+ case constant.ChannelTypeVertexAi:
+ return &taskvertex.TaskAdaptor{}
case constant.ChannelTypeVidu:
return &taskVidu.TaskAdaptor{}
}
diff --git a/relay/relay_task.go b/relay/relay_task.go
index 95b8083b3..6faec176d 100644
--- a/relay/relay_task.go
+++ b/relay/relay_task.go
@@ -15,6 +15,8 @@ import (
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting/ratio_setting"
+ "strconv"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -32,6 +34,7 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
if err != nil {
return service.TaskErrorWrapper(err, "gen_relay_info_failed", http.StatusInternalServerError)
}
+ relayInfo.InitChannelMeta(c)
adaptor := GetTaskAdaptor(platform)
if adaptor == nil {
@@ -197,6 +200,9 @@ func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
if taskErr != nil {
return taskErr
}
+ if len(respBody) == 0 {
+ respBody = []byte("{\"code\":\"success\",\"data\":null}")
+ }
c.Writer.Header().Set("Content-Type", "application/json")
_, err := io.Copy(c.Writer, bytes.NewBuffer(respBody))
@@ -276,10 +282,92 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d
return
}
- respBody, err = json.Marshal(dto.TaskResponse[any]{
- Code: "success",
- Data: TaskModel2Dto(originTask),
- })
+ func() {
+ channelModel, err2 := model.GetChannelById(originTask.ChannelId, true)
+ if err2 != nil {
+ return
+ }
+ if channelModel.Type != constant.ChannelTypeVertexAi {
+ return
+ }
+ baseURL := constant.ChannelBaseURLs[channelModel.Type]
+ if channelModel.GetBaseURL() != "" {
+ baseURL = channelModel.GetBaseURL()
+ }
+ adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type)))
+ if adaptor == nil {
+ return
+ }
+ resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
+ "task_id": originTask.TaskID,
+ "action": originTask.Action,
+ })
+ if err2 != nil || resp == nil {
+ return
+ }
+ defer resp.Body.Close()
+ body, err2 := io.ReadAll(resp.Body)
+ if err2 != nil {
+ return
+ }
+ ti, err2 := adaptor.ParseTaskResult(body)
+ if err2 == nil && ti != nil {
+ if ti.Status != "" {
+ originTask.Status = model.TaskStatus(ti.Status)
+ }
+ if ti.Progress != "" {
+ originTask.Progress = ti.Progress
+ }
+ if ti.Url != "" {
+ originTask.FailReason = ti.Url
+ }
+ _ = originTask.Update()
+ var raw map[string]any
+ _ = json.Unmarshal(body, &raw)
+ format := "mp4"
+ if respObj, ok := raw["response"].(map[string]any); ok {
+ if vids, ok := respObj["videos"].([]any); ok && len(vids) > 0 {
+ if v0, ok := vids[0].(map[string]any); ok {
+ if mt, ok := v0["mimeType"].(string); ok && mt != "" {
+ if strings.Contains(mt, "mp4") {
+ format = "mp4"
+ } else {
+ format = mt
+ }
+ }
+ }
+ }
+ }
+ status := "processing"
+ switch originTask.Status {
+ case model.TaskStatusSuccess:
+ status = "succeeded"
+ case model.TaskStatusFailure:
+ status = "failed"
+ case model.TaskStatusQueued, model.TaskStatusSubmitted:
+ status = "queued"
+ }
+ out := map[string]any{
+ "error": nil,
+ "format": format,
+ "metadata": nil,
+ "status": status,
+ "task_id": originTask.TaskID,
+ "url": originTask.FailReason,
+ }
+ respBody, _ = json.Marshal(dto.TaskResponse[any]{
+ Code: "success",
+ Data: out,
+ })
+ }
+ }()
+
+ if len(respBody) == 0 {
+ respBody, err = json.Marshal(dto.TaskResponse[any]{
+ Code: "success",
+ Data: TaskModel2Dto(originTask),
+ })
+ }
return
}
From e732c5842675d2aeeb3faa2af633341fb9d9c1ac Mon Sep 17 00:00:00 2001
From: creamlike1024
Date: Wed, 27 Aug 2025 21:30:52 +0800
Subject: [PATCH 03/64] =?UTF-8?q?feat:=20gemini-2.5-flash-image-preview=20?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E5=92=8C=E5=9B=BE=E7=89=87=E8=BE=93=E5=87=BA?=
=?UTF-8?q?=E8=AE=A1=E8=B4=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
dto/gemini.go | 16 ++++----
relay/channel/gemini/relay-gemini-native.go | 36 ++++++++++++++++++
relay/compatible_handler.go | 15 ++++++++
service/token_counter.go | 2 +-
setting/model_setting/gemini.go | 1 +
setting/operation_setting/tools.go | 11 ++++++
setting/ratio_setting/model_ratio.go | 10 +++--
web/src/helpers/render.jsx | 38 +++++++++++++++----
web/src/hooks/usage-logs/useUsageLogsData.jsx | 2 +
9 files changed, 111 insertions(+), 20 deletions(-)
diff --git a/dto/gemini.go b/dto/gemini.go
index 5df67ba0b..cd5d74cdd 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -2,11 +2,12 @@ package dto
import (
"encoding/json"
- "github.com/gin-gonic/gin"
"one-api/common"
"one-api/logger"
"one-api/types"
"strings"
+
+ "github.com/gin-gonic/gin"
)
type GeminiChatRequest struct {
@@ -268,14 +269,15 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
- PromptTokenCount int `json:"promptTokenCount"`
- CandidatesTokenCount int `json:"candidatesTokenCount"`
- TotalTokenCount int `json:"totalTokenCount"`
- ThoughtsTokenCount int `json:"thoughtsTokenCount"`
- PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ ThoughtsTokenCount int `json:"thoughtsTokenCount"`
+ PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails"`
+ CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails"`
}
-type GeminiPromptTokensDetails struct {
+type GeminiModalityTokenCount struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go
index 974a22f50..564b86908 100644
--- a/relay/channel/gemini/relay-gemini-native.go
+++ b/relay/channel/gemini/relay-gemini-native.go
@@ -46,6 +46,32 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
+ if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
+ imageOutputCounts := 0
+ for _, candidate := range geminiResponse.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image/") {
+ imageOutputCounts++
+ }
+ }
+ }
+ if imageOutputCounts != 0 {
+ usage.CompletionTokens = usage.CompletionTokens - imageOutputCounts*1290
+ usage.TotalTokens = usage.TotalTokens - imageOutputCounts*1290
+ c.Set("gemini_image_tokens", imageOutputCounts*1290)
+ }
+ }
+
+ // if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
+ // for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
+ // if detail.Modality == "IMAGE" {
+ // usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
+ // usage.TotalTokens = usage.TotalTokens - detail.TokenCount
+ // c.Set("gemini_image_tokens", detail.TokenCount)
+ // }
+ // }
+ // }
+
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
@@ -136,6 +162,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
+
+ if strings.HasPrefix(info.UpstreamModelName, "gemini-2.5-flash-image-preview") {
+ for _, detail := range geminiResponse.UsageMetadata.CandidatesTokensDetails {
+ if detail.Modality == "IMAGE" {
+ usage.CompletionTokens = usage.CompletionTokens - detail.TokenCount
+ usage.TotalTokens = usage.TotalTokens - detail.TokenCount
+ c.Set("gemini_image_tokens", detail.TokenCount)
+ }
+ }
+ }
}
// 直接发送 GeminiChatResponse 响应
diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go
index 1f6c525b5..a3c6ace6e 100644
--- a/relay/compatible_handler.go
+++ b/relay/compatible_handler.go
@@ -314,11 +314,22 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
} else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
}
+ var dGeminiImageOutputQuota decimal.Decimal
+ var imageOutputPrice float64
+ if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
+ imageOutputPrice = operation_setting.GetGeminiImageOutputPricePerMillionTokens(modelName)
+ if imageOutputPrice > 0 {
+ dImageOutputTokens := decimal.NewFromInt(int64(ctx.GetInt("gemini_image_tokens")))
+ dGeminiImageOutputQuota = decimal.NewFromFloat(imageOutputPrice).Div(decimal.NewFromInt(1000000)).Mul(dImageOutputTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
+ }
+ }
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
+ // 添加 Gemini image output 计费
+ quotaCalculateDecimal = quotaCalculateDecimal.Add(dGeminiImageOutputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -413,6 +424,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
+ if !dGeminiImageOutputQuota.IsZero() {
+ other["image_output_token_count"] = ctx.GetInt("gemini_image_tokens")
+ other["image_output_price"] = imageOutputPrice
+ }
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,
diff --git a/service/token_counter.go b/service/token_counter.go
index bac6c067b..9a929e13e 100644
--- a/service/token_counter.go
+++ b/service/token_counter.go
@@ -304,7 +304,7 @@ func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relayco
for _, file := range meta.Files {
switch file.FileType {
case types.FileTypeImage:
- if info.RelayFormat == types.RelayFormatGemini {
+ if info.RelayFormat == types.RelayFormatGemini && !strings.HasPrefix(model, "gemini-2.5-flash-image-preview") {
tkm += 256
} else {
token, err := getImageToken(file, model, info.IsStream)
diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go
index f132fec88..5412155f1 100644
--- a/setting/model_setting/gemini.go
+++ b/setting/model_setting/gemini.go
@@ -26,6 +26,7 @@ var defaultGeminiSettings = GeminiSettings{
SupportedImagineModels: []string{
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-exp",
+ "gemini-2.5-flash-image-preview",
},
ThinkingAdapterEnabled: false,
ThinkingAdapterBudgetTokensPercentage: 0.6,
diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go
index 549a1862e..b87265ee1 100644
--- a/setting/operation_setting/tools.go
+++ b/setting/operation_setting/tools.go
@@ -24,6 +24,10 @@ const (
ClaudeWebSearchPrice = 10.00
)
+const (
+ Gemini25FlashImagePreviewImageOutputPrice = 30.00
+)
+
func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice
}
@@ -65,3 +69,10 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
+
+func GetGeminiImageOutputPricePerMillionTokens(modelName string) float64 {
+ if strings.HasPrefix(modelName, "gemini-2.5-flash-image-preview") {
+ return Gemini25FlashImagePreviewImageOutputPrice
+ }
+ return 0
+}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index f06cd71ef..1a1b0afa8 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
+ "gemini-2.5-flash-image-preview": 0.15, // $0.30(text/image) / 1M tokens
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -293,10 +294,11 @@ var (
)
var defaultCompletionRatio = map[string]float64{
- "gpt-4-gizmo-*": 2,
- "gpt-4o-gizmo-*": 3,
- "gpt-4-all": 2,
- "gpt-image-1": 8,
+ "gpt-4-gizmo-*": 2,
+ "gpt-4o-gizmo-*": 3,
+ "gpt-4-all": 2,
+ "gpt-image-1": 8,
+ "gemini-2.5-flash-image-preview": 8.3333333333,
}
// InitRatioSettings initializes all model related settings maps
diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx
index f7d66c798..597576288 100644
--- a/web/src/helpers/render.jsx
+++ b/web/src/helpers/render.jsx
@@ -1080,7 +1080,7 @@ export function renderModelPrice(
cacheRatio = 1.0,
image = false,
imageRatio = 1.0,
- imageOutputTokens = 0,
+ imageInputTokens = 0,
webSearch = false,
webSearchCallCount = 0,
webSearchPrice = 0,
@@ -1090,6 +1090,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
+ imageOutputTokens = 0,
+ imageOutputPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(groupRatio, user_group_ratio);
groupRatio = effectiveGroupRatio;
@@ -1117,9 +1119,9 @@ export function renderModelPrice(
let effectiveInputTokens =
inputTokens - cacheTokens + cacheTokens * cacheRatio;
// Handle image tokens if present
- if (image && imageOutputTokens > 0) {
+ if (image && imageInputTokens > 0) {
effectiveInputTokens =
- inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
+ inputTokens - imageInputTokens + imageInputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
@@ -1129,7 +1131,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
- (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
+ (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
+ (imageOutputTokens / 1000000) * imageOutputPrice * groupRatio;
return (
<>
@@ -1164,7 +1167,7 @@ export function renderModelPrice(
)}
)}
- {image && imageOutputTokens > 0 && (
+ {image && imageInputTokens > 0 && (
{i18next.t(
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
@@ -1191,17 +1194,26 @@ export function renderModelPrice(
})}
)}
+ {imageOutputPrice > 0 && imageOutputTokens > 0 && (
+
+ {i18next.t('图片输出价格:${{price}} * 分组倍率{{ratio}} = ${{total}} / 1M tokens', {
+ price: imageOutputPrice,
+ ratio: groupRatio,
+ total: imageOutputPrice * groupRatio,
+ })}
+
+ )}
{(() => {
// 构建输入部分描述
let inputDesc = '';
- if (image && imageOutputTokens > 0) {
+ if (image && imageInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
{
- nonImageInput: inputTokens - imageOutputTokens,
- imageInput: imageOutputTokens,
+ nonImageInput: inputTokens - imageInputTokens,
+ imageInput: imageInputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
},
@@ -1271,6 +1283,16 @@ export function renderModelPrice(
},
)
: '',
+ imageOutputPrice > 0 && imageOutputTokens > 0
+ ? i18next.t(
+ ' + 图片输出 {{tokenCounts}} tokens * ${{price}} / 1M tokens * 分组倍率{{ratio}}',
+ {
+ tokenCounts: imageOutputTokens,
+ price: imageOutputPrice,
+ ratio: groupRatio,
+ },
+ )
+ : '',
].join('');
return i18next.t(
diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx
index 0c6c44529..bd2b1100b 100644
--- a/web/src/hooks/usage-logs/useUsageLogsData.jsx
+++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx
@@ -445,6 +445,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
+ other?.image_output_token_count || 0,
+ other?.image_output_price || 0,
);
}
expandDataLocal.push({
From 4055777110ef55292071a67dd1ce2b409d692479 Mon Sep 17 00:00:00 2001
From: HynoR <20227709+HynoR@users.noreply.github.com>
Date: Thu, 28 Aug 2025 15:16:25 +0800
Subject: [PATCH 04/64] fix: update model name filtering to be case-sensitive
---
web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx | 2 +-
web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx
index 5ca8686b8..9394ae83d 100644
--- a/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx
+++ b/web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx
@@ -131,7 +131,7 @@ export default function ModelRatioNotSetEditor(props) {
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText
- ? model.name.toLowerCase().includes(searchText.toLowerCase())
+ ? model.name.includes(searchText)
: true,
);
diff --git a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
index 1205f6d82..680bff4a4 100644
--- a/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
+++ b/web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx
@@ -99,7 +99,7 @@ export default function ModelSettingsVisualEditor(props) {
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) => {
const keywordMatch = searchText
- ? model.name.toLowerCase().includes(searchText.toLowerCase())
+ ? model.name.includes(searchText)
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
From af94e11c7da4895c55acbf737b1868d03fdb7729 Mon Sep 17 00:00:00 2001
From: yunayj
Date: Fri, 29 Aug 2025 19:06:01 +0800
Subject: [PATCH 05/64] =?UTF-8?q?=E4=BF=AE=E6=94=B9claude=20system?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E4=B8=BA=E6=95=B0=E7=BB=84=E6=A0=BC=E5=BC=8F?=
=?UTF-8?q?=EF=BC=8C=E6=8F=90=E5=8D=87API=E5=85=BC=E5=AE=B9=E6=80=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
relay/channel/claude/relay-claude.go | 27 +++++++++++++++++++++------
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 0c445bb9a..7550a97c8 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -274,19 +274,28 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
+ // 初始化system消息数组,用于累积多个system消息
+ var systemMessages []dto.ClaudeMediaMessage
+
for _, message := range formatMessages {
if message.Role == "system" {
+ // 根据Claude API规范,system字段使用数组格式更有通用性
if message.IsStringContent() {
- claudeRequest.System = message.StringContent()
+ systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
+ Type: "text",
+ Text: common.GetPointer[string](message.StringContent()),
+ })
} else {
- contents := message.ParseContent()
- content := ""
- for _, ctx := range contents {
+ // 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
+ for _, ctx := range message.ParseContent() {
if ctx.Type == "text" {
- content += ctx.Text
+ systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
+ Type: "text",
+ Text: common.GetPointer[string](ctx.Text),
+ })
}
+ // 未来可以在这里扩展对图片等其他类型的支持
}
- claudeRequest.System = content
}
} else {
if isFirstMessage {
@@ -392,6 +401,12 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMessages = append(claudeMessages, claudeMessage)
}
}
+
+ // 设置累积的system消息
+ if len(systemMessages) > 0 {
+ claudeRequest.System = systemMessages
+ }
+
claudeRequest.Prompt = ""
claudeRequest.Messages = claudeMessages
return &claudeRequest, nil
From 8809c44443af90a518b720cd13681a79d5ae6477 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?F=E3=80=82?=
Date: Sun, 31 Aug 2025 07:07:40 +0800
Subject: [PATCH 06/64] =?UTF-8?q?=E9=A1=B6=E6=A0=8F=E5=92=8C=E4=BE=A7?=
=?UTF-8?q?=E8=BE=B9=E6=A0=8F=E7=AE=A1=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
增加用户体验
---
controller/misc.go | 4 +
controller/user.go | 185 +++++++-
dto/user_settings.go | 1 +
model/user.go | 86 ++++
web/src/App.jsx | 40 +-
.../layout/HeaderBar/Navigation.jsx | 5 +-
web/src/components/layout/HeaderBar/index.jsx | 5 +-
web/src/components/layout/SiderBar.jsx | 286 ++++++++-----
.../components/settings/OperationSetting.jsx | 16 +
.../components/settings/PersonalSetting.jsx | 4 +
.../personal/cards/NotificationSettings.jsx | 360 +++++++++++++++-
web/src/hooks/common/useHeaderBar.js | 40 +-
web/src/hooks/common/useNavigation.js | 94 ++--
web/src/hooks/common/useSidebar.js | 220 ++++++++++
web/src/hooks/common/useUserPermissions.js | 100 +++++
web/src/i18n/locales/en.json | 60 ++-
.../Operation/SettingsHeaderNavModules.jsx | 326 ++++++++++++++
.../Operation/SettingsSidebarModulesAdmin.jsx | 362 ++++++++++++++++
.../Personal/SettingsSidebarModulesUser.jsx | 404 ++++++++++++++++++
19 files changed, 2428 insertions(+), 170 deletions(-)
create mode 100644 web/src/hooks/common/useSidebar.js
create mode 100644 web/src/hooks/common/useUserPermissions.js
create mode 100644 web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
create mode 100644 web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
create mode 100644 web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
diff --git a/controller/misc.go b/controller/misc.go
index f30ab8c79..dfe3091b5 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -89,6 +89,10 @@ func GetStatus(c *gin.Context) {
"announcements_enabled": cs.AnnouncementsEnabled,
"faq_enabled": cs.FAQEnabled,
+ // 模块管理配置
+ "HeaderNavModules": common.OptionMap["HeaderNavModules"],
+ "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
+
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
diff --git a/controller/user.go b/controller/user.go
index c9795c0cb..91fdce4cf 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -210,6 +210,7 @@ func Register(c *gin.Context) {
Password: user.Password,
DisplayName: user.Username,
InviterId: inviterId,
+ Role: common.RoleCommonUser, // 明确设置角色为普通用户
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
@@ -426,6 +427,7 @@ func GetAffCode(c *gin.Context) {
func GetSelf(c *gin.Context) {
id := c.GetInt("id")
+ userRole := c.GetInt("role")
user, err := model.GetUserById(id, false)
if err != nil {
common.ApiError(c, err)
@@ -434,14 +436,136 @@ func GetSelf(c *gin.Context) {
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
+ // 计算用户权限信息
+ permissions := calculateUserPermissions(userRole)
+
+ // 获取用户设置并提取sidebar_modules
+ userSetting := user.GetSetting()
+
+ // 构建响应数据,包含用户信息和权限
+ responseData := map[string]interface{}{
+ "id": user.Id,
+ "username": user.Username,
+ "display_name": user.DisplayName,
+ "role": user.Role,
+ "status": user.Status,
+ "email": user.Email,
+ "group": user.Group,
+ "quota": user.Quota,
+ "used_quota": user.UsedQuota,
+ "request_count": user.RequestCount,
+ "aff_code": user.AffCode,
+ "aff_count": user.AffCount,
+ "aff_quota": user.AffQuota,
+ "aff_history_quota": user.AffHistoryQuota,
+ "inviter_id": user.InviterId,
+ "linux_do_id": user.LinuxDOId,
+ "setting": user.Setting,
+ "stripe_customer": user.StripeCustomer,
+ "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
+ "permissions": permissions, // 新增权限字段
+ }
+
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
- "data": user,
+ "data": responseData,
})
return
}
+// 计算用户权限的辅助函数
+func calculateUserPermissions(userRole int) map[string]interface{} {
+ permissions := map[string]interface{}{}
+
+ // 根据用户角色计算权限
+ if userRole == common.RoleRootUser {
+ // 超级管理员不需要边栏设置功能
+ permissions["sidebar_settings"] = false
+ permissions["sidebar_modules"] = map[string]interface{}{}
+ } else if userRole == common.RoleAdminUser {
+ // 管理员可以设置边栏,但不包含系统设置功能
+ permissions["sidebar_settings"] = true
+ permissions["sidebar_modules"] = map[string]interface{}{
+ "admin": map[string]interface{}{
+ "setting": false, // 管理员不能访问系统设置
+ },
+ }
+ } else {
+ // 普通用户只能设置个人功能,不包含管理员区域
+ permissions["sidebar_settings"] = true
+ permissions["sidebar_modules"] = map[string]interface{}{
+ "admin": false, // 普通用户不能访问管理员区域
+ }
+ }
+
+ return permissions
+}
+
+// 根据用户角色生成默认的边栏配置
+func generateDefaultSidebarConfig(userRole int) string {
+ defaultConfig := map[string]interface{}{}
+
+ // 聊天区域 - 所有用户都可以访问
+ defaultConfig["chat"] = map[string]interface{}{
+ "enabled": true,
+ "playground": true,
+ "chat": true,
+ }
+
+ // 控制台区域 - 所有用户都可以访问
+ defaultConfig["console"] = map[string]interface{}{
+ "enabled": true,
+ "detail": true,
+ "token": true,
+ "log": true,
+ "midjourney": true,
+ "task": true,
+ }
+
+ // 个人中心区域 - 所有用户都可以访问
+ defaultConfig["personal"] = map[string]interface{}{
+ "enabled": true,
+ "topup": true,
+ "personal": true,
+ }
+
+ // 管理员区域 - 根据角色决定
+ if userRole == common.RoleAdminUser {
+ // 管理员可以访问管理员区域,但不能访问系统设置
+ defaultConfig["admin"] = map[string]interface{}{
+ "enabled": true,
+ "channel": true,
+ "models": true,
+ "redemption": true,
+ "user": true,
+ "setting": false, // 管理员不能访问系统设置
+ }
+ } else if userRole == common.RoleRootUser {
+ // 超级管理员可以访问所有功能
+ defaultConfig["admin"] = map[string]interface{}{
+ "enabled": true,
+ "channel": true,
+ "models": true,
+ "redemption": true,
+ "user": true,
+ "setting": true,
+ }
+ }
+ // 普通用户不包含admin区域
+
+ // 转换为JSON字符串
+ configBytes, err := json.Marshal(defaultConfig)
+ if err != nil {
+ common.SysLog("生成默认边栏配置失败: " + err.Error())
+ return ""
+ }
+
+ return string(configBytes)
+}
+
+
+
func GetUserModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -528,8 +652,8 @@ func UpdateUser(c *gin.Context) {
}
func UpdateSelf(c *gin.Context) {
- var user model.User
- err := json.NewDecoder(c.Request.Body).Decode(&user)
+ var requestData map[string]interface{}
+ err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -537,6 +661,60 @@ func UpdateSelf(c *gin.Context) {
})
return
}
+
+ // 检查是否是sidebar_modules更新请求
+ if sidebarModules, exists := requestData["sidebar_modules"]; exists {
+ userId := c.GetInt("id")
+ user, err := model.GetUserById(userId, false)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+
+ // 获取当前用户设置
+ currentSetting := user.GetSetting()
+
+ // 更新sidebar_modules字段
+ if sidebarModulesStr, ok := sidebarModules.(string); ok {
+ currentSetting.SidebarModules = sidebarModulesStr
+ }
+
+ // 保存更新后的设置
+ user.SetSetting(currentSetting)
+ if err := user.Update(false); err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "更新设置失败: " + err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "设置更新成功",
+ })
+ return
+ }
+
+ // 原有的用户信息更新逻辑
+ var user model.User
+ requestDataBytes, err := json.Marshal(requestData)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "无效的参数",
+ })
+ return
+ }
+ err = json.Unmarshal(requestDataBytes, &user)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "无效的参数",
+ })
+ return
+ }
+
if user.Password == "" {
user.Password = "$I_LOVE_U" // make Validator happy :)
}
@@ -679,6 +857,7 @@ func CreateUser(c *gin.Context) {
Username: user.Username,
Password: user.Password,
DisplayName: user.DisplayName,
+ Role: user.Role, // 保持管理员设置的角色
}
if err := cleanUser.Insert(0); err != nil {
common.ApiError(c, err)
diff --git a/dto/user_settings.go b/dto/user_settings.go
index 2e1a15418..56beb7118 100644
--- a/dto/user_settings.go
+++ b/dto/user_settings.go
@@ -8,6 +8,7 @@ type UserSetting struct {
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
+ SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
}
var (
diff --git a/model/user.go b/model/user.go
index 29d7a4462..ea0584c5a 100644
--- a/model/user.go
+++ b/model/user.go
@@ -91,6 +91,68 @@ func (user *User) SetSetting(setting dto.UserSetting) {
user.Setting = string(settingBytes)
}
+// 根据用户角色生成默认的边栏配置
+func generateDefaultSidebarConfigForRole(userRole int) string {
+ defaultConfig := map[string]interface{}{}
+
+ // 聊天区域 - 所有用户都可以访问
+ defaultConfig["chat"] = map[string]interface{}{
+ "enabled": true,
+ "playground": true,
+ "chat": true,
+ }
+
+ // 控制台区域 - 所有用户都可以访问
+ defaultConfig["console"] = map[string]interface{}{
+ "enabled": true,
+ "detail": true,
+ "token": true,
+ "log": true,
+ "midjourney": true,
+ "task": true,
+ }
+
+ // 个人中心区域 - 所有用户都可以访问
+ defaultConfig["personal"] = map[string]interface{}{
+ "enabled": true,
+ "topup": true,
+ "personal": true,
+ }
+
+ // 管理员区域 - 根据角色决定
+ if userRole == common.RoleAdminUser {
+ // 管理员可以访问管理员区域,但不能访问系统设置
+ defaultConfig["admin"] = map[string]interface{}{
+ "enabled": true,
+ "channel": true,
+ "models": true,
+ "redemption": true,
+ "user": true,
+ "setting": false, // 管理员不能访问系统设置
+ }
+ } else if userRole == common.RoleRootUser {
+ // 超级管理员可以访问所有功能
+ defaultConfig["admin"] = map[string]interface{}{
+ "enabled": true,
+ "channel": true,
+ "models": true,
+ "redemption": true,
+ "user": true,
+ "setting": true,
+ }
+ }
+ // 普通用户不包含admin区域
+
+ // 转换为JSON字符串
+ configBytes, err := json.Marshal(defaultConfig)
+ if err != nil {
+ common.SysLog("生成默认边栏配置失败: " + err.Error())
+ return ""
+ }
+
+ return string(configBytes)
+}
+
// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil
func CheckUserExistOrDeleted(username string, email string) (bool, error) {
var user User
@@ -320,10 +382,34 @@ func (user *User) Insert(inviterId int) error {
user.Quota = common.QuotaForNewUser
//user.SetAccessToken(common.GetUUID())
user.AffCode = common.GetRandomString(4)
+
+ // 初始化用户设置,包括默认的边栏配置
+ if user.Setting == "" {
+ defaultSetting := dto.UserSetting{}
+ // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置
+ user.SetSetting(defaultSetting)
+ }
+
result := DB.Create(user)
if result.Error != nil {
return result.Error
}
+
+ // 用户创建成功后,根据角色初始化边栏配置
+ // 需要重新获取用户以确保有正确的ID和Role
+ var createdUser User
+ if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil {
+ // 生成基于角色的默认边栏配置
+ defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role)
+ if defaultSidebarConfig != "" {
+ currentSetting := createdUser.GetSetting()
+ currentSetting.SidebarModules = defaultSidebarConfig
+ createdUser.SetSetting(currentSetting)
+ createdUser.Update(false)
+ common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role))
+ }
+ }
+
if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
}
diff --git a/web/src/App.jsx b/web/src/App.jsx
index e3bd7db85..cb9245244 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { lazy, Suspense } from 'react';
+import React, { lazy, Suspense, useContext, useMemo } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/common/ui/Loading';
import User from './pages/User';
@@ -27,6 +27,7 @@ import LoginForm from './components/auth/LoginForm';
import NotFound from './pages/NotFound';
import Forbidden from './pages/Forbidden';
import Setting from './pages/Setting';
+import { StatusContext } from './context/Status';
import PasswordResetForm from './components/auth/PasswordResetForm';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
@@ -53,6 +54,29 @@ const About = lazy(() => import('./pages/About'));
function App() {
const location = useLocation();
+ const [statusState] = useContext(StatusContext);
+
+ // 获取模型广场权限配置
+ const pricingRequireAuth = useMemo(() => {
+ const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
+ if (headerNavModulesConfig) {
+ try {
+ const modules = JSON.parse(headerNavModulesConfig);
+
+ // 处理向后兼容性:如果pricing是boolean,默认不需要登录
+ if (typeof modules.pricing === 'boolean') {
+ return false; // 默认不需要登录鉴权
+ }
+
+ // 如果是对象格式,使用requireAuth配置
+ return modules.pricing?.requireAuth === true;
+ } catch (error) {
+ console.error('解析顶栏模块配置失败:', error);
+ return false; // 默认不需要登录
+ }
+ }
+ return false; // 默认不需要登录
+ }, [statusState?.status?.HeaderNavModules]);
return (
@@ -253,9 +277,17 @@ function App() {
} key={location.pathname}>
-
-
+ pricingRequireAuth ? (
+
+ } key={location.pathname}>
+
+
+
+ ) : (
+ } key={location.pathname}>
+
+
+ )
}
/>
{
+const Navigation = ({ mainNavLinks, isMobile, isLoading, userState, pricingRequireAuth }) => {
const renderNavLinks = () => {
const baseClasses =
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
@@ -51,6 +51,9 @@ const Navigation = ({ mainNavLinks, isMobile, isLoading, userState }) => {
if (link.itemKey === 'console' && !userState.user) {
targetPath = '/login';
}
+ if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) {
+ targetPath = '/login';
+ }
return (
diff --git a/web/src/components/layout/HeaderBar/index.jsx b/web/src/components/layout/HeaderBar/index.jsx
index db104de43..81b51d7fe 100644
--- a/web/src/components/layout/HeaderBar/index.jsx
+++ b/web/src/components/layout/HeaderBar/index.jsx
@@ -44,6 +44,8 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
isDemoSiteMode,
isConsoleRoute,
theme,
+ headerNavModules,
+ pricingRequireAuth,
logout,
handleLanguageChange,
handleThemeToggle,
@@ -60,7 +62,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
getUnreadKeys,
} = useNotifications(statusState);
- const { mainNavLinks } = useNavigation(t, docsLink);
+ const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules);
return (
@@ -102,6 +104,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
isMobile={isMobile}
isLoading={isLoading}
userState={userState}
+ pricingRequireAuth={pricingRequireAuth}
/>
{} }) => {
const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
+ const { isModuleVisible, hasSectionVisibleModules, loading: sidebarLoading } = useSidebar();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [chatItems, setChatItems] = useState([]);
@@ -57,117 +59,158 @@ const SiderBar = ({ onNavigate = () => {} }) => {
const [routerMapState, setRouterMapState] = useState(routerMap);
const workspaceItems = useMemo(
- () => [
- {
- text: t('数据看板'),
- itemKey: 'detail',
- to: '/detail',
- className:
- localStorage.getItem('enable_data_export') === 'true'
- ? ''
- : 'tableHiddle',
- },
- {
- text: t('令牌管理'),
- itemKey: 'token',
- to: '/token',
- },
- {
- text: t('使用日志'),
- itemKey: 'log',
- to: '/log',
- },
- {
- text: t('绘图日志'),
- itemKey: 'midjourney',
- to: '/midjourney',
- className:
- localStorage.getItem('enable_drawing') === 'true'
- ? ''
- : 'tableHiddle',
- },
- {
- text: t('任务日志'),
- itemKey: 'task',
- to: '/task',
- className:
- localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
- },
- ],
+ () => {
+ const items = [
+ {
+ text: t('数据看板'),
+ itemKey: 'detail',
+ to: '/detail',
+ className:
+ localStorage.getItem('enable_data_export') === 'true'
+ ? ''
+ : 'tableHiddle',
+ },
+ {
+ text: t('令牌管理'),
+ itemKey: 'token',
+ to: '/token',
+ },
+ {
+ text: t('使用日志'),
+ itemKey: 'log',
+ to: '/log',
+ },
+ {
+ text: t('绘图日志'),
+ itemKey: 'midjourney',
+ to: '/midjourney',
+ className:
+ localStorage.getItem('enable_drawing') === 'true'
+ ? ''
+ : 'tableHiddle',
+ },
+ {
+ text: t('任务日志'),
+ itemKey: 'task',
+ to: '/task',
+ className:
+ localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
+ },
+ ];
+
+ // 根据配置过滤项目
+ const filteredItems = items.filter(item => {
+ const configVisible = isModuleVisible('console', item.itemKey);
+ return configVisible;
+ });
+
+ return filteredItems;
+ },
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
+ isModuleVisible,
],
);
const financeItems = useMemo(
- () => [
- {
- text: t('钱包管理'),
- itemKey: 'topup',
- to: '/topup',
- },
- {
- text: t('个人设置'),
- itemKey: 'personal',
- to: '/personal',
- },
- ],
- [t],
+ () => {
+ const items = [
+ {
+ text: t('钱包管理'),
+ itemKey: 'topup',
+ to: '/topup',
+ },
+ {
+ text: t('个人设置'),
+ itemKey: 'personal',
+ to: '/personal',
+ },
+ ];
+
+ // 根据配置过滤项目
+ const filteredItems = items.filter(item => {
+ const configVisible = isModuleVisible('personal', item.itemKey);
+ return configVisible;
+ });
+
+ return filteredItems;
+ },
+ [t, isModuleVisible],
);
const adminItems = useMemo(
- () => [
- {
- text: t('渠道管理'),
- itemKey: 'channel',
- to: '/channel',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('模型管理'),
- itemKey: 'models',
- to: '/console/models',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('兑换码管理'),
- itemKey: 'redemption',
- to: '/redemption',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('用户管理'),
- itemKey: 'user',
- to: '/user',
- className: isAdmin() ? '' : 'tableHiddle',
- },
- {
- text: t('系统设置'),
- itemKey: 'setting',
- to: '/setting',
- className: isRoot() ? '' : 'tableHiddle',
- },
- ],
- [isAdmin(), isRoot(), t],
+ () => {
+ const items = [
+ {
+ text: t('渠道管理'),
+ itemKey: 'channel',
+ to: '/channel',
+ className: isAdmin() ? '' : 'tableHiddle',
+ },
+ {
+ text: t('模型管理'),
+ itemKey: 'models',
+ to: '/console/models',
+ className: isAdmin() ? '' : 'tableHiddle',
+ },
+ {
+ text: t('兑换码管理'),
+ itemKey: 'redemption',
+ to: '/redemption',
+ className: isAdmin() ? '' : 'tableHiddle',
+ },
+ {
+ text: t('用户管理'),
+ itemKey: 'user',
+ to: '/user',
+ className: isAdmin() ? '' : 'tableHiddle',
+ },
+ {
+ text: t('系统设置'),
+ itemKey: 'setting',
+ to: '/setting',
+ className: isRoot() ? '' : 'tableHiddle',
+ },
+ ];
+
+ // 根据配置过滤项目
+ const filteredItems = items.filter(item => {
+ const configVisible = isModuleVisible('admin', item.itemKey);
+ return configVisible;
+ });
+
+ return filteredItems;
+ },
+ [isAdmin(), isRoot(), t, isModuleVisible],
);
const chatMenuItems = useMemo(
- () => [
- {
- text: t('操练场'),
- itemKey: 'playground',
- to: '/playground',
- },
- {
- text: t('聊天'),
- itemKey: 'chat',
- items: chatItems,
- },
- ],
- [chatItems, t],
+ () => {
+ const items = [
+ {
+ text: t('操练场'),
+ itemKey: 'playground',
+ to: '/playground',
+ },
+ {
+ text: t('聊天'),
+ itemKey: 'chat',
+ items: chatItems,
+ },
+ ];
+
+ // 根据配置过滤项目
+ const filteredItems = items.filter(item => {
+ const configVisible = isModuleVisible('chat', item.itemKey);
+ return configVisible;
+ });
+
+ return filteredItems;
+ },
+ [chatItems, t, isModuleVisible],
);
// 更新路由映射,添加聊天路由
@@ -213,7 +256,6 @@ const SiderBar = ({ onNavigate = () => {} }) => {
updateRouterMapWithChats(chats);
}
} catch (e) {
- console.error(e);
showError('聊天数据解析失败');
}
}
@@ -382,31 +424,41 @@ const SiderBar = ({ onNavigate = () => {} }) => {
}}
>
{/* 聊天区域 */}
-
- {!collapsed &&
{t('聊天')}
}
- {chatMenuItems.map((item) => renderSubItem(item))}
-
+ {hasSectionVisibleModules('chat') && (
+
+ {!collapsed &&
{t('聊天')}
}
+ {chatMenuItems.map((item) => renderSubItem(item))}
+
+ )}
{/* 控制台区域 */}
-
-
- {!collapsed && (
-
{t('控制台')}
- )}
- {workspaceItems.map((item) => renderNavItem(item))}
-
+ {hasSectionVisibleModules('console') && (
+ <>
+
+
+ {!collapsed && (
+
{t('控制台')}
+ )}
+ {workspaceItems.map((item) => renderNavItem(item))}
+
+ >
+ )}
{/* 个人中心区域 */}
-
-
- {!collapsed && (
-
{t('个人中心')}
- )}
- {financeItems.map((item) => renderNavItem(item))}
-
+ {hasSectionVisibleModules('personal') && (
+ <>
+
+
+ {!collapsed && (
+
{t('个人中心')}
+ )}
+ {financeItems.map((item) => renderNavItem(item))}
+
+ >
+ )}
- {/* 管理员区域 - 只在管理员时显示 */}
- {isAdmin() && (
+ {/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
+ {isAdmin() && hasSectionVisibleModules('admin') && (
<>
diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx
index d2669c643..05bda1528 100644
--- a/web/src/components/settings/OperationSetting.jsx
+++ b/web/src/components/settings/OperationSetting.jsx
@@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral';
+import SettingsHeaderNavModules from '../../pages/Setting/Operation/SettingsHeaderNavModules';
+import SettingsSidebarModulesAdmin from '../../pages/Setting/Operation/SettingsSidebarModulesAdmin';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords';
import SettingsLog from '../../pages/Setting/Operation/SettingsLog';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring';
@@ -46,6 +48,12 @@ const OperationSetting = () => {
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
+ /* 顶栏模块管理 */
+ HeaderNavModules: '',
+
+ /* 左侧边栏模块管理(管理员) */
+ SidebarModulesAdmin: '',
+
/* 敏感词设置 */
CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false,
@@ -108,6 +116,14 @@ const OperationSetting = () => {
+ {/* 顶栏模块管理 */}
+
+
+
+ {/* 左侧边栏模块管理(管理员) */}
+
+
+
{/* 屏蔽词过滤设置 */}
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index cdeb1f511..07f2b8c48 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -38,6 +38,8 @@ const PersonalSetting = () => {
let navigate = useNavigate();
const { t } = useTranslation();
+
+
const [inputs, setInputs] = useState({
wechat_verification_code: '',
email_verification_code: '',
@@ -330,6 +332,8 @@ const PersonalSetting = () => {
saveNotificationSettings={saveNotificationSettings}
/>
+
+
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index 6caffde7b..d76706c55 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState, useContext } from 'react';
import {
Button,
Typography,
@@ -28,11 +28,17 @@ import {
Toast,
Tabs,
TabPane,
+ Switch,
+ Row,
+ Col,
} from '@douyinfe/semi-ui';
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
-import { ShieldCheck, Bell, DollarSign } from 'lucide-react';
-import { renderQuotaWithPrompt } from '../../../../helpers';
+import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react';
+import { renderQuotaWithPrompt, API, showSuccess, showError } from '../../../../helpers';
import CodeViewer from '../../../playground/CodeViewer';
+import { StatusContext } from '../../../../context/Status';
+import { UserContext } from '../../../../context/User';
+import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
const NotificationSettings = ({
t,
@@ -41,6 +47,128 @@ const NotificationSettings = ({
saveNotificationSettings,
}) => {
const formApiRef = useRef(null);
+ const [statusState] = useContext(StatusContext);
+ const [userState] = useContext(UserContext);
+
+ // 左侧边栏设置相关状态
+ const [sidebarLoading, setSidebarLoading] = useState(false);
+ const [activeTabKey, setActiveTabKey] = useState('notification');
+ const [sidebarModulesUser, setSidebarModulesUser] = useState({
+ chat: {
+ enabled: true,
+ playground: true,
+ chat: true
+ },
+ console: {
+ enabled: true,
+ detail: true,
+ token: true,
+ log: true,
+ midjourney: true,
+ task: true
+ },
+ personal: {
+ enabled: true,
+ topup: true,
+ personal: true
+ },
+ admin: {
+ enabled: true,
+ channel: true,
+ models: true,
+ redemption: true,
+ user: true,
+ setting: true
+ }
+ });
+ const [adminConfig, setAdminConfig] = useState(null);
+
+ // 使用后端权限验证替代前端角色判断
+ const {
+ permissions,
+ loading: permissionsLoading,
+ hasSidebarSettingsPermission,
+ isSidebarSectionAllowed,
+ isSidebarModuleAllowed,
+ } = useUserPermissions();
+
+ // 左侧边栏设置处理函数
+ const handleSectionChange = (sectionKey) => {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesUser,
+ [sectionKey]: {
+ ...sidebarModulesUser[sectionKey],
+ enabled: checked
+ }
+ };
+ setSidebarModulesUser(newModules);
+ };
+ };
+
+ const handleModuleChange = (sectionKey, moduleKey) => {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesUser,
+ [sectionKey]: {
+ ...sidebarModulesUser[sectionKey],
+ [moduleKey]: checked
+ }
+ };
+ setSidebarModulesUser(newModules);
+ };
+ };
+
+ const saveSidebarSettings = async () => {
+ setSidebarLoading(true);
+ try {
+ const res = await API.put('/api/user/self', {
+ sidebar_modules: JSON.stringify(sidebarModulesUser)
+ });
+ if (res.data.success) {
+ showSuccess(t('侧边栏设置保存成功'));
+ } else {
+ showError(res.data.message);
+ }
+ } catch (error) {
+ showError(t('保存失败'));
+ }
+ setSidebarLoading(false);
+ };
+
+ const resetSidebarModules = () => {
+ const defaultConfig = {
+ chat: { enabled: true, playground: true, chat: true },
+ console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true },
+ personal: { enabled: true, topup: true, personal: true },
+ admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true }
+ };
+ setSidebarModulesUser(defaultConfig);
+ };
+
+ // 加载左侧边栏配置
+ useEffect(() => {
+ const loadSidebarConfigs = async () => {
+ try {
+ // 获取管理员全局配置
+ if (statusState?.status?.SidebarModulesAdmin) {
+ const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
+ setAdminConfig(adminConf);
+ }
+
+ // 获取用户个人配置
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_modules) {
+ const userConf = JSON.parse(userRes.data.data.sidebar_modules);
+ setSidebarModulesUser(userConf);
+ }
+ } catch (error) {
+ console.error('加载边栏配置失败:', error);
+ }
+ };
+
+ loadSidebarConfigs();
+ }, [statusState]);
// 初始化表单值
useEffect(() => {
@@ -54,6 +182,75 @@ const NotificationSettings = ({
handleNotificationSettingChange(field, value);
};
+ // 检查功能是否被管理员允许
+ const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
+ if (!adminConfig) return true;
+
+ if (moduleKey) {
+ return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey];
+ } else {
+ return adminConfig[sectionKey]?.enabled;
+ }
+ };
+
+ // 区域配置数据(根据权限过滤)
+ const sectionConfigs = [
+ {
+ key: 'chat',
+ title: t('聊天区域'),
+ description: t('操练场和聊天功能'),
+ modules: [
+ { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') },
+ { key: 'chat', title: t('聊天'), description: t('聊天会话管理') }
+ ]
+ },
+ {
+ key: 'console',
+ title: t('控制台区域'),
+ description: t('数据管理和日志查看'),
+ modules: [
+ { key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
+ { key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
+ { key: 'log', title: t('使用日志'), description: t('API使用记录') },
+ { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') },
+ { key: 'task', title: t('任务日志'), description: t('系统任务记录') }
+ ]
+ },
+ {
+ key: 'personal',
+ title: t('个人中心区域'),
+ description: t('用户个人功能'),
+ modules: [
+ { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
+ { key: 'personal', title: t('个人设置'), description: t('个人信息设置') }
+ ]
+ },
+ // 管理员区域:根据后端权限控制显示
+ {
+ key: 'admin',
+ title: t('管理员区域'),
+ description: t('系统管理功能'),
+ modules: [
+ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
+ { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
+ { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') },
+ { key: 'user', title: t('用户管理'), description: t('用户账户管理') },
+ { key: 'setting', title: t('系统设置'), description: t('系统参数配置') }
+ ]
+ }
+ ].filter(section => {
+ // 使用后端权限验证替代前端角色判断
+ return isSidebarSectionAllowed(section.key);
+ }).map(section => ({
+ ...section,
+ modules: section.modules.filter(module =>
+ isSidebarModuleAllowed(section.key, module.key)
+ )
+ })).filter(section =>
+ // 过滤掉没有可用模块的区域
+ section.modules.length > 0 && isAllowedByAdmin(section.key)
+ );
+
// 表单提交
const handleSubmit = () => {
if (formApiRef.current) {
@@ -75,10 +272,32 @@ const NotificationSettings = ({
-
+
+ {activeTabKey === 'sidebar' ? (
+ // 边栏设置标签页的按钮
+ <>
+
+
+ >
+ ) : (
+ // 其他标签页的通用保存按钮
+
+ )}
}
>
@@ -103,7 +322,11 @@ const NotificationSettings = ({
onSubmit={handleSubmit}
>
{() => (
-
+ setActiveTabKey(key)}
+ >
{/* 通知配置 Tab */}
+
+ {/* 左侧边栏设置 Tab - 根据后端权限控制显示 */}
+ {hasSidebarSettingsPermission() && (
+
+
+ {t('边栏设置')}
+
+ }
+ itemKey='sidebar'
+ >
+
+
+
+ {t('您可以个性化设置侧边栏的要显示功能')}
+
+
+
+ {/* 边栏设置功能区域容器 */}
+
+
+ {sectionConfigs.map((section) => (
+
+ {/* 区域标题和总开关 */}
+
+
+
+ {section.title}
+
+
+ {section.description}
+
+
+
+
+
+ {/* 功能模块网格 */}
+
+ {section.modules
+ .filter(module => isAllowedByAdmin(section.key, module.key))
+ .map((module) => (
+
+
+
+
+
+ {module.title}
+
+
+ {module.description}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ ))}
+
{/* 关闭边栏设置功能区域容器 */}
+
+
+ )}
)}
diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js
index 1b91511ba..9f95a9b9a 100644
--- a/web/src/hooks/common/useHeaderBar.js
+++ b/web/src/hooks/common/useHeaderBar.js
@@ -17,7 +17,7 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { useState, useEffect, useContext, useCallback } from 'react';
+import { useState, useEffect, useContext, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
@@ -51,6 +51,42 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const docsLink = statusState?.status?.docs_link || '';
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
+ // 获取顶栏模块配置
+ const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
+
+ // 使用useMemo确保headerNavModules正确响应statusState变化
+ const headerNavModules = useMemo(() => {
+ if (headerNavModulesConfig) {
+ try {
+ const modules = JSON.parse(headerNavModulesConfig);
+
+ // 处理向后兼容性:如果pricing是boolean,转换为对象格式
+ if (typeof modules.pricing === 'boolean') {
+ modules.pricing = {
+ enabled: modules.pricing,
+ requireAuth: false // 默认不需要登录鉴权
+ };
+ }
+
+ return modules;
+ } catch (error) {
+ console.error('解析顶栏模块配置失败:', error);
+ return null;
+ }
+ }
+ return null;
+ }, [headerNavModulesConfig]);
+
+ // 获取模型广场权限配置
+ const pricingRequireAuth = useMemo(() => {
+ if (headerNavModules?.pricing) {
+ return typeof headerNavModules.pricing === 'object'
+ ? headerNavModules.pricing.requireAuth
+ : false; // 默认不需要登录
+ }
+ return false; // 默认不需要登录
+ }, [headerNavModules]);
+
const isConsoleRoute = location.pathname.startsWith('/console');
const theme = useTheme();
@@ -156,6 +192,8 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
isConsoleRoute,
theme,
drawerOpen,
+ headerNavModules,
+ pricingRequireAuth,
// Actions
logout,
diff --git a/web/src/hooks/common/useNavigation.js b/web/src/hooks/common/useNavigation.js
index e0bac5521..43d9024ea 100644
--- a/web/src/hooks/common/useNavigation.js
+++ b/web/src/hooks/common/useNavigation.js
@@ -19,41 +19,67 @@ For commercial licensing, please contact support@quantumnous.com
import { useMemo } from 'react';
-export const useNavigation = (t, docsLink) => {
+export const useNavigation = (t, docsLink, headerNavModules) => {
const mainNavLinks = useMemo(
- () => [
- {
- text: t('首页'),
- itemKey: 'home',
- to: '/',
- },
- {
- text: t('控制台'),
- itemKey: 'console',
- to: '/console',
- },
- {
- text: t('模型广场'),
- itemKey: 'pricing',
- to: '/pricing',
- },
- ...(docsLink
- ? [
- {
- text: t('文档'),
- itemKey: 'docs',
- isExternal: true,
- externalLink: docsLink,
- },
- ]
- : []),
- {
- text: t('关于'),
- itemKey: 'about',
- to: '/about',
- },
- ],
- [t, docsLink],
+ () => {
+ // 默认配置,如果没有传入配置则显示所有模块
+ const defaultModules = {
+ home: true,
+ console: true,
+ pricing: true,
+ docs: true,
+ about: true,
+ };
+
+ // 使用传入的配置或默认配置
+ const modules = headerNavModules || defaultModules;
+
+ const allLinks = [
+ {
+ text: t('首页'),
+ itemKey: 'home',
+ to: '/',
+ },
+ {
+ text: t('控制台'),
+ itemKey: 'console',
+ to: '/console',
+ },
+ {
+ text: t('模型广场'),
+ itemKey: 'pricing',
+ to: '/pricing',
+ },
+ ...(docsLink
+ ? [
+ {
+ text: t('文档'),
+ itemKey: 'docs',
+ isExternal: true,
+ externalLink: docsLink,
+ },
+ ]
+ : []),
+ {
+ text: t('关于'),
+ itemKey: 'about',
+ to: '/about',
+ },
+ ];
+
+ // 根据配置过滤导航链接
+ return allLinks.filter(link => {
+ if (link.itemKey === 'docs') {
+ return docsLink && modules.docs;
+ }
+ if (link.itemKey === 'pricing') {
+ // 支持新的pricing配置格式
+ return typeof modules.pricing === 'object' ? modules.pricing.enabled : modules.pricing;
+ }
+ return modules[link.itemKey] === true;
+ });
+ },
+ [t, docsLink, headerNavModules],
);
return {
diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js
new file mode 100644
index 000000000..0a695bbd4
--- /dev/null
+++ b/web/src/hooks/common/useSidebar.js
@@ -0,0 +1,220 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useMemo, useContext } from 'react';
+import { StatusContext } from '../../context/Status';
+import { API } from '../../helpers';
+
+export const useSidebar = () => {
+ const [statusState] = useContext(StatusContext);
+ const [userConfig, setUserConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // 默认配置
+ const defaultAdminConfig = {
+ chat: {
+ enabled: true,
+ playground: true,
+ chat: true
+ },
+ console: {
+ enabled: true,
+ detail: true,
+ token: true,
+ log: true,
+ midjourney: true,
+ task: true
+ },
+ personal: {
+ enabled: true,
+ topup: true,
+ personal: true
+ },
+ admin: {
+ enabled: true,
+ channel: true,
+ models: true,
+ redemption: true,
+ user: true,
+ setting: true
+ }
+ };
+
+ // 获取管理员配置
+ const adminConfig = useMemo(() => {
+ if (statusState?.status?.SidebarModulesAdmin) {
+ try {
+ const config = JSON.parse(statusState.status.SidebarModulesAdmin);
+ return config;
+ } catch (error) {
+ return defaultAdminConfig;
+ }
+ }
+ return defaultAdminConfig;
+ }, [statusState?.status?.SidebarModulesAdmin]);
+
+ // 加载用户配置的通用方法
+ const loadUserConfig = async () => {
+ try {
+ setLoading(true);
+ const res = await API.get('/api/user/self');
+ if (res.data.success && res.data.data.sidebar_modules) {
+ let config;
+ // 检查sidebar_modules是字符串还是对象
+ if (typeof res.data.data.sidebar_modules === 'string') {
+ config = JSON.parse(res.data.data.sidebar_modules);
+ } else {
+ config = res.data.data.sidebar_modules;
+ }
+ setUserConfig(config);
+ } else {
+ // 当用户没有配置时,生成一个基于管理员配置的默认用户配置
+ // 这样可以确保权限控制正确生效
+ const defaultUserConfig = {};
+ Object.keys(adminConfig).forEach(sectionKey => {
+ if (adminConfig[sectionKey]?.enabled) {
+ defaultUserConfig[sectionKey] = { enabled: true };
+ // 为每个管理员允许的模块设置默认值为true
+ Object.keys(adminConfig[sectionKey]).forEach(moduleKey => {
+ if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
+ defaultUserConfig[sectionKey][moduleKey] = true;
+ }
+ });
+ }
+ });
+ setUserConfig(defaultUserConfig);
+ }
+ } catch (error) {
+ // 出错时也生成默认配置,而不是设置为空对象
+ const defaultUserConfig = {};
+ Object.keys(adminConfig).forEach(sectionKey => {
+ if (adminConfig[sectionKey]?.enabled) {
+ defaultUserConfig[sectionKey] = { enabled: true };
+ Object.keys(adminConfig[sectionKey]).forEach(moduleKey => {
+ if (moduleKey !== 'enabled' && adminConfig[sectionKey][moduleKey]) {
+ defaultUserConfig[sectionKey][moduleKey] = true;
+ }
+ });
+ }
+ });
+ setUserConfig(defaultUserConfig);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 刷新用户配置的方法(供外部调用)
+ const refreshUserConfig = async () => {
+ if (Object.keys(adminConfig).length > 0) {
+ await loadUserConfig();
+ }
+ };
+
+ // 加载用户配置
+ useEffect(() => {
+ // 只有当管理员配置加载完成后才加载用户配置
+ if (Object.keys(adminConfig).length > 0) {
+ loadUserConfig();
+ }
+ }, [adminConfig]);
+
+ // 计算最终的显示配置
+ const finalConfig = useMemo(() => {
+ const result = {};
+
+ // 确保adminConfig已加载
+ if (!adminConfig || Object.keys(adminConfig).length === 0) {
+ return result;
+ }
+
+ // 如果userConfig未加载,等待加载完成
+ if (!userConfig) {
+ return result;
+ }
+
+ // 遍历所有区域
+ Object.keys(adminConfig).forEach(sectionKey => {
+ const adminSection = adminConfig[sectionKey];
+ const userSection = userConfig[sectionKey];
+
+ // 如果管理员禁用了整个区域,则该区域不显示
+ if (!adminSection?.enabled) {
+ result[sectionKey] = { enabled: false };
+ return;
+ }
+
+ // 区域级别:用户可以选择隐藏管理员允许的区域
+ // 当userSection存在时检查enabled状态,否则默认为true
+ const sectionEnabled = userSection ? (userSection.enabled !== false) : true;
+ result[sectionKey] = { enabled: sectionEnabled };
+
+ // 功能级别:只有管理员和用户都允许的功能才显示
+ Object.keys(adminSection).forEach(moduleKey => {
+ if (moduleKey === 'enabled') return;
+
+ const adminAllowed = adminSection[moduleKey];
+ // 当userSection存在时检查模块状态,否则默认为true
+ const userAllowed = userSection ? (userSection[moduleKey] !== false) : true;
+
+ result[sectionKey][moduleKey] = adminAllowed && userAllowed && sectionEnabled;
+ });
+ });
+
+ return result;
+ }, [adminConfig, userConfig]);
+
+ // 检查特定功能是否应该显示
+ const isModuleVisible = (sectionKey, moduleKey = null) => {
+ if (moduleKey) {
+ return finalConfig[sectionKey]?.[moduleKey] === true;
+ } else {
+ return finalConfig[sectionKey]?.enabled === true;
+ }
+ };
+
+ // 检查区域是否有任何可见的功能
+ const hasSectionVisibleModules = (sectionKey) => {
+ const section = finalConfig[sectionKey];
+ if (!section?.enabled) return false;
+
+ return Object.keys(section).some(key =>
+ key !== 'enabled' && section[key] === true
+ );
+ };
+
+ // 获取区域的可见功能列表
+ const getVisibleModules = (sectionKey) => {
+ const section = finalConfig[sectionKey];
+ if (!section?.enabled) return [];
+
+ return Object.keys(section)
+ .filter(key => key !== 'enabled' && section[key] === true);
+ };
+
+ return {
+ loading,
+ adminConfig,
+ userConfig,
+ finalConfig,
+ isModuleVisible,
+ hasSectionVisibleModules,
+ getVisibleModules,
+ refreshUserConfig
+ };
+};
diff --git a/web/src/hooks/common/useUserPermissions.js b/web/src/hooks/common/useUserPermissions.js
new file mode 100644
index 000000000..743594353
--- /dev/null
+++ b/web/src/hooks/common/useUserPermissions.js
@@ -0,0 +1,100 @@
+import { useState, useEffect } from 'react';
+import { API } from '../../helpers';
+
+/**
+ * 用户权限钩子 - 从后端获取用户权限,替代前端角色判断
+ * 确保权限控制的安全性,防止前端绕过
+ */
+export const useUserPermissions = () => {
+ const [permissions, setPermissions] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // 加载用户权限(从用户信息接口获取)
+ const loadPermissions = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await API.get('/api/user/self');
+ if (res.data.success) {
+ const userPermissions = res.data.data.permissions;
+ setPermissions(userPermissions);
+ console.log('用户权限加载成功:', userPermissions);
+ } else {
+ setError(res.data.message || '获取权限失败');
+ console.error('获取权限失败:', res.data.message);
+ }
+ } catch (error) {
+ setError('网络错误,请重试');
+ console.error('加载用户权限异常:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadPermissions();
+ }, []);
+
+ // 检查是否有边栏设置权限
+ const hasSidebarSettingsPermission = () => {
+ return permissions?.sidebar_settings === true;
+ };
+
+ // 检查是否允许访问特定的边栏区域
+ const isSidebarSectionAllowed = (sectionKey) => {
+ if (!permissions?.sidebar_modules) return true;
+ const sectionPerms = permissions.sidebar_modules[sectionKey];
+ return sectionPerms !== false;
+ };
+
+ // 检查是否允许访问特定的边栏模块
+ const isSidebarModuleAllowed = (sectionKey, moduleKey) => {
+ if (!permissions?.sidebar_modules) return true;
+ const sectionPerms = permissions.sidebar_modules[sectionKey];
+
+ // 如果整个区域被禁用
+ if (sectionPerms === false) return false;
+
+ // 如果区域存在但模块被禁用
+ if (sectionPerms && sectionPerms[moduleKey] === false) return false;
+
+ return true;
+ };
+
+ // 获取允许的边栏区域列表
+ const getAllowedSidebarSections = () => {
+ if (!permissions?.sidebar_modules) return [];
+
+ return Object.keys(permissions.sidebar_modules).filter(sectionKey =>
+ isSidebarSectionAllowed(sectionKey)
+ );
+ };
+
+ // 获取特定区域允许的模块列表
+ const getAllowedSidebarModules = (sectionKey) => {
+ if (!permissions?.sidebar_modules) return [];
+ const sectionPerms = permissions.sidebar_modules[sectionKey];
+
+ if (sectionPerms === false) return [];
+ if (!sectionPerms || typeof sectionPerms !== 'object') return [];
+
+ return Object.keys(sectionPerms).filter(moduleKey =>
+ moduleKey !== 'enabled' && sectionPerms[moduleKey] === true
+ );
+ };
+
+ return {
+ permissions,
+ loading,
+ error,
+ loadPermissions,
+ hasSidebarSettingsPermission,
+ isSidebarSectionAllowed,
+ isSidebarModuleAllowed,
+ getAllowedSidebarSections,
+ getAllowedSidebarModules,
+ };
+};
+
+export default useUserPermissions;
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 877fa44fe..7b308b9b7 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2017,5 +2017,63 @@
"查看密钥": "View key",
"查看渠道密钥": "View channel key",
"渠道密钥信息": "Channel key information",
- "密钥获取成功": "Key acquisition successful"
+ "密钥获取成功": "Key acquisition successful",
+ "顶栏管理": "Header Management",
+ "控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
+ "用户主页,展示系统信息": "User homepage, displaying system information",
+ "用户控制面板,管理账户": "User control panel for account management",
+ "模型广场": "Model Marketplace",
+ "模型定价,需要登录访问": "Model pricing, requires login to access",
+ "文档": "Documentation",
+ "系统文档和帮助信息": "System documentation and help information",
+ "关于系统的详细信息": "Detailed information about the system",
+ "重置为默认": "Reset to Default",
+ "保存设置": "Save Settings",
+ "已重置为默认配置": "Reset to default configuration",
+ "保存成功": "Saved successfully",
+ "保存失败,请重试": "Save failed, please try again",
+ "侧边栏管理(全局控制)": "Sidebar Management (Global Control)",
+ "全局控制侧边栏区域和功能显示,管理员隐藏的功能用户无法启用": "Global control of sidebar areas and functions, users cannot enable functions hidden by administrators",
+ "聊天区域": "Chat Area",
+ "操练场和聊天功能": "Playground and chat functions",
+ "操练场": "Playground",
+ "AI模型测试环境": "AI model testing environment",
+ "聊天": "Chat",
+ "聊天会话管理": "Chat session management",
+ "控制台区域": "Console Area",
+ "数据管理和日志查看": "Data management and log viewing",
+ "数据看板": "Dashboard",
+ "系统数据统计": "System data statistics",
+ "令牌管理": "Token Management",
+ "API令牌管理": "API token management",
+ "使用日志": "Usage Logs",
+ "API使用记录": "API usage records",
+ "绘图日志": "Drawing Logs",
+ "绘图任务记录": "Drawing task records",
+ "任务日志": "Task Logs",
+ "系统任务记录": "System task records",
+ "个人中心区域": "Personal Center Area",
+ "用户个人功能": "User personal functions",
+ "钱包管理": "Wallet Management",
+ "余额充值管理": "Balance recharge management",
+ "个人设置": "Personal Settings",
+ "个人信息设置": "Personal information settings",
+ "管理员区域": "Administrator Area",
+ "系统管理功能": "System management functions",
+ "渠道管理": "Channel Management",
+ "API渠道配置": "API channel configuration",
+ "模型管理": "Model Management",
+ "AI模型配置": "AI model configuration",
+ "兑换码管理": "Redemption Code Management",
+ "兑换码生成管理": "Redemption code generation management",
+ "用户管理": "User Management",
+ "用户账户管理": "User account management",
+ "系统设置": "System Settings",
+ "系统参数配置": "System parameter configuration",
+ "边栏设置": "Sidebar Settings",
+ "您可以个性化设置侧边栏的要显示功能": "You can customize the sidebar functions to display",
+ "保存边栏设置": "Save Sidebar Settings",
+ "侧边栏设置保存成功": "Sidebar settings saved successfully",
+ "需要登录访问": "Require Login",
+ "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace"
}
diff --git a/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
new file mode 100644
index 000000000..623accefb
--- /dev/null
+++ b/web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
@@ -0,0 +1,326 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useState, useContext } from 'react';
+import { Button, Card, Col, Form, Row, Switch, Typography } from '@douyinfe/semi-ui';
+import { API, showError, showSuccess } from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { StatusContext } from '../../../context/Status';
+
+const { Text } = Typography;
+
+export default function SettingsHeaderNavModules(props) {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [statusState, statusDispatch] = useContext(StatusContext);
+
+ // 顶栏模块管理状态
+ const [headerNavModules, setHeaderNavModules] = useState({
+ home: true,
+ console: true,
+ pricing: {
+ enabled: true,
+ requireAuth: false // 默认不需要登录鉴权
+ },
+ docs: true,
+ about: true,
+ });
+
+ // 处理顶栏模块配置变更
+ function handleHeaderNavModuleChange(moduleKey) {
+ return (checked) => {
+ const newModules = { ...headerNavModules };
+ if (moduleKey === 'pricing') {
+ // 对于pricing模块,只更新enabled属性
+ newModules[moduleKey] = {
+ ...newModules[moduleKey],
+ enabled: checked
+ };
+ } else {
+ newModules[moduleKey] = checked;
+ }
+ setHeaderNavModules(newModules);
+ };
+ }
+
+ // 处理模型广场权限控制变更
+ function handlePricingAuthChange(checked) {
+ const newModules = { ...headerNavModules };
+ newModules.pricing = {
+ ...newModules.pricing,
+ requireAuth: checked
+ };
+ setHeaderNavModules(newModules);
+ }
+
+ // 重置顶栏模块为默认配置
+ function resetHeaderNavModules() {
+ const defaultModules = {
+ home: true,
+ console: true,
+ pricing: {
+ enabled: true,
+ requireAuth: false
+ },
+ docs: true,
+ about: true,
+ };
+ setHeaderNavModules(defaultModules);
+ showSuccess(t('已重置为默认配置'));
+ }
+
+ // 保存配置
+ async function onSubmit() {
+ setLoading(true);
+ try {
+ const res = await API.put('/api/option/', {
+ key: 'HeaderNavModules',
+ value: JSON.stringify(headerNavModules),
+ });
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess(t('保存成功'));
+
+ // 立即更新StatusContext中的状态
+ statusDispatch({
+ type: 'set',
+ payload: {
+ ...statusState.status,
+ HeaderNavModules: JSON.stringify(headerNavModules)
+ }
+ });
+
+ // 刷新父组件状态
+ if (props.refresh) {
+ await props.refresh();
+ }
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ showError(t('保存失败,请重试'));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ // 从 props.options 中获取配置
+ if (props.options && props.options.HeaderNavModules) {
+ try {
+ const modules = JSON.parse(props.options.HeaderNavModules);
+
+ // 处理向后兼容性:如果pricing是boolean,转换为对象格式
+ if (typeof modules.pricing === 'boolean') {
+ modules.pricing = {
+ enabled: modules.pricing,
+ requireAuth: false // 默认不需要登录鉴权
+ };
+ }
+
+ setHeaderNavModules(modules);
+ } catch (error) {
+ // 使用默认配置
+ const defaultModules = {
+ home: true,
+ console: true,
+ pricing: {
+ enabled: true,
+ requireAuth: false
+ },
+ docs: true,
+ about: true,
+ };
+ setHeaderNavModules(defaultModules);
+ }
+ }
+ }, [props.options]);
+
+ // 模块配置数据
+ const moduleConfigs = [
+ {
+ key: 'home',
+ title: t('首页'),
+ description: t('用户主页,展示系统信息')
+ },
+ {
+ key: 'console',
+ title: t('控制台'),
+ description: t('用户控制面板,管理账户')
+ },
+ {
+ key: 'pricing',
+ title: t('模型广场'),
+ description: t('模型定价,需要登录访问'),
+ hasSubConfig: true // 标识该模块有子配置
+ },
+ {
+ key: 'docs',
+ title: t('文档'),
+ description: t('系统文档和帮助信息')
+ },
+ {
+ key: 'about',
+ title: t('关于'),
+ description: t('关于系统的详细信息')
+ }
+ ];
+
+ return (
+
+
+
+
+ {moduleConfigs.map((module) => (
+
+
+
+
+
+ {module.title}
+
+
+ {module.description}
+
+
+
+
+
+
+
+ {/* 为模型广场添加权限控制子开关 */}
+ {module.key === 'pricing' && (module.key === 'pricing' ? headerNavModules[module.key]?.enabled : headerNavModules[module.key]) && (
+
+
+
+
+ {t('需要登录访问')}
+
+
+ {t('开启后未登录用户无法访问模型广场')}
+
+
+
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
new file mode 100644
index 000000000..ec8485fba
--- /dev/null
+++ b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
@@ -0,0 +1,362 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useContext } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, Form, Button, Switch, Row, Col, Typography } from '@douyinfe/semi-ui';
+import { API, showSuccess, showError } from '../../../helpers';
+import { StatusContext } from '../../../context/Status';
+
+const { Text } = Typography;
+
+export default function SettingsSidebarModulesAdmin(props) {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [statusState, statusDispatch] = useContext(StatusContext);
+
+ // 左侧边栏模块管理状态(管理员全局控制)
+ const [sidebarModulesAdmin, setSidebarModulesAdmin] = useState({
+ chat: {
+ enabled: true,
+ playground: true,
+ chat: true
+ },
+ console: {
+ enabled: true,
+ detail: true,
+ token: true,
+ log: true,
+ midjourney: true,
+ task: true
+ },
+ personal: {
+ enabled: true,
+ topup: true,
+ personal: true
+ },
+ admin: {
+ enabled: true,
+ channel: true,
+ models: true,
+ redemption: true,
+ user: true,
+ setting: true
+ }
+ });
+
+ // 处理区域级别开关变更
+ function handleSectionChange(sectionKey) {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesAdmin,
+ [sectionKey]: {
+ ...sidebarModulesAdmin[sectionKey],
+ enabled: checked
+ }
+ };
+ setSidebarModulesAdmin(newModules);
+ };
+ }
+
+ // 处理功能级别开关变更
+ function handleModuleChange(sectionKey, moduleKey) {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesAdmin,
+ [sectionKey]: {
+ ...sidebarModulesAdmin[sectionKey],
+ [moduleKey]: checked
+ }
+ };
+ setSidebarModulesAdmin(newModules);
+ };
+ }
+
+ // 重置为默认配置
+ function resetSidebarModules() {
+ const defaultModules = {
+ chat: {
+ enabled: true,
+ playground: true,
+ chat: true
+ },
+ console: {
+ enabled: true,
+ detail: true,
+ token: true,
+ log: true,
+ midjourney: true,
+ task: true
+ },
+ personal: {
+ enabled: true,
+ topup: true,
+ personal: true
+ },
+ admin: {
+ enabled: true,
+ channel: true,
+ models: true,
+ redemption: true,
+ user: true,
+ setting: true
+ }
+ };
+ setSidebarModulesAdmin(defaultModules);
+ showSuccess(t('已重置为默认配置'));
+ }
+
+ // 保存配置
+ async function onSubmit() {
+ setLoading(true);
+ try {
+ const res = await API.put('/api/option/', {
+ key: 'SidebarModulesAdmin',
+ value: JSON.stringify(sidebarModulesAdmin),
+ });
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess(t('保存成功'));
+
+ // 立即更新StatusContext中的状态
+ statusDispatch({
+ type: 'set',
+ payload: {
+ ...statusState.status,
+ SidebarModulesAdmin: JSON.stringify(sidebarModulesAdmin)
+ }
+ });
+
+ // 刷新父组件状态
+ if (props.refresh) {
+ await props.refresh();
+ }
+ } else {
+ showError(message);
+ }
+ } catch (error) {
+ showError(t('保存失败,请重试'));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ // 从 props.options 中获取配置
+ if (props.options && props.options.SidebarModulesAdmin) {
+ try {
+ const modules = JSON.parse(props.options.SidebarModulesAdmin);
+ setSidebarModulesAdmin(modules);
+ } catch (error) {
+ // 使用默认配置
+ const defaultModules = {
+ chat: { enabled: true, playground: true, chat: true },
+ console: { enabled: true, detail: true, token: true, log: true, midjourney: true, task: true },
+ personal: { enabled: true, topup: true, personal: true },
+ admin: { enabled: true, channel: true, models: true, redemption: true, user: true, setting: true }
+ };
+ setSidebarModulesAdmin(defaultModules);
+ }
+ }
+ }, [props.options]);
+
+ // 区域配置数据
+ const sectionConfigs = [
+ {
+ key: 'chat',
+ title: t('聊天区域'),
+ description: t('操练场和聊天功能'),
+ modules: [
+ { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') },
+ { key: 'chat', title: t('聊天'), description: t('聊天会话管理') }
+ ]
+ },
+ {
+ key: 'console',
+ title: t('控制台区域'),
+ description: t('数据管理和日志查看'),
+ modules: [
+ { key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
+ { key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
+ { key: 'log', title: t('使用日志'), description: t('API使用记录') },
+ { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') },
+ { key: 'task', title: t('任务日志'), description: t('系统任务记录') }
+ ]
+ },
+ {
+ key: 'personal',
+ title: t('个人中心区域'),
+ description: t('用户个人功能'),
+ modules: [
+ { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
+ { key: 'personal', title: t('个人设置'), description: t('个人信息设置') }
+ ]
+ },
+ {
+ key: 'admin',
+ title: t('管理员区域'),
+ description: t('系统管理功能'),
+ modules: [
+ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
+ { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
+ { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') },
+ { key: 'user', title: t('用户管理'), description: t('用户账户管理') },
+ { key: 'setting', title: t('系统设置'), description: t('系统参数配置') }
+ ]
+ }
+ ];
+
+ return (
+
+
+
+ {sectionConfigs.map((section) => (
+
+ {/* 区域标题和总开关 */}
+
+
+
+ {section.title}
+
+
+ {section.description}
+
+
+
+
+
+ {/* 功能模块网格 */}
+
+ {section.modules.map((module) => (
+
+
+
+
+
+ {module.title}
+
+
+ {module.description}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
new file mode 100644
index 000000000..bb779c7ea
--- /dev/null
+++ b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
@@ -0,0 +1,404 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useContext } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Card, Button, Switch, Typography, Row, Col, Avatar } from '@douyinfe/semi-ui';
+import { API, showSuccess, showError } from '../../../helpers';
+import { StatusContext } from '../../../context/Status';
+import { UserContext } from '../../../context/User';
+import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
+import { useSidebar } from '../../../hooks/common/useSidebar';
+import { Settings } from 'lucide-react';
+
+const { Text } = Typography;
+
+export default function SettingsSidebarModulesUser() {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [statusState] = useContext(StatusContext);
+
+ // 使用后端权限验证替代前端角色判断
+ const {
+ permissions,
+ loading: permissionsLoading,
+ hasSidebarSettingsPermission,
+ isSidebarSectionAllowed,
+ isSidebarModuleAllowed,
+ } = useUserPermissions();
+
+ // 使用useSidebar钩子获取刷新方法
+ const { refreshUserConfig } = useSidebar();
+
+ // 如果没有边栏设置权限,不显示此组件
+ if (!permissionsLoading && !hasSidebarSettingsPermission()) {
+ return null;
+ }
+
+ // 权限加载中,显示加载状态
+ if (permissionsLoading) {
+ return null;
+ }
+
+ // 根据用户权限生成默认配置
+ const generateDefaultConfig = () => {
+ const defaultConfig = {};
+
+ // 聊天区域 - 所有用户都可以访问
+ if (isSidebarSectionAllowed('chat')) {
+ defaultConfig.chat = {
+ enabled: true,
+ playground: isSidebarModuleAllowed('chat', 'playground'),
+ chat: isSidebarModuleAllowed('chat', 'chat')
+ };
+ }
+
+ // 控制台区域 - 所有用户都可以访问
+ if (isSidebarSectionAllowed('console')) {
+ defaultConfig.console = {
+ enabled: true,
+ detail: isSidebarModuleAllowed('console', 'detail'),
+ token: isSidebarModuleAllowed('console', 'token'),
+ log: isSidebarModuleAllowed('console', 'log'),
+ midjourney: isSidebarModuleAllowed('console', 'midjourney'),
+ task: isSidebarModuleAllowed('console', 'task')
+ };
+ }
+
+ // 个人中心区域 - 所有用户都可以访问
+ if (isSidebarSectionAllowed('personal')) {
+ defaultConfig.personal = {
+ enabled: true,
+ topup: isSidebarModuleAllowed('personal', 'topup'),
+ personal: isSidebarModuleAllowed('personal', 'personal')
+ };
+ }
+
+ // 管理员区域 - 只有管理员可以访问
+ if (isSidebarSectionAllowed('admin')) {
+ defaultConfig.admin = {
+ enabled: true,
+ channel: isSidebarModuleAllowed('admin', 'channel'),
+ models: isSidebarModuleAllowed('admin', 'models'),
+ redemption: isSidebarModuleAllowed('admin', 'redemption'),
+ user: isSidebarModuleAllowed('admin', 'user'),
+ setting: isSidebarModuleAllowed('admin', 'setting')
+ };
+ }
+
+ return defaultConfig;
+ };
+
+ // 用户个人左侧边栏模块设置
+ const [sidebarModulesUser, setSidebarModulesUser] = useState({});
+
+ // 管理员全局配置
+ const [adminConfig, setAdminConfig] = useState(null);
+
+ // 处理区域级别开关变更
+ function handleSectionChange(sectionKey) {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesUser,
+ [sectionKey]: {
+ ...sidebarModulesUser[sectionKey],
+ enabled: checked
+ }
+ };
+ setSidebarModulesUser(newModules);
+ console.log('用户边栏区域配置变更:', sectionKey, checked, newModules);
+ };
+ }
+
+ // 处理功能级别开关变更
+ function handleModuleChange(sectionKey, moduleKey) {
+ return (checked) => {
+ const newModules = {
+ ...sidebarModulesUser,
+ [sectionKey]: {
+ ...sidebarModulesUser[sectionKey],
+ [moduleKey]: checked
+ }
+ };
+ setSidebarModulesUser(newModules);
+ console.log('用户边栏功能配置变更:', sectionKey, moduleKey, checked, newModules);
+ };
+ }
+
+ // 重置为默认配置(基于权限过滤)
+ function resetSidebarModules() {
+ const defaultConfig = generateDefaultConfig();
+ setSidebarModulesUser(defaultConfig);
+ showSuccess(t('已重置为默认配置'));
+ console.log('用户边栏配置重置为默认:', defaultConfig);
+ }
+
+ // 保存配置
+ async function onSubmit() {
+ setLoading(true);
+ try {
+ console.log('保存用户边栏配置:', sidebarModulesUser);
+ const res = await API.put('/api/user/self', {
+ sidebar_modules: JSON.stringify(sidebarModulesUser),
+ });
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess(t('保存成功'));
+ console.log('用户边栏配置保存成功');
+
+ // 刷新useSidebar钩子中的用户配置,实现实时更新
+ await refreshUserConfig();
+ console.log('用户边栏配置已刷新,边栏将立即更新');
+ } else {
+ showError(message);
+ console.error('用户边栏配置保存失败:', message);
+ }
+ } catch (error) {
+ showError(t('保存失败,请重试'));
+ console.error('用户边栏配置保存异常:', error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // 统一的配置加载逻辑
+ useEffect(() => {
+ const loadConfigs = async () => {
+ try {
+ // 获取管理员全局配置
+ if (statusState?.status?.SidebarModulesAdmin) {
+ const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
+ setAdminConfig(adminConf);
+ console.log('加载管理员边栏配置:', adminConf);
+ }
+
+ // 获取用户个人配置
+ const userRes = await API.get('/api/user/self');
+ if (userRes.data.success && userRes.data.data.sidebar_modules) {
+ let userConf;
+ // 检查sidebar_modules是字符串还是对象
+ if (typeof userRes.data.data.sidebar_modules === 'string') {
+ userConf = JSON.parse(userRes.data.data.sidebar_modules);
+ } else {
+ userConf = userRes.data.data.sidebar_modules;
+ }
+ console.log('从API加载的用户配置:', userConf);
+
+ // 确保用户配置也经过权限过滤
+ const filteredUserConf = {};
+ Object.keys(userConf).forEach(sectionKey => {
+ if (isSidebarSectionAllowed(sectionKey)) {
+ filteredUserConf[sectionKey] = { ...userConf[sectionKey] };
+ // 过滤不允许的模块
+ Object.keys(userConf[sectionKey]).forEach(moduleKey => {
+ if (moduleKey !== 'enabled' && !isSidebarModuleAllowed(sectionKey, moduleKey)) {
+ delete filteredUserConf[sectionKey][moduleKey];
+ }
+ });
+ }
+ });
+ setSidebarModulesUser(filteredUserConf);
+ console.log('权限过滤后的用户配置:', filteredUserConf);
+ } else {
+ // 如果用户没有配置,使用权限过滤后的默认配置
+ const defaultConfig = generateDefaultConfig();
+ setSidebarModulesUser(defaultConfig);
+ console.log('用户无配置,使用默认配置:', defaultConfig);
+ }
+ } catch (error) {
+ console.error('加载边栏配置失败:', error);
+ // 出错时也使用默认配置
+ const defaultConfig = generateDefaultConfig();
+ setSidebarModulesUser(defaultConfig);
+ }
+ };
+
+ // 只有权限加载完成且有边栏设置权限时才加载配置
+ if (!permissionsLoading && hasSidebarSettingsPermission()) {
+ loadConfigs();
+ }
+ }, [statusState, permissionsLoading, hasSidebarSettingsPermission, isSidebarSectionAllowed, isSidebarModuleAllowed]);
+
+ // 检查功能是否被管理员允许
+ const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
+ if (!adminConfig) return true;
+
+ if (moduleKey) {
+ return adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey];
+ } else {
+ return adminConfig[sectionKey]?.enabled;
+ }
+ };
+
+ // 区域配置数据(根据后端权限过滤)
+ const sectionConfigs = [
+ {
+ key: 'chat',
+ title: t('聊天区域'),
+ description: t('操练场和聊天功能'),
+ modules: [
+ { key: 'playground', title: t('操练场'), description: t('AI模型测试环境') },
+ { key: 'chat', title: t('聊天'), description: t('聊天会话管理') }
+ ]
+ },
+ {
+ key: 'console',
+ title: t('控制台区域'),
+ description: t('数据管理和日志查看'),
+ modules: [
+ { key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
+ { key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
+ { key: 'log', title: t('使用日志'), description: t('API使用记录') },
+ { key: 'midjourney', title: t('绘图日志'), description: t('绘图任务记录') },
+ { key: 'task', title: t('任务日志'), description: t('系统任务记录') }
+ ]
+ },
+ {
+ key: 'personal',
+ title: t('个人中心区域'),
+ description: t('用户个人功能'),
+ modules: [
+ { key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
+ { key: 'personal', title: t('个人设置'), description: t('个人信息设置') }
+ ]
+ },
+ {
+ key: 'admin',
+ title: t('管理员区域'),
+ description: t('系统管理功能'),
+ modules: [
+ { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
+ { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
+ { key: 'redemption', title: t('兑换码管理'), description: t('兑换码生成管理') },
+ { key: 'user', title: t('用户管理'), description: t('用户账户管理') },
+ { key: 'setting', title: t('系统设置'), description: t('系统参数配置') }
+ ]
+ }
+ ].filter(section => {
+ // 使用后端权限验证替代前端角色判断
+ return isSidebarSectionAllowed(section.key);
+ }).map(section => ({
+ ...section,
+ modules: section.modules.filter(module =>
+ isSidebarModuleAllowed(section.key, module.key)
+ )
+ })).filter(section =>
+ // 过滤掉没有可用模块的区域
+ section.modules.length > 0 && isAllowedByAdmin(section.key)
+ );
+
+ return (
+
+ {/* 卡片头部 */}
+
+
+
+
+
+
+ {t('左侧边栏个人设置')}
+
+
+ {t('个性化设置左侧边栏的显示内容')}
+
+
+
+
+
+
+ {t('您可以个性化设置侧边栏的要显示功能')}
+
+
+
+ {sectionConfigs.map((section) => (
+
+ {/* 区域标题和总开关 */}
+
+
+
+ {section.title}
+
+
+ {section.description}
+
+
+
+
+
+ {/* 功能模块网格 */}
+
+ {section.modules.map((module) => (
+
+
+
+
+
+ {module.title}
+
+
+ {module.description}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ ))}
+
+ {/* 底部按钮 */}
+
+
+
+
+
+ );
+}
From 9127449a7a3b16c7abf8b75a3fdf25e84fc47950 Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Sun, 31 Aug 2025 13:00:28 +0800
Subject: [PATCH 07/64] =?UTF-8?q?=F0=9F=90=9B=20fix(db):=20rename=20compos?=
=?UTF-8?q?ite=20unique=20indexes=20to=20avoid=20drop/recreate=20on=20rest?=
=?UTF-8?q?art?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Model: rename `uk_model_name` -> `uk_model_name_delete_at`
(composite on `model_name` + `deleted_at`)
- Vendor: rename `uk_vendor_name` -> `uk_vendor_name_delete_at`
(composite on `name` + `deleted_at`)
- Keep legacy cleanup in `model/main.go` to drop old index names
(`uk_model_name`, `model_name`, `uk_vendor_name`, `name`) for compatibility.
Result: idempotent GORM migrations and no unnecessary index churn on MySQL restarts.
Files:
- `model/model_meta.go`
- `model/vendor_meta.go`
---
model/main.go | 11 ++++++-----
model/model_meta.go | 4 ++--
model/vendor_meta.go | 4 ++--
3 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/model/main.go b/model/main.go
index dbf271521..0fe9ceef9 100644
--- a/model/main.go
+++ b/model/main.go
@@ -270,9 +270,9 @@ func migrateDB() error {
dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称
- //if !common.UsingPostgreSQL {
- // return migrateDBFast()
- //}
+ // 清理旧索引名(兼容历史),避免与新的复合唯一索引冲突
+ // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围
+ // 计划:该兼容逻辑将在后续几个版本中移除
err := DB.AutoMigrate(
&Channel{},
&Token{},
@@ -299,8 +299,9 @@ func migrateDB() error {
}
func migrateDBFast() error {
- // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
- // 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引冲突
+ // 清理旧索引名(兼容历史),允许软删除后重新插入同名记录
+ // 说明:仅清理旧名 uk_model_name/model_name、uk_vendor_name/name;新索引名 uk_model_name_delete_at/uk_vendor_name_delete_at 不在清理范围
+ // 计划:该兼容逻辑将在后续几个版本中移除
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("models", "model_name")
diff --git a/model/model_meta.go b/model/model_meta.go
index b7602b0ec..e9582e441 100644
--- a/model/model_meta.go
+++ b/model/model_meta.go
@@ -21,7 +21,7 @@ type BoundChannel struct {
type Model struct {
Id int `json:"id"`
- ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
+ ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
@@ -30,7 +30,7 @@ type Model struct {
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
- DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
diff --git a/model/vendor_meta.go b/model/vendor_meta.go
index 88439f249..20deaea9b 100644
--- a/model/vendor_meta.go
+++ b/model/vendor_meta.go
@@ -14,13 +14,13 @@ import (
type Vendor struct {
Id int `json:"id"`
- Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
+ Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
- DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
+ DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"`
}
// Insert 创建新的供应商记录
From cdef6da9e999381c06091862c9f1f1042982775b Mon Sep 17 00:00:00 2001
From: t0ng7u
Date: Sun, 31 Aug 2025 13:08:34 +0800
Subject: [PATCH 08/64] =?UTF-8?q?=F0=9F=8E=A8=20style(go):=20format=20enti?=
=?UTF-8?q?re=20codebase?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Apply canonical Go formatting to all .go files
- No functional changes; whitespace/import/struct layout only
- Improves consistency, reduces diff noise, and aligns with standard tooling
---
common/utils.go | 48 +--
controller/ratio_config.go | 32 +-
controller/task_video.go | 2 +-
controller/uptime_kuma.go | 30 +-
dto/ratio_sync.go | 36 +-
middleware/stats.go | 6 +-
relay/channel/mokaai/constants.go | 2 +-
setting/console_setting/config.go | 40 +-
setting/console_setting/validation.go | 484 ++++++++++++-------------
setting/ratio_setting/expose_ratio.go | 8 +-
setting/ratio_setting/exposed_cache.go | 68 ++--
11 files changed, 378 insertions(+), 378 deletions(-)
diff --git a/common/utils.go b/common/utils.go
index 17aecd950..f82538138 100644
--- a/common/utils.go
+++ b/common/utils.go
@@ -257,32 +257,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
- durationStr := string(bytes.TrimSpace(output))
- if durationStr == "N/A" {
- // Create a temporary output file name
- tmpFp, err := os.CreateTemp("", "audio-*"+ext)
- if err != nil {
- return 0, errors.Wrap(err, "failed to create temporary file")
- }
- tmpName := tmpFp.Name()
- // Close immediately so ffmpeg can open the file on Windows.
- _ = tmpFp.Close()
- defer os.Remove(tmpName)
+ durationStr := string(bytes.TrimSpace(output))
+ if durationStr == "N/A" {
+ // Create a temporary output file name
+ tmpFp, err := os.CreateTemp("", "audio-*"+ext)
+ if err != nil {
+ return 0, errors.Wrap(err, "failed to create temporary file")
+ }
+ tmpName := tmpFp.Name()
+ // Close immediately so ffmpeg can open the file on Windows.
+ _ = tmpFp.Close()
+ defer os.Remove(tmpName)
- // ffmpeg -y -i filename -vcodec copy -acodec copy
- ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
- if err := ffmpegCmd.Run(); err != nil {
- return 0, errors.Wrap(err, "failed to run ffmpeg")
- }
+ // ffmpeg -y -i filename -vcodec copy -acodec copy
+ ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
+ if err := ffmpegCmd.Run(); err != nil {
+ return 0, errors.Wrap(err, "failed to run ffmpeg")
+ }
- // Recalculate the duration of the new file
- c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
- output, err := c.Output()
- if err != nil {
- return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
- }
- durationStr = string(bytes.TrimSpace(output))
- }
+ // Recalculate the duration of the new file
+ c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
+ output, err := c.Output()
+ if err != nil {
+ return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
+ }
+ durationStr = string(bytes.TrimSpace(output))
+ }
return strconv.ParseFloat(durationStr, 64)
}
diff --git a/controller/ratio_config.go b/controller/ratio_config.go
index 6ddc3d9ef..0cb4aa73b 100644
--- a/controller/ratio_config.go
+++ b/controller/ratio_config.go
@@ -1,24 +1,24 @@
package controller
import (
- "net/http"
- "one-api/setting/ratio_setting"
+ "net/http"
+ "one-api/setting/ratio_setting"
- "github.com/gin-gonic/gin"
+ "github.com/gin-gonic/gin"
)
func GetRatioConfig(c *gin.Context) {
- if !ratio_setting.IsExposeRatioEnabled() {
- c.JSON(http.StatusForbidden, gin.H{
- "success": false,
- "message": "倍率配置接口未启用",
- })
- return
- }
+ if !ratio_setting.IsExposeRatioEnabled() {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": "倍率配置接口未启用",
+ })
+ return
+ }
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "message": "",
- "data": ratio_setting.GetExposedData(),
- })
-}
\ No newline at end of file
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": ratio_setting.GetExposedData(),
+ })
+}
diff --git a/controller/task_video.go b/controller/task_video.go
index ffb6728ba..84b78f901 100644
--- a/controller/task_video.go
+++ b/controller/task_video.go
@@ -113,7 +113,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.StartTime = now
}
case model.TaskStatusSuccess:
- task.Progress = "100%"
+ task.Progress = "100%"
if task.FinishTime == 0 {
task.FinishTime = now
}
diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go
index 05d6297eb..41b9695c3 100644
--- a/controller/uptime_kuma.go
+++ b/controller/uptime_kuma.go
@@ -31,7 +31,7 @@ type Monitor struct {
type UptimeGroupResult struct {
CategoryName string `json:"categoryName"`
- Monitors []Monitor `json:"monitors"`
+ Monitors []Monitor `json:"monitors"`
}
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
url, _ := groupConfig["url"].(string)
slug, _ := groupConfig["slug"].(string)
categoryName, _ := groupConfig["categoryName"].(string)
-
+
result := UptimeGroupResult{
CategoryName: categoryName,
- Monitors: []Monitor{},
+ Monitors: []Monitor{},
}
-
+
if url == "" || slug == "" {
return result
}
baseURL := strings.TrimSuffix(url, "/")
-
+
var statusData struct {
PublicGroupList []struct {
- ID int `json:"id"`
- Name string `json:"name"`
+ ID int `json:"id"`
+ Name string `json:"name"`
MonitorList []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"monitorList"`
} `json:"publicGroupList"`
}
-
+
var heartbeatData struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
@@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
}
g, gCtx := errgroup.WithContext(ctx)
- g.Go(func() error {
- return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
+ g.Go(func() error {
+ return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
})
- g.Go(func() error {
- return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
+ g.Go(func() error {
+ return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
})
if g.Wait() != nil {
@@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
client := &http.Client{Timeout: httpTimeout}
results := make([]UptimeGroupResult, len(groups))
-
+
g, gCtx := errgroup.WithContext(ctx)
for i, group := range groups {
i, group := i, group
@@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
return nil
})
}
-
+
g.Wait()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
-}
\ No newline at end of file
+}
diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go
index 6315f31ae..d6bbf68e1 100644
--- a/dto/ratio_sync.go
+++ b/dto/ratio_sync.go
@@ -1,23 +1,23 @@
package dto
type UpstreamDTO struct {
- ID int `json:"id,omitempty"`
- Name string `json:"name" binding:"required"`
- BaseURL string `json:"base_url" binding:"required"`
- Endpoint string `json:"endpoint"`
+ ID int `json:"id,omitempty"`
+ Name string `json:"name" binding:"required"`
+ BaseURL string `json:"base_url" binding:"required"`
+ Endpoint string `json:"endpoint"`
}
type UpstreamRequest struct {
- ChannelIDs []int64 `json:"channel_ids"`
- Upstreams []UpstreamDTO `json:"upstreams"`
- Timeout int `json:"timeout"`
+ ChannelIDs []int64 `json:"channel_ids"`
+ Upstreams []UpstreamDTO `json:"upstreams"`
+ Timeout int `json:"timeout"`
}
// TestResult 上游测试连通性结果
type TestResult struct {
- Name string `json:"name"`
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
}
// DifferenceItem 差异项
@@ -25,14 +25,14 @@ type TestResult struct {
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct {
- Current interface{} `json:"current"`
- Upstreams map[string]interface{} `json:"upstreams"`
- Confidence map[string]bool `json:"confidence"`
+ Current interface{} `json:"current"`
+ Upstreams map[string]interface{} `json:"upstreams"`
+ Confidence map[string]bool `json:"confidence"`
}
type SyncableChannel struct {
- ID int `json:"id"`
- Name string `json:"name"`
- BaseURL string `json:"base_url"`
- Status int `json:"status"`
-}
\ No newline at end of file
+ ID int `json:"id"`
+ Name string `json:"name"`
+ BaseURL string `json:"base_url"`
+ Status int `json:"status"`
+}
diff --git a/middleware/stats.go b/middleware/stats.go
index 1c97983f7..e49e56991 100644
--- a/middleware/stats.go
+++ b/middleware/stats.go
@@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 增加活跃连接数
atomic.AddInt64(&globalStats.activeConnections, 1)
-
+
// 确保在请求结束时减少连接数
defer func() {
atomic.AddInt64(&globalStats.activeConnections, -1)
}()
-
+
c.Next()
}
}
@@ -38,4 +38,4 @@ func GetStats() StatsInfo {
return StatsInfo{
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
}
-}
\ No newline at end of file
+}
diff --git a/relay/channel/mokaai/constants.go b/relay/channel/mokaai/constants.go
index 415d83b7f..385a0876b 100644
--- a/relay/channel/mokaai/constants.go
+++ b/relay/channel/mokaai/constants.go
@@ -6,4 +6,4 @@ var ModelList = []string{
"m3e-small",
}
-var ChannelName = "mokaai"
\ No newline at end of file
+var ChannelName = "mokaai"
diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go
index 6327e5584..8cfcd0ed6 100644
--- a/setting/console_setting/config.go
+++ b/setting/console_setting/config.go
@@ -3,37 +3,37 @@ package console_setting
import "one-api/setting/config"
type ConsoleSetting struct {
- ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
- UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
- Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
- FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
- ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
- UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
- AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
- FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
+ ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
+ UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
+ Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
+ FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
+ ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
+ UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
+ AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
+ FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
}
// 默认配置
var defaultConsoleSetting = ConsoleSetting{
- ApiInfo: "",
- UptimeKumaGroups: "",
- Announcements: "",
- FAQ: "",
- ApiInfoEnabled: true,
- UptimeKumaEnabled: true,
- AnnouncementsEnabled: true,
- FAQEnabled: true,
+ ApiInfo: "",
+ UptimeKumaGroups: "",
+ Announcements: "",
+ FAQ: "",
+ ApiInfoEnabled: true,
+ UptimeKumaEnabled: true,
+ AnnouncementsEnabled: true,
+ FAQEnabled: true,
}
// 全局实例
var consoleSetting = defaultConsoleSetting
func init() {
- // 注册到全局配置管理器,键名为 console_setting
- config.GlobalConfig.Register("console_setting", &consoleSetting)
+ // 注册到全局配置管理器,键名为 console_setting
+ config.GlobalConfig.Register("console_setting", &consoleSetting)
}
// GetConsoleSetting 获取 ConsoleSetting 配置实例
func GetConsoleSetting() *ConsoleSetting {
- return &consoleSetting
-}
\ No newline at end of file
+ return &consoleSetting
+}
diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go
index fda6453df..529457761 100644
--- a/setting/console_setting/validation.go
+++ b/setting/console_setting/validation.go
@@ -1,304 +1,304 @@
package console_setting
import (
- "encoding/json"
- "fmt"
- "net/url"
- "regexp"
- "strings"
- "time"
- "sort"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
)
var (
- urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
- dangerousChars = []string{"