diff --git a/common/topup-ratio.go b/common/topup-ratio.go index 8f03395d2f97..79d448ca2539 100644 --- a/common/topup-ratio.go +++ b/common/topup-ratio.go @@ -2,6 +2,7 @@ package common import ( "encoding/json" + "sync" ) var TopupGroupRatio = map[string]float64{ @@ -10,7 +11,21 @@ var TopupGroupRatio = map[string]float64{ "svip": 1, } +var topupGroupRatioMutex sync.RWMutex + +func GetTopupGroupRatioCopy() map[string]float64 { + topupGroupRatioMutex.RLock() + defer topupGroupRatioMutex.RUnlock() + cp := make(map[string]float64, len(TopupGroupRatio)) + for k, v := range TopupGroupRatio { + cp[k] = v + } + return cp +} + func TopupGroupRatio2JSONString() string { + topupGroupRatioMutex.RLock() + defer topupGroupRatioMutex.RUnlock() jsonBytes, err := json.Marshal(TopupGroupRatio) if err != nil { SysError("error marshalling model ratio: " + err.Error()) @@ -19,12 +34,20 @@ func TopupGroupRatio2JSONString() string { } func UpdateTopupGroupRatioByJSONString(jsonStr string) error { - TopupGroupRatio = make(map[string]float64) - return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio) + var tmp map[string]float64 + if err := json.Unmarshal([]byte(jsonStr), &tmp); err != nil { + return err + } + topupGroupRatioMutex.Lock() + TopupGroupRatio = tmp + topupGroupRatioMutex.Unlock() + return nil } func GetTopupGroupRatio(name string) float64 { + topupGroupRatioMutex.RLock() ratio, ok := TopupGroupRatio[name] + topupGroupRatioMutex.RUnlock() if !ok { SysError("topup group ratio not found: " + name) return 1 diff --git a/controller/misc.go b/controller/misc.go index 875142ffbf04..93c4f07b7e60 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -42,6 +42,12 @@ func GetStatus(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() + // 获取用户角色信息(如果已登录) + var userRole int = -1 + if role := c.GetInt("role"); role > 0 { + userRole = role + } + data := gin.H{ "version": common.Version, "start_time": common.StartTime, @@ -63,6 +69,7 @@ func GetStatus(c *gin.Context) { "turnstile_site_key": common.TurnstileSiteKey, "top_up_link": common.TopUpLink, "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "invitation_enabled": operation_setting.GetGeneralSetting().InvitationEnabled, "quota_per_unit": common.QuotaPerUnit, "display_in_currency": common.DisplayInCurrencyEnabled, "enable_batch_update": common.BatchUpdateEnabled, @@ -87,9 +94,8 @@ func GetStatus(c *gin.Context) { "announcements_enabled": cs.AnnouncementsEnabled, "faq_enabled": cs.FAQEnabled, - // 模块管理配置 - "HeaderNavModules": common.OptionMap["HeaderNavModules"], - "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + // 模块管理配置 - 根据用户权限过滤 + "header_nav_modules": filterHeaderNavModulesForUser(common.OptionMap["HeaderNavModules"], userRole), "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, @@ -303,3 +309,307 @@ func ResetPassword(c *gin.Context) { }) return } + +// filterHeaderNavModulesForUser 根据用户权限过滤顶栏模块配置 +func filterHeaderNavModulesForUser(headerNavModulesRaw interface{}, userRole int) interface{} { + // 如果配置为空,返回原配置 + if headerNavModulesRaw == nil { + return headerNavModulesRaw + } + + headerNavModulesStr, ok := headerNavModulesRaw.(string) + if !ok || headerNavModulesStr == "" { + return headerNavModulesRaw + } + + // 解析配置 + var config map[string]interface{} + if err := json.Unmarshal([]byte(headerNavModulesStr), &config); err != nil { + // 解析失败时返回空配置,采用安全优先策略 + return "{}" + } + + // 对于所有用户(包括未登录用户),都需要移除被禁用的模块 + filteredConfig := make(map[string]interface{}) + for key, value := range config { + // 首先检查模块是否启用 + if isHeaderNavModuleEnabled(key, value) { + // 未登录用户:仅移除被禁用的模块,保留启用模块(包括 pricing,以便前端根据 requireAuth 决定跳转到 /login) + if userRole == -1 { + filteredConfig[key] = value + continue + } + + // 超级管理员可以看到所有启用的模块 + if userRole >= common.RoleRootUser { + filteredConfig[key] = value + } else { + // 管理员和普通用户:进行权限检查 + if hasHeaderNavModulePermission(key, value, userRole) { + filteredConfig[key] = value + } + } + } + // 被禁用的模块(isHeaderNavModuleEnabled返回false)不会被添加到filteredConfig中 + } + + // 转换回JSON字符串 + filteredBytes, err := json.Marshal(filteredConfig) + if err != nil { + return "{}" + } + + return string(filteredBytes) +} + +// FilterSidebarModulesAdminForUser 根据用户权限过滤侧边栏管理配置 +func FilterSidebarModulesAdminForUser(sidebarModulesRaw interface{}, userRole int) interface{} { + // 如果用户未登录,返回空配置以保护敏感信息 + if userRole == -1 { + return "{}" + } + + if sidebarModulesRaw == nil { + return sidebarModulesRaw + } + + sidebarModulesStr, ok := sidebarModulesRaw.(string) + if !ok || sidebarModulesStr == "" { + return sidebarModulesRaw + } + + // 解析配置 + var config map[string]interface{} + if err := json.Unmarshal([]byte(sidebarModulesStr), &config); err != nil { + // 解析失败时返回空配置,采用安全优先策略 + return "{}" + } + + + + // 对于所有用户,移除被禁用的模块 + filteredConfig := make(map[string]interface{}) + for sectionKey, sectionValue := range config { + if sectionObj, ok := sectionValue.(map[string]interface{}); ok { + // 检查区域是否启用 + sectionEnabledByConfig := true + if enabled, hasEnabled := sectionObj["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok && !enabledBool { + sectionEnabledByConfig = false + } + } + + // 如果区域被配置为禁用,跳过整个区域(但console区域需要特殊处理,因为数据看板始终可访问) + if !sectionEnabledByConfig && sectionKey != "console" { + continue + } + + filteredSection := make(map[string]interface{}) + hasValidModules := false // 标记区域是否有有效的模块 + + // 复制区域配置 + for moduleKey, moduleValue := range sectionObj { + // 检查用户是否有权限访问此模块 + modulePath := sectionKey + "." + moduleKey + + // 数据看板始终允许访问,强制设置为启用 + if modulePath == "console.detail" { + filteredSection[moduleKey] = true + hasValidModules = true + } else if moduleKey == "enabled" { + // 只有当区域启用时才复制enabled字段 + if sectionEnabledByConfig { + filteredSection[moduleKey] = moduleValue + } + } else if sectionEnabledByConfig && hasModulePermissionForUser(userRole, modulePath, moduleValue) { + // 处理嵌套权限(如admin.user.groupManagement) + if moduleObj, ok := moduleValue.(map[string]interface{}); ok { + filteredModule := make(map[string]interface{}) + + // 首先检查模块本身是否启用 + if enabled, hasEnabled := moduleObj["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok && !enabledBool { + continue // 跳过被禁用的模块 + } + filteredModule["enabled"] = enabled + } + + // 过滤子模块 + for subKey, subValue := range moduleObj { + if subKey == "enabled" { + continue // enabled字段已经处理过了 + } + + subModulePath := modulePath + "." + subKey + + // 检查子模块是否启用 + if subValueBool, ok := subValue.(bool); ok && !subValueBool { + continue // 跳过被禁用的子模块 + } + + if hasModulePermissionForUser(userRole, subModulePath, subValue) { + filteredModule[subKey] = subValue + } + } + + // 只有当过滤后的模块不为空时才添加 + if len(filteredModule) > 0 { + filteredSection[moduleKey] = filteredModule + hasValidModules = true + } + } else { + // 检查简单模块值是否启用 + if moduleValueBool, ok := moduleValue.(bool); ok && !moduleValueBool { + continue // 跳过被禁用的简单模块 + } + filteredSection[moduleKey] = moduleValue + hasValidModules = true + } + } + } + + // 只有当区域有有效模块时才添加到结果中 + if hasValidModules && len(filteredSection) > 0 { + filteredConfig[sectionKey] = filteredSection + } + } + } + + // 转换回JSON字符串 + filteredBytes, err := json.Marshal(filteredConfig) + if err != nil { + return "{}" + } + + return string(filteredBytes) +} + +// isHeaderNavModuleEnabled 检查顶栏模块是否启用 +func isHeaderNavModuleEnabled(moduleKey string, moduleValue interface{}) bool { + switch v := moduleValue.(type) { + case bool: + return v + case map[string]interface{}: + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + return enabledBool + } + } + return true // 如果没有enabled字段,默认启用 + default: + return true + } +} + +// hasHeaderNavModulePermission 检查用户是否有权限访问顶栏模块 +func hasHeaderNavModulePermission(moduleKey string, moduleValue interface{}, userRole int) bool { + // 对于模型广场,需要检查requireAuth配置 + if moduleKey == "pricing" { + return checkPricingModulePermission(moduleValue, userRole) + } + + // 未登录用户和普通用户只能访问基础模块 + if userRole < common.RoleAdminUser { + allowedModules := map[string]bool{ + "home": true, + "console": true, + "docs": true, // 文档允许未登录用户访问 + "about": true, // 关于页面允许未登录用户访问 + } + return allowedModules[moduleKey] + } + + // 管理员可以访问更多模块 + return true +} + +// checkPricingModulePermission 检查模型广场模块的权限 +func checkPricingModulePermission(moduleValue interface{}, userRole int) bool { + // 如果是布尔值配置,默认不需要登录 + if boolValue, ok := moduleValue.(bool); ok { + return boolValue // 简单的启用/禁用 + } + + // 如果是对象配置,检查requireAuth设置 + if objValue, ok := moduleValue.(map[string]interface{}); ok { + // 检查模块是否启用 + if enabled, hasEnabled := objValue["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok && !enabledBool { + return false // 模块被禁用 + } + } + + // 检查是否需要登录 + if requireAuth, hasRequireAuth := objValue["requireAuth"]; hasRequireAuth { + if requireAuthBool, ok := requireAuth.(bool); ok && requireAuthBool { + // 需要登录才能访问,未登录用户不能访问 + return userRole >= common.RoleCommonUser + } + } + + // 默认不需要登录 + return true + } + + // 其他情况默认允许 + return true +} + +// hasModulePermissionForUser 检查用户是否有权限访问指定模块 +func hasModulePermissionForUser(userRole int, modulePath string, moduleValue interface{}) bool { + // 数据看板始终允许访问,不受控制台区域开关影响 + if modulePath == "console.detail" { + return true + } + + // 普通用户只能访问基础功能 + if userRole < common.RoleAdminUser { + return isUserModuleAllowedInFilter(modulePath) + } + + // 管理员需要检查模块是否启用 + if userRole >= common.RoleAdminUser && userRole < common.RoleRootUser { + // 检查模块值是否为启用状态 + switch v := moduleValue.(type) { + case bool: + return v + case map[string]interface{}: + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + return enabledBool + } + } + return true // 如果没有enabled字段,默认启用 + default: + return true + } + } + + return true +} + +// isUserModuleAllowedInFilter 检查普通用户是否允许访问指定模块(用于过滤) +func isUserModuleAllowedInFilter(modulePath string) bool { + // 数据看板始终允许访问,不受控制台区域开关影响 + if modulePath == "console.detail" { + return true + } + + // 普通用户允许访问的模块列表 + allowedModules := map[string]bool{ + "console.enabled": true, + "console.detail": true, + "console.token": true, + "console.log": true, + "console.midjourney": true, + "console.task": true, + "personal.enabled": true, + "personal.topup": true, + "personal.personal": true, + "chat.enabled": true, + "chat.playground": true, + "chat.chat": true, + } + + return allowedModules[modulePath] +} diff --git a/controller/option.go b/controller/option.go index 3e59c68e044e..7d1c676f540e 100644 --- a/controller/option.go +++ b/controller/option.go @@ -129,7 +129,7 @@ func UpdateOption(c *gin.Context) { return } case "ImageRatio": - err = ratio_setting.UpdateImageRatioByJSONString(option.Value) + err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -138,7 +138,7 @@ func UpdateOption(c *gin.Context) { return } case "AudioRatio": - err = ratio_setting.UpdateAudioRatioByJSONString(option.Value) + err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -147,7 +147,7 @@ func UpdateOption(c *gin.Context) { return } case "AudioCompletionRatio": - err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value) + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string)) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/controller/pricing.go b/controller/pricing.go index 4b7cc86d505c..c77250e0a174 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,6 +1,9 @@ package controller import ( + "encoding/json" + "net/http" + "one-api/common" "one-api/model" "one-api/setting" "one-api/setting/ratio_setting" @@ -9,6 +12,15 @@ import ( ) func GetPricing(c *gin.Context) { + // 检查模型广场访问权限 + allowed, code, msg := checkPricingAccess(c) + if !allowed { + c.JSON(code, gin.H{ + "success": false, + "message": msg, + }) + return + } pricing := model.GetPricing() userId, exists := c.Get("id") usableGroup := map[string]string{} @@ -49,6 +61,91 @@ func GetPricing(c *gin.Context) { }) } +// checkPricingAccess 检查用户是否有权限访问模型广场 +// 返回值:(是否允许访问, HTTP状态码, 错误消息) +func checkPricingAccess(c *gin.Context) (bool, int, string) { + // 获取顶栏模块配置 + common.OptionMapRWMutex.RLock() + headerNavModulesRaw, exists := common.OptionMap["HeaderNavModules"] + common.OptionMapRWMutex.RUnlock() + + if !exists || headerNavModulesRaw == "" { + // 如果没有配置,默认允许访问 + return true, 0, "" + } + + // 解析配置 + var config map[string]interface{} + if err := json.Unmarshal([]byte(headerNavModulesRaw), &config); err != nil { + // 解析失败时返回500错误 + return false, http.StatusInternalServerError, "配置解析失败" + } + + // 检查pricing模块配置 + pricingConfig, hasPricing := config["pricing"] + if !hasPricing { + // 如果没有pricing配置,默认允许访问 + return true, 0, "" + } + + // 检查模块是否启用 + if !isPricingModuleEnabled(pricingConfig) { + return false, http.StatusForbidden, "模型广场功能已被禁用" + } + + // 检查是否需要登录 + if isPricingRequireAuth(pricingConfig) { + // 需要登录,检查用户是否已登录 + userId, exists := c.Get("id") + if !exists || userId == nil { + return false, http.StatusUnauthorized, "需要登录才能访问模型广场" + } + + // 从数据库获取用户信息验证角色 + user, err := model.GetUserById(userId.(int), false) + if err != nil { + return false, http.StatusInternalServerError, "获取用户信息失败" + } + + if user.Role < common.RoleCommonUser { + return false, http.StatusForbidden, "权限不足" + } + } + + // 允许访问 + return true, 0, "" +} + +// isPricingModuleEnabled 检查pricing模块是否启用 +func isPricingModuleEnabled(moduleValue interface{}) bool { + switch v := moduleValue.(type) { + case bool: + return v + case map[string]interface{}: + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + return enabledBool + } + } + return true // 如果没有enabled字段,默认启用 + default: + return true + } +} + +// isPricingRequireAuth 检查pricing模块是否需要登录 +func isPricingRequireAuth(moduleValue interface{}) bool { + if objValue, ok := moduleValue.(map[string]interface{}); ok { + if requireAuth, hasRequireAuth := objValue["requireAuth"]; hasRequireAuth { + if requireAuthBool, ok := requireAuth.(bool); ok { + return requireAuthBool + } + } + } + // 默认不需要登录 + return false +} + func ResetModelRatio(c *gin.Context) { defaultStr := ratio_setting.DefaultModelRatio2JSONString() err := model.UpdateOption("ModelRatio", defaultStr) diff --git a/controller/user.go b/controller/user.go index 982329cec0de..79ee42e4a851 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -10,7 +11,9 @@ import ( "one-api/logger" "one-api/model" "one-api/setting" + "one-api/setting/operation_setting" "strconv" + "time" "strings" "sync" @@ -25,6 +28,61 @@ type LoginRequest struct { Password string `json:"password"` } +// validateAvatar 验证头像数据 +func validateAvatar(avatarData string) error { + if avatarData == "" { + return nil // 允许空头像 + } + + // 检查是否是有效的base64数据 + if !strings.HasPrefix(avatarData, "data:image/") { + return fmt.Errorf("头像必须是有效的图片格式") + } + + // 提取base64数据部分 + parts := strings.Split(avatarData, ",") + if len(parts) != 2 { + return fmt.Errorf("头像数据格式无效") + } + + // 检查MIME类型 + mimeType := parts[0] + allowedTypes := []string{ + "data:image/jpeg;base64", + "data:image/jpg;base64", + "data:image/png;base64", + "data:image/gif;base64", + "data:image/webp;base64", + } + + isValidType := false + for _, allowedType := range allowedTypes { + if mimeType == allowedType { + isValidType = true + break + } + } + + if !isValidType { + return fmt.Errorf("不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP") + } + + // 解码base64数据检查大小 + base64Data := parts[1] + decodedData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + return fmt.Errorf("头像数据解码失败") + } + + // 检查文件大小(2MB限制) + const maxSize = 2 * 1024 * 1024 // 2MB + if len(decodedData) > maxSize { + return fmt.Errorf("头像文件大小不能超过2MB") + } + + return nil +} + func Login(c *gin.Context) { if !common.PasswordLoginEnabled { c.JSON(http.StatusOK, gin.H{ @@ -115,6 +173,7 @@ func setupLogin(user *model.User, c *gin.Context) { Role: user.Role, Status: user.Status, Group: user.Group, + Avatar: user.Avatar, } c.JSON(http.StatusOK, gin.H{ "message": "", @@ -375,6 +434,16 @@ type TransferAffQuotaRequest struct { } func TransferAffQuota(c *gin.Context) { + // 检查邀请功能是否启用 + generalSetting := operation_setting.GetGeneralSetting() + if !generalSetting.InvitationEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "邀请功能已被管理员禁用", + }) + return + } + id := c.GetInt("id") user, err := model.GetUserById(id, true) if err != nil { @@ -401,6 +470,16 @@ func TransferAffQuota(c *gin.Context) { } func GetAffCode(c *gin.Context) { + // 检查邀请功能是否启用 + generalSetting := operation_setting.GetGeneralSetting() + if !generalSetting.InvitationEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "邀请功能已被管理员禁用", + }) + return + } + id := c.GetInt("id") user, err := model.GetUserById(id, true) if err != nil { @@ -436,13 +515,45 @@ 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 = "" + // 完全移除头像数据,头像通过专用端点获取 + user.Avatar = "" + // 计算用户权限信息 permissions := calculateUserPermissions(userRole) // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() - // 构建响应数据,包含用户信息和权限 + // 计算系统允许的最大权限范围 + systemSidebarConfig := calculateFinalSidebarConfig(userRole, userSetting) + + // 提取并过滤用户的侧边栏偏好设置,确保与系统权限一致 + var userSidebarModules interface{} + if userSetting.SidebarModules != "" { + var userSidebarModulesMap map[string]interface{} + if err := json.Unmarshal([]byte(userSetting.SidebarModules), &userSidebarModulesMap); err == nil { + // 基于系统权限过滤用户偏好 + filteredUserModules := filterUserModulesBySystemConfig(userSidebarModulesMap, systemSidebarConfig) + userSidebarModules = filteredUserModules + } else { + userSidebarModules = userSetting.SidebarModules + } + } else { + userSidebarModules = map[string]interface{}{} + } + + // 清理用户设置中的sidebar_modules,确保与最终配置一致 + cleanedSetting := cleanUserSettingForResponse(user.Setting, systemSidebarConfig) + + // 计算最终的显示配置(系统权限 ∩ 用户偏好) + finalSidebarConfig := calculateFinalDisplayConfig(systemSidebarConfig, userSidebarModules) + + // 精简权限信息,只保留必要的权限标识 + simplifiedPermissions := map[string]interface{}{ + "sidebar_settings": permissions["sidebar_settings"], // 是否有侧边栏设置权限 + } + + // 构建响应数据,包含用户信息和精简的配置 responseData := map[string]interface{}{ "id": user.Id, "username": user.Username, @@ -460,10 +571,11 @@ func GetSelf(c *gin.Context) { "aff_history_quota": user.AffHistoryQuota, "inviter_id": user.InviterId, "linux_do_id": user.LinuxDOId, - "setting": user.Setting, + "setting": cleanedSetting, // 完整用户设置(保持兼容性) "stripe_customer": user.StripeCustomer, - "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 - "permissions": permissions, // 新增权限字段 + "avatar": user.Avatar, + "sidebar_config": finalSidebarConfig, // 最终的侧边栏配置 + "permissions": simplifiedPermissions, // 精简的权限信息 } c.JSON(http.StatusOK, gin.H{ @@ -502,66 +614,529 @@ func calculateUserPermissions(userRole int) map[string]interface{} { return permissions } -// 根据用户角色生成默认的边栏配置 -func generateDefaultSidebarConfig(userRole int) string { - defaultConfig := map[string]interface{}{} - - // 聊天区域 - 所有用户都可以访问 - defaultConfig["chat"] = map[string]interface{}{ - "enabled": true, - "playground": true, - "chat": true, +// 计算最终的侧边栏配置(系统配置 + 权限过滤,不包含用户偏好) +func calculateFinalSidebarConfig(userRole int, userSetting dto.UserSetting) map[string]interface{} { + // 1. 获取系统的侧边栏管理配置 + common.OptionMapRWMutex.RLock() + sidebarAdminConfigRaw := common.OptionMap["SidebarModulesAdmin"] + common.OptionMapRWMutex.RUnlock() + + // 2. 解析系统配置 + var systemConfig map[string]interface{} + if sidebarAdminConfigRaw != "" { + if err := json.Unmarshal([]byte(sidebarAdminConfigRaw), &systemConfig); err != nil { + // 解析失败时使用默认配置 + systemConfig = getDefaultSystemConfig() + } + } else { + systemConfig = getDefaultSystemConfig() } - // 控制台区域 - 所有用户都可以访问 - defaultConfig["console"] = map[string]interface{}{ - "enabled": true, - "detail": true, - "token": true, - "log": true, - "midjourney": true, - "task": true, - } + // 3. 不再考虑用户个人偏好,sidebar_config只反映系统允许的最大权限范围 + + // 4. 计算最终配置 + finalConfig := map[string]interface{}{} + + // 遍历系统配置的所有区域 + for sectionKey, sectionValue := range systemConfig { + sectionObj, ok := sectionValue.(map[string]interface{}) + if !ok { + continue + } + + // 检查用户是否有权限访问这个区域 + if !hasUserPermissionForSection(userRole, sectionKey) { + continue + } + + // 检查系统是否启用了这个区域 + sectionEnabled := true + if enabled, hasEnabled := sectionObj["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + sectionEnabled = enabledBool + } + } + + if !sectionEnabled { + continue + } + + // 计算区域的最终配置(只考虑系统配置和用户权限,不考虑用户偏好) + sectionConfig := map[string]interface{}{} + + // 区域级别的enabled状态:只要系统启用就为true + sectionConfig["enabled"] = sectionEnabled + + // 处理区域内的各个模块 + for moduleKey, moduleValue := range sectionObj { + if moduleKey == "enabled" { + continue + } - // 个人中心区域 - 所有用户都可以访问 - defaultConfig["personal"] = map[string]interface{}{ - "enabled": true, - "topup": true, - "personal": true, + // 检查用户是否有权限访问这个模块 + modulePath := sectionKey + "." + moduleKey + if !hasUserPermissionForModule(userRole, modulePath) { + sectionConfig[moduleKey] = false + continue + } + + // 处理嵌套的模块配置(如 admin.user) + switch v := moduleValue.(type) { + case bool: + // 简单的布尔值模块 + systemModuleEnabled := v + finalModuleEnabled := systemModuleEnabled && sectionConfig["enabled"].(bool) + sectionConfig[moduleKey] = finalModuleEnabled + case map[string]interface{}: + // 嵌套的对象模块(如 admin.user 包含 enabled 和 groupManagement) + nestedModuleConfig := map[string]interface{}{} + + // 检查嵌套模块的enabled状态 + nestedEnabled := true + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + nestedEnabled = enabledBool + } + } + + // 最终的enabled状态 + finalNestedEnabled := nestedEnabled && sectionConfig["enabled"].(bool) + nestedModuleConfig["enabled"] = finalNestedEnabled + + // 处理嵌套模块的子功能 + for subModuleKey, subModuleValue := range v { + if subModuleKey == "enabled" { + continue + } + + // 检查用户是否有权限访问这个子功能 + subModulePath := sectionKey + "." + moduleKey + "." + subModuleKey + if !hasUserPermissionForModule(userRole, subModulePath) { + nestedModuleConfig[subModuleKey] = false + continue + } + + // 检查系统是否启用了这个子功能 + subModuleEnabled := true + if subModuleBool, ok := subModuleValue.(bool); ok { + subModuleEnabled = subModuleBool + } + + // 最终状态:系统启用 && 用户权限允许 && 父模块启用 + finalSubModuleEnabled := subModuleEnabled && finalNestedEnabled + nestedModuleConfig[subModuleKey] = finalSubModuleEnabled + } + + sectionConfig[moduleKey] = nestedModuleConfig + default: + // 其他类型,直接设置为false + sectionConfig[moduleKey] = false + } + } + + finalConfig[sectionKey] = sectionConfig } - // 管理员区域 - 根据角色决定 - if userRole == common.RoleAdminUser { - // 管理员可以访问管理员区域,但不能访问系统设置 - defaultConfig["admin"] = map[string]interface{}{ + return finalConfig +} + +// 获取默认的系统配置 +func getDefaultSystemConfig() map[string]interface{} { + return map[string]interface{}{ + "chat": 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{}{ + "playground": true, + "chat": true, + }, + "console": map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + }, + "personal": map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + }, + "admin": map[string]interface{}{ "enabled": true, "channel": true, "models": true, "redemption": true, - "user": true, - "setting": true, + "user": map[string]interface{}{ + "enabled": true, + "groupManagement": true, // 默认启用分组管理 + }, + "setting": true, + }, + } +} + +// 检查用户是否有权限访问指定区域 +func hasUserPermissionForSection(userRole int, sectionKey string) bool { + // 普通用户不能访问管理员区域 + if userRole < common.RoleAdminUser && sectionKey == "admin" { + return false + } + return true +} + +// 检查用户是否有权限访问指定模块 +func hasUserPermissionForModule(userRole int, modulePath string) bool { + // 数据看板始终允许访问 + if modulePath == "console.detail" { + return true + } + + // 管理员不能访问系统设置 + if userRole == common.RoleAdminUser && modulePath == "admin.setting" { + return false + } + + // 处理嵌套的模块路径(如 admin.user.groupManagement) + pathParts := strings.Split(modulePath, ".") + if len(pathParts) >= 2 { + sectionKey := pathParts[0] + + // 普通用户不能访问管理员区域的任何模块 + if userRole < common.RoleAdminUser && sectionKey == "admin" { + return false + } + + // 对于三层路径(如 admin.user.groupManagement),检查特殊权限 + if len(pathParts) == 3 && sectionKey == "admin" && pathParts[1] == "user" && pathParts[2] == "groupManagement" { + // 分组管理功能:管理员和超级管理员都可以访问 + return userRole >= common.RoleAdminUser } } - // 普通用户不包含admin区域 - // 转换为JSON字符串 - configBytes, err := json.Marshal(defaultConfig) - if err != nil { - common.SysLog("生成默认边栏配置失败: " + err.Error()) + return true +} + +// 清理用户设置,添加系统权限信息供个人设置页面使用 +func cleanUserSettingForResponse(originalSetting string, systemSidebarConfig map[string]interface{}) string { + if originalSetting == "" { return "" } - return string(configBytes) + // 解析原始设置 + var userSetting dto.UserSetting + if err := json.Unmarshal([]byte(originalSetting), &userSetting); err != nil { + // 解析失败,返回原始设置 + return originalSetting + } + + // 如果没有sidebar_modules配置,直接返回 + if userSetting.SidebarModules == "" { + return originalSetting + } + + // 解析用户的sidebar_modules配置 + var userSidebarModules map[string]interface{} + if err := json.Unmarshal([]byte(userSetting.SidebarModules), &userSidebarModules); err != nil { + // 解析失败,返回原始设置 + return originalSetting + } + + // 基于系统配置过滤用户的sidebar_modules,同时保留系统权限信息 + filteredSidebarModules := map[string]interface{}{} + for sectionKey, sectionValue := range systemSidebarConfig { + sectionObj, ok := sectionValue.(map[string]interface{}) + if !ok { + continue + } + + // 检查系统是否允许这个区域 + systemSectionEnabled, hasEnabled := sectionObj["enabled"] + if !hasEnabled || systemSectionEnabled != true { + continue + } + + // 获取用户对这个区域的配置 + userSection := map[string]interface{}{} + if userSidebarModules[sectionKey] != nil { + if userSectionObj, ok := userSidebarModules[sectionKey].(map[string]interface{}); ok { + userSection = userSectionObj + } + } + + // 构建过滤后的区域配置 + filteredSection := map[string]interface{}{ + "enabled": userSection["enabled"], // 保持用户的enabled偏好 + } + + // 只保留最终配置中存在的模块(支持布尔与嵌套对象) + for moduleKey, moduleValue := range sectionObj { + if moduleKey == "enabled" { + continue + } + + // 判断系统是否允许该模块 + systemAllows := false + switch v := moduleValue.(type) { + case bool: + systemAllows = v + case map[string]interface{}: + // 嵌套对象,检查其enabled状态,缺省视为true + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + systemAllows = enabledBool + } else { + systemAllows = true + } + } else { + systemAllows = true + } + default: + systemAllows = false + } + + if systemAllows { + // 保持用户对这个模块的偏好(布尔或对象),若未设置则默认启用 + if userModuleValue, exists := userSection[moduleKey]; exists { + filteredSection[moduleKey] = userModuleValue + } else { + filteredSection[moduleKey] = true // 默认启用 + } + } + } + + filteredSidebarModules[sectionKey] = filteredSection + } + + // 更新用户设置中的sidebar_modules + filteredSidebarModulesJSON, err := json.Marshal(filteredSidebarModules) + if err != nil { + // 序列化失败,返回原始设置 + return originalSetting + } + + userSetting.SidebarModules = string(filteredSidebarModulesJSON) + + // 添加系统权限信息供个人设置页面使用 + systemConfigJSON, err := json.Marshal(systemSidebarConfig) + if err == nil { + // 创建一个扩展的用户设置结构 + extendedSetting := map[string]interface{}{ + "sidebar_modules": userSetting.SidebarModules, + "sidebar_system_config": string(systemConfigJSON), // 系统权限信息 + } + + // 添加其他用户设置字段(如果有的话) + var originalSettingMap map[string]interface{} + if err := json.Unmarshal([]byte(originalSetting), &originalSettingMap); err == nil { + for key, value := range originalSettingMap { + if key != "sidebar_modules" && key != "sidebar_system_config" { + extendedSetting[key] = value + } + } + } + + // 序列化扩展的设置 + if extendedSettingJSON, err := json.Marshal(extendedSetting); err == nil { + return string(extendedSettingJSON) + } + } + + // 如果添加系统配置失败,使用原有逻辑 + cleanedSettingJSON, err := json.Marshal(userSetting) + if err != nil { + return originalSetting + } + + return string(cleanedSettingJSON) +} + +// 基于系统权限过滤用户偏好设置 +func filterUserModulesBySystemConfig(userModules map[string]interface{}, systemConfig map[string]interface{}) map[string]interface{} { + filteredModules := map[string]interface{}{} + + // 只保留系统允许的区域和模块 + for sectionKey, sectionValue := range systemConfig { + systemSection, ok := sectionValue.(map[string]interface{}) + if !ok || systemSection["enabled"] != true { + continue + } + + // 获取用户对这个区域的偏好 + userSection := map[string]interface{}{} + if userModules[sectionKey] != nil { + if userSectionObj, ok := userModules[sectionKey].(map[string]interface{}); ok { + userSection = userSectionObj + } + } + + // 构建过滤后的区域配置 + filteredSection := map[string]interface{}{ + "enabled": userSection["enabled"], // 保持用户的enabled偏好 + } + + // 只保留系统允许的模块(同时支持布尔模块与嵌套对象模块) + for moduleKey, moduleValue := range systemSection { + if moduleKey == "enabled" { + continue + } + + // 判断系统是否允许该模块 + systemAllows := false + switch v := moduleValue.(type) { + case bool: + systemAllows = v + case map[string]interface{}: + // 嵌套对象,检查其enabled状态,缺省视为true + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + systemAllows = enabledBool + } else { + systemAllows = true + } + } else { + systemAllows = true + } + default: + systemAllows = false + } + + if systemAllows { + // 保持用户对这个模块的偏好(支持布尔或对象),若未设置则默认启用 + if userModuleValue, exists := userSection[moduleKey]; exists { + filteredSection[moduleKey] = userModuleValue + } else { + filteredSection[moduleKey] = true // 默认启用 + } + } + } + + filteredModules[sectionKey] = filteredSection + } + + return filteredModules +} + +// 计算最终的显示配置(系统权限 ∩ 用户偏好) +func calculateFinalDisplayConfig(systemConfig map[string]interface{}, userModules interface{}) map[string]interface{} { + finalConfig := map[string]interface{}{} + + // 解析用户偏好设置 + var userPreferences map[string]interface{} + switch v := userModules.(type) { + case map[string]interface{}: + userPreferences = v + case string: + if err := json.Unmarshal([]byte(v), &userPreferences); err != nil { + userPreferences = map[string]interface{}{} + } + default: + userPreferences = map[string]interface{}{} + } + + // 遍历系统允许的所有区域 + for sectionKey, sectionValue := range systemConfig { + systemSection, ok := sectionValue.(map[string]interface{}) + if !ok || systemSection["enabled"] != true { + continue + } + + // 获取用户对这个区域的偏好 + userSection := map[string]interface{}{} + if userPreferences[sectionKey] != nil { + if userSectionObj, ok := userPreferences[sectionKey].(map[string]interface{}); ok { + userSection = userSectionObj + } + } + + // 计算区域的最终配置 + sectionConfig := map[string]interface{}{} + + // 区域级别:用户可以关闭系统允许的区域 + userSectionEnabled := userSection["enabled"] != false + sectionConfig["enabled"] = userSectionEnabled + + // 处理区域内的模块 + for moduleKey, moduleValue := range systemSection { + if moduleKey == "enabled" { + continue + } + + // 检查系统是否允许这个模块 + var systemModuleEnabled bool + switch v := moduleValue.(type) { + case bool: + systemModuleEnabled = v + case map[string]interface{}: + // 对于嵌套对象,检查其enabled状态 + if enabled, hasEnabled := v["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + systemModuleEnabled = enabledBool + } else { + systemModuleEnabled = true // 默认启用 + } + } else { + systemModuleEnabled = true // 没有enabled字段时默认启用 + } + default: + systemModuleEnabled = false + } + + if !systemModuleEnabled { + sectionConfig[moduleKey] = false + continue + } + + // 对于嵌套对象,需要合并系统配置和用户偏好 + if nestedObj, isNested := moduleValue.(map[string]interface{}); isNested { + // 获取用户对这个嵌套对象的偏好 + userNestedObj := map[string]interface{}{} + if userSection[moduleKey] != nil { + if userNestedMap, ok := userSection[moduleKey].(map[string]interface{}); ok { + userNestedObj = userNestedMap + } + } + + // 计算有效的enabled:支持用户以布尔值直接覆盖嵌套对象(个人设置场景) + var effectiveEnabled interface{} + if userBool, ok := userSection[moduleKey].(bool); ok { + effectiveEnabled = userBool + } else if ue, exists := userNestedObj["enabled"]; exists { + effectiveEnabled = ue + } else if sysEnabled, has := nestedObj["enabled"]; has { + effectiveEnabled = sysEnabled + } else { + effectiveEnabled = true + } + + // 合并系统配置和用户偏好 + finalNestedObj := make(map[string]interface{}) + for k, v := range nestedObj { + if k == "enabled" { + finalNestedObj[k] = effectiveEnabled + } else { + // 其他字段保持系统配置 + finalNestedObj[k] = v + } + } + + // 如果区域被禁用,强制将嵌套对象的enabled设置为false + if !userSectionEnabled { + finalNestedObj["enabled"] = false + } + + sectionConfig[moduleKey] = finalNestedObj + } else { + // 简单布尔值模块,用户可以关闭系统允许的模块 + userModuleEnabled := userSection[moduleKey] != false + // 最终状态:系统允许 && 用户偏好 && 区域启用 + sectionConfig[moduleKey] = systemModuleEnabled && userModuleEnabled && userSectionEnabled + } + } + + finalConfig[sectionKey] = sectionConfig + } + + return finalConfig } func GetUserModels(c *gin.Context) { @@ -649,6 +1224,54 @@ func UpdateUser(c *gin.Context) { return } +func GetUserAvatar(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 检查是否强制获取头像(用于上传后刷新) + forceRefresh := c.Query("force_refresh") == "true" + + // 检查会话中是否已获取过头像 + sessionId := c.GetHeader("X-Session-ID") + if sessionId == "" { + // 如果没有会话ID,生成一个 + sessionId = fmt.Sprintf("session_%d_%d", id, time.Now().Unix()) + } + + // sessionKey := fmt.Sprintf("avatar_session_%s", sessionId) // 保留用于未来扩展 + + // 如果不是强制刷新且会话中已获取过,返回空响应 + if !forceRefresh { + if sessionValue := c.GetHeader("X-Avatar-Fetched"); sessionValue == "true" { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "avatar_cached", + "data": gin.H{ + "avatar": "", + "cached": true, + }, + }) + return + } + } + + // 返回头像数据 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "avatar": user.Avatar, + "cached": false, + "session_id": sessionId, + }, + }) + return +} + func UpdateSelf(c *gin.Context) { var requestData map[string]interface{} err := json.NewDecoder(c.Request.Body).Decode(&requestData) @@ -694,6 +1317,48 @@ func UpdateSelf(c *gin.Context) { return } + // 检查是否是纯头像更新请求 + if len(requestData) == 1 { + if avatarData, exists := requestData["avatar"]; exists { + userId := c.GetInt("id") + + // 验证头像数据 + avatarStr, ok := avatarData.(string) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "头像数据格式无效", + }) + return + } + + if err := validateAvatar(avatarStr); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 直接更新头像字段 + user := model.User{ + Id: userId, + Avatar: avatarStr, + } + + if err := user.UpdateAvatar(); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return + } + } + // 原有的用户信息更新逻辑 var user model.User requestDataBytes, err := json.Marshal(requestData) @@ -724,20 +1389,35 @@ func UpdateSelf(c *gin.Context) { return } + // 验证头像数据 + if err := validateAvatar(user.Avatar); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + cleanUser := model.User{ Id: c.GetInt("id"), Username: user.Username, Password: user.Password, DisplayName: user.DisplayName, + Avatar: user.Avatar, } if user.Password == "$I_LOVE_U" { user.Password = "" // rollback to what it should be cleanUser.Password = "" } - updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) - if err != nil { - common.ApiError(c, err) - return + + // 只有当明确提供了 original_password 或者要更新密码时才进行密码验证 + var updatePassword bool + if _, hasOriginalPassword := requestData["original_password"]; hasOriginalPassword || user.Password != "" { + updatePassword, err = checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) + if err != nil { + common.ApiError(c, err) + return + } } if err := cleanUser.Update(updatePassword); err != nil { common.ApiError(c, err) @@ -842,6 +1522,10 @@ func CreateUser(c *gin.Context) { if user.DisplayName == "" { user.DisplayName = user.Username } + // 如果没有指定分组,设置为默认分组 + if user.Group == "" { + user.Group = "default" + } myRole := c.GetInt("role") if user.Role >= myRole { c.JSON(http.StatusOK, gin.H{ @@ -856,6 +1540,7 @@ func CreateUser(c *gin.Context) { Password: user.Password, DisplayName: user.DisplayName, Role: user.Role, // 保持管理员设置的角色 + Group: user.Group, // 保持管理员设置的分组 } if err := cleanUser.Insert(0); err != nil { common.ApiError(c, err) @@ -949,6 +1634,15 @@ func ManageUser(c *gin.Context) { return } user.Role = common.RoleAdminUser + + // 同步更新用户的sidebar_modules配置 + currentSetting := user.GetSetting() + newSidebarConfig := model.GenerateDefaultSidebarConfigForRole(user.Role) + if newSidebarConfig != "" { + currentSetting.SidebarModules = newSidebarConfig + user.SetSetting(currentSetting) + common.SysLog(fmt.Sprintf("用户 %s 提升为管理员,已同步更新边栏配置", user.Username)) + } case "demote": if user.Role == common.RoleRootUser { c.JSON(http.StatusOK, gin.H{ @@ -965,6 +1659,15 @@ func ManageUser(c *gin.Context) { return } user.Role = common.RoleCommonUser + + // 同步更新用户的sidebar_modules配置 + currentSetting := user.GetSetting() + newSidebarConfig := model.GenerateDefaultSidebarConfigForRole(user.Role) + if newSidebarConfig != "" { + currentSetting.SidebarModules = newSidebarConfig + user.SetSetting(currentSetting) + common.SysLog(fmt.Sprintf("用户 %s 降级为普通用户,已同步更新边栏配置", user.Username)) + } } if err := user.Update(false); err != nil { diff --git a/controller/user_group.go b/controller/user_group.go new file mode 100644 index 000000000000..5c28b8c5475a --- /dev/null +++ b/controller/user_group.go @@ -0,0 +1,430 @@ +package controller + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "one-api/common" + "one-api/model" + "one-api/service" + "one-api/setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +// GetAllUserGroups 获取用户分组列表 +func GetAllUserGroups(c *gin.Context) { + groups, err := model.GetAllUserGroups() + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, groups) +} + +// isReservedGroup reports whether the name is one of the system reserved groups. +func isReservedGroup(name string) bool { + switch strings.ToLower(name) { + case "default", "vip", "svip": + return true + default: + return false + } +} + +// CreateUserGroup 创建新的用户分组 +func CreateUserGroup(c *gin.Context) { + var g model.UserGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + g.Name = strings.TrimSpace(g.Name) + if g.Name == "" { + common.ApiErrorMsg(c, "分组名称不能为空") + return + } + // 禁止使用系统保留分组名 + if isReservedGroup(strings.ToLower(g.Name)) { + common.ApiErrorMsg(c, "不能使用系统保留分组名:default、vip、svip") + return + } + if g.Ratio < 0 { + common.ApiErrorMsg(c, "分组倍率不能小于0") + return + } + if g.Ratio == 0 { + g.Ratio = 1.0 // 默认倍率为1.0 + } + + // 创建前检查名称 + if dup, err := model.IsUserGroupNameDuplicated(0, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "分组名称已存在") + return + } + + if err := g.Insert(); err != nil { + common.ApiError(c, err) + return + } + + // 同步到内存 + if err := service.SyncUserGroupsToMemory(); err != nil { + common.SysLog("同步用户分组到内存失败: " + err.Error()) + } + + common.ApiSuccess(c, &g) +} + +// UpdateUserGroup 更新用户分组 +func UpdateUserGroup(c *gin.Context) { + var g model.UserGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Id == 0 { + common.ApiErrorMsg(c, "缺少分组 ID") + return + } + g.Name = strings.TrimSpace(g.Name) + if g.Name == "" { + common.ApiErrorMsg(c, "分组名称不能为空") + return + } + if g.Ratio < 0 { + common.ApiErrorMsg(c, "分组倍率不能小于0") + return + } + if g.Ratio == 0 { + g.Ratio = 1.0 + } + // 获取原分组信息 + oldGroup, err := model.GetUserGroupById(g.Id) + if err != nil { + common.ApiError(c, err) + return + } + + // 名称冲突检查 + if dup, err := model.IsUserGroupNameDuplicated(g.Id, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "分组名称已存在") + return + } + + // 使用事务确保分组更新和用户更新的数据一致性 + tx := model.DB.Begin() + if tx.Error != nil { + common.ApiError(c, tx.Error) + return + } + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 在事务中更新分组 + if err := g.UpdateTx(tx); err != nil { + tx.Rollback() + common.ApiError(c, err) + return + } + + // 如果名称发生变化,需要在同一事务中更新用户数据 + if oldGroup.Name != g.Name { + if isReservedGroup(strings.ToLower(g.Name)) { + tx.Rollback() + common.ApiErrorMsg(c, "不能将分组名称修改为系统保留分组名") + return + } + common.SysLog(fmt.Sprintf("检测到分组名称变化: '%s' -> '%s'", oldGroup.Name, g.Name)) + + // 禁止修改系统保留分组名 + if isReservedGroup(strings.ToLower(oldGroup.Name)) { + tx.Rollback() + common.ApiErrorMsg(c, "不能修改系统保留分组名称") + return + } + + // 在同一事务中更新所有使用旧分组名的用户 + result := tx.Model(&model.User{}). + Where("`group` = ?", oldGroup.Name). + Update("group", g.Name) + if result.Error != nil { + tx.Rollback() + common.SysLog("更新用户分组名称失败: " + result.Error.Error()) + common.ApiErrorMsg(c, "更新用户分组名称失败: "+result.Error.Error()) + return + } + common.SysLog(fmt.Sprintf("在事务中成功更新 %d 个用户的分组名称", result.RowsAffected)) + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + common.SysLog("提交分组更新事务失败: " + err.Error()) + common.ApiError(c, err) + return + } + + // 事务提交成功后,同步到内存 + if err := service.SyncUserGroupsToMemory(); err != nil { + common.SysLog("同步用户分组到内存失败: " + err.Error()) + } + + common.ApiSuccess(c, &g) +} + +// DeleteUserGroup 删除用户分组 +func DeleteUserGroup(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "无效的分组 ID") + return + } + + group, err := model.GetUserGroupById(id) + if err != nil { + common.ApiError(c, err) + return + } + + // 检查是否有用户正在使用此分组 + if inUse, err := model.IsUserGroupInUse(group.Name); err != nil { + common.ApiError(c, err) + return + } else if inUse { + common.ApiErrorMsg(c, "该分组正在被用户使用,无法删除") + return + } + + // 不允许删除默认分组 + if isReservedGroup(group.Name) { + common.ApiErrorMsg(c, "不能删除系统默认分组") + return + } + + if err := group.Delete(); err != nil { + common.ApiError(c, err) + return + } + + // 同步到内存 + if err := service.SyncUserGroupsToMemory(); err != nil { + common.SysLog("同步用户分组到内存失败: " + err.Error()) + } + + common.ApiSuccess(c, nil) +} + +// syncGroupToRatioSetting 同步分组到倍率设置 +func syncGroupToRatioSetting(groupName string, ratio float64, add bool) error { + groupRatio := ratio_setting.GetGroupRatioCopy() + + if add { + groupRatio[groupName] = ratio + } else { + delete(groupRatio, groupName) + } + + jsonBytes, err := json.Marshal(groupRatio) + if err != nil { + return err + } + + // 更新到数据库 + if err := model.UpdateOption("GroupRatio", string(jsonBytes)); err != nil { + return err + } + + // 更新内存中的设置 + return ratio_setting.UpdateGroupRatioByJSONString(string(jsonBytes)) +} + +// syncGroupToUserUsableGroups 同步分组到用户可选分组 +func syncGroupToUserUsableGroups(groupName, description string, add bool) error { + userUsableGroups := setting.GetUserUsableGroupsCopy() + + if add { + if description == "" { + description = groupName + "分组" + } + userUsableGroups[groupName] = description + } else { + delete(userUsableGroups, groupName) + } + + jsonBytes, err := json.Marshal(userUsableGroups) + if err != nil { + return err + } + + // 更新到数据库 + if err := model.UpdateOption("UserUsableGroups", string(jsonBytes)); err != nil { + return err + } + + // 更新内存中的设置 + return setting.UpdateUserUsableGroupsByJSONString(string(jsonBytes)) +} + +// syncGroupToTopupRatio 同步分组到充值倍率设置 +func syncGroupToTopupRatio(groupName string, ratio float64, add bool) error { + // 获取当前充值分组倍率的副本(线程安全) + topupGroupRatio := common.GetTopupGroupRatioCopy() + if add { + topupGroupRatio[groupName] = ratio + } else { + delete(topupGroupRatio, groupName) + } + + jsonBytes, err := json.Marshal(topupGroupRatio) + if err != nil { + return err + } + + // 更新到数据库 + if err := model.UpdateOption("TopupGroupRatio", string(jsonBytes)); err != nil { + return err + } + + // 更新内存中的设置 + return common.UpdateTopupGroupRatioByJSONString(string(jsonBytes)) +} + +// MigrateUserGroupData 迁移用户分组数据 +func MigrateUserGroupData(c *gin.Context) { + if err := service.MigrateUserGroupsFromOptions(); err != nil { + common.ApiError(c, err) + return + } + + // 同步到内存 + if err := service.SyncUserGroupsToMemory(); err != nil { + common.SysLog("同步用户分组到内存失败: " + err.Error()) + } + + common.ApiSuccess(c, "用户分组数据迁移完成") +} + +// GetUserGroupsAsOptions 获取用户分组数据(以 options 格式返回) +func GetUserGroupsAsOptions(c *gin.Context) { + options, err := service.GetUserGroupsAsOptions() + if err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, options) +} + +// BatchUpdateUserGroups 批量更新用户分组 +func BatchUpdateUserGroups(c *gin.Context) { + var req struct { + GroupRatio string `json:"GroupRatio"` + UserUsableGroups string `json:"UserUsableGroups"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + // 解析分组倍率 + var groupRatio map[string]float64 + if req.GroupRatio != "" { + if err := json.Unmarshal([]byte(req.GroupRatio), &groupRatio); err != nil { + common.ApiErrorMsg(c, "分组倍率格式错误: "+err.Error()) + return + } + } + + // 解析用户可选分组 + var userUsableGroups map[string]string + if req.UserUsableGroups != "" { + if err := json.Unmarshal([]byte(req.UserUsableGroups), &userUsableGroups); err != nil { + common.ApiErrorMsg(c, "用户可选分组格式错误: "+err.Error()) + return + } + } + + // 合并所有分组名称 + allGroups := make(map[string]bool) + for name := range groupRatio { + allGroups[name] = true + } + for name := range userUsableGroups { + allGroups[name] = true + } + + // 批量更新分组 + updatedCount := 0 + for groupName := range allGroups { + // 获取现有分组 + existingGroup, err := model.GetUserGroupByName(groupName) + if err != nil && err.Error() != "record not found" { + common.SysLog("获取分组 " + groupName + " 时出错: " + err.Error()) + continue + } + + ratio := groupRatio[groupName] + if ratio == 0 { + ratio = 1.0 // 默认倍率 + } + + description := userUsableGroups[groupName] + if description == "" { + description = groupName + "分组" + } + + if existingGroup == nil { + // 创建新分组 + newGroup := &model.UserGroup{ + Name: groupName, + Description: description, + Ratio: ratio, + } + if err := newGroup.Insert(); err != nil { + common.SysLog("创建分组 " + groupName + " 失败: " + err.Error()) + continue + } + updatedCount++ + } else { + // 更新现有分组 + needUpdate := false + if existingGroup.Ratio != ratio { + existingGroup.Ratio = ratio + needUpdate = true + } + if existingGroup.Description != description { + existingGroup.Description = description + needUpdate = true + } + + if needUpdate { + if err := existingGroup.Update(); err != nil { + common.SysLog("更新分组 " + groupName + " 失败: " + err.Error()) + continue + } + updatedCount++ + } + } + } + + // 同步到内存 + if err := service.SyncUserGroupsToMemory(); err != nil { + common.SysLog("同步用户分组到内存失败: " + err.Error()) + } + + common.ApiSuccess(c, fmt.Sprintf("批量更新完成,处理了 %d 个分组", updatedCount)) +} diff --git a/middleware/auth.go b/middleware/auth.go index 25caf50d9be0..b2fc104f68a4 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "encoding/json" "fmt" "net/http" "one-api/common" @@ -175,6 +176,172 @@ func WssAuth(c *gin.Context) { } +// ModuleAuth 检查用户是否有权限访问特定功能模块 +func ModuleAuth(modulePath string) gin.HandlerFunc { + return func(c *gin.Context) { + // 优先从上游鉴权放入的上下文读取 + userRole := c.GetInt("role") + userId := c.GetInt("id") + if userRole == 0 || userId == 0 { + // 兼容旧流程:再从 session 兜底 + sess := sessions.Default(c) + if v, ok := sess.Get("role").(int); ok { + userRole = v + } + if v, ok := sess.Get("id").(int); ok { + userId = v + } + } + // 如果用户未登录,先进行基础认证 + if userRole == 0 || userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录,无权访问", + }) + c.Abort() + return + } + + // 超级管理员始终允许访问所有功能 + if userRole >= common.RoleRootUser { + c.Next() + return + } + + // 检查用户是否有权限访问指定模块 + if !hasModulePermission(userRole, userId, modulePath) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "无权访问此功能模块", + }) + c.Abort() + return + } + + c.Next() + } +} + +// hasModulePermission 检查用户是否有权限访问指定模块 +func hasModulePermission(userRole int, userId int, modulePath string) bool { + // 普通用户只能访问基础功能 + if userRole < common.RoleAdminUser { + return isUserModuleAllowed(modulePath) + } + + // 管理员需要检查侧边栏管理配置 + if userRole >= common.RoleAdminUser && userRole < common.RoleRootUser { + return isAdminModuleAllowed(modulePath) + } + + return true +} + +// isUserModuleAllowed 检查普通用户是否允许访问指定模块 +func isUserModuleAllowed(modulePath string) bool { + // 数据看板始终允许访问,不受控制台区域开关影响 + if modulePath == "console.detail" { + return true + } + + // 普通用户允许访问的模块列表 + allowedModules := map[string]bool{ + "console.detail": true, + "console.token": true, + "console.log": true, + "console.midjourney": true, + "console.task": true, + "personal.topup": true, + "personal.personal": true, + "chat.playground": true, + "chat.chat": true, + } + + return allowedModules[modulePath] +} + +// isAdminModuleAllowed 检查管理员是否允许访问指定模块 +func isAdminModuleAllowed(modulePath string) bool { + // 数据看板始终允许访问,不受控制台区域开关影响 + if modulePath == "console.detail" { + return true + } + + // 获取侧边栏管理配置 + common.OptionMapRWMutex.RLock() + sidebarConfig, exists := common.OptionMap["SidebarModulesAdmin"] + common.OptionMapRWMutex.RUnlock() + + if !exists || sidebarConfig == "" { + // 如果没有配置,默认允许管理员访问所有功能(除了系统设置) + if modulePath == "admin.setting" { + return false + } + return true + } + + // 解析配置 + var config map[string]interface{} + if err := json.Unmarshal([]byte(sidebarConfig), &config); err != nil { + // 解析失败时采用安全优先策略,拒绝访问 + common.SysLog("解析侧边栏配置失败: " + err.Error()) + return false + } + + // 检查嵌套权限 + return checkNestedPermission(config, modulePath) +} + +// checkNestedPermission 检查嵌套权限路径 +func checkNestedPermission(config map[string]interface{}, modulePath string) bool { + parts := strings.Split(modulePath, ".") + current := config + + for i, part := range parts { + if current == nil { + return false + } + + value, exists := current[part] + if !exists { + return false + } + + // 如果是最后一个部分,检查布尔值 + if i == len(parts)-1 { + if boolVal, ok := value.(bool); ok { + return boolVal + } + // 如果是对象且有enabled字段,检查enabled + if objVal, ok := value.(map[string]interface{}); ok { + if enabled, hasEnabled := objVal["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok { + return enabledBool + } + } + // 如果没有enabled字段,默认为true + return true + } + return false + } + + // 中间路径必须是对象 + if objVal, ok := value.(map[string]interface{}); ok { + // 检查区域是否启用 + if enabled, hasEnabled := objVal["enabled"]; hasEnabled { + if enabledBool, ok := enabled.(bool); ok && !enabledBool { + return false + } + } + current = objVal + } else { + return false + } + } + + return false +} + func TokenAuth() func(c *gin.Context) { return func(c *gin.Context) { // 先检测是否为ws diff --git a/model/main.go b/model/main.go index 1a38d371b847..087ada2fd8ee 100644 --- a/model/main.go +++ b/model/main.go @@ -114,6 +114,8 @@ func CheckSetup() { } } + + func chooseDB(envName string, isLog bool) (*gorm.DB, error) { defer func() { initCol() @@ -262,6 +264,7 @@ func migrateDB() error { &Model{}, &Vendor{}, &PrefillGroup{}, + &UserGroup{}, &Setup{}, &TwoFA{}, &TwoFABackupCode{}, @@ -269,6 +272,17 @@ func migrateDB() error { if err != nil { return err } + + // 初始化默认用户分组 + if err := InitDefaultUserGroups(); err != nil { + common.SysLog("初始化默认用户分组失败: " + err.Error()) + } + + // 迁移用户分组数据 + if err := MigrateUserGroupData(); err != nil { + common.SysLog("迁移用户分组数据失败: " + err.Error()) + } + return nil } @@ -294,6 +308,7 @@ func migrateDBFast() error { {&Model{}, "Model"}, {&Vendor{}, "Vendor"}, {&PrefillGroup{}, "PrefillGroup"}, + {&UserGroup{}, "UserGroup"}, {&Setup{}, "Setup"}, {&TwoFA{}, "TwoFA"}, {&TwoFABackupCode{}, "TwoFABackupCode"}, @@ -473,3 +488,45 @@ func PingDB() error { common.SysLog("Database pinged successfully") return nil } + +// MigrateUserGroupData 迁移用户分组数据 +func MigrateUserGroupData() error { + // 检查是否已经执行过迁移标记 + var migrationOption Option + err := DB.Where("`key` = ?", "UserGroupMigrationCompleted").First(&migrationOption).Error + if err == nil && migrationOption.Value == "true" { + // 已经迁移过,跳过 + return nil + } + + // 检查是否需要迁移 + var count int64 + DB.Model(&UserGroup{}).Count(&count) + if count == 0 { + // 没有用户分组数据,标记为已迁移 + UpdateOption("UserGroupMigrationCompleted", "true") + return nil + } + + // 检查是否存在 options 表中的分组数据需要迁移 + var groupRatioOption Option + err = DB.Where("`key` = ?", "GroupRatio").First(&groupRatioOption).Error + if err != nil { + // 没有 GroupRatio 配置,说明不需要迁移,标记为已完成 + UpdateOption("UserGroupMigrationCompleted", "true") + return nil + } + + // 检查是否已经迁移过(通过检查是否存在非默认分组) + var nonDefaultCount int64 + DB.Model(&UserGroup{}).Where("name NOT IN (?)", []string{"default", "vip", "svip"}).Count(&nonDefaultCount) + + if nonDefaultCount > 0 { + // 已经有非默认分组,说明已经迁移过,标记为已完成 + UpdateOption("UserGroupMigrationCompleted", "true") + return nil + } + + common.SysLog("检测到需要迁移用户分组数据,请访问分组管理页面或调用迁移 API") + return nil +} diff --git a/model/option.go b/model/option.go index ceecff658f5e..9f44ae828d6a 100644 --- a/model/option.go +++ b/model/option.go @@ -159,6 +159,8 @@ func loadOptionsFromDatabase() { } } + + func SyncOptions(frequency int) { for { time.Sleep(time.Duration(frequency) * time.Second) diff --git a/model/user.go b/model/user.go index ea0584c5a05a..727b7c43f1e0 100644 --- a/model/user.go +++ b/model/user.go @@ -7,6 +7,7 @@ import ( "one-api/common" "one-api/dto" "one-api/logger" + "one-api/setting/operation_setting" "strconv" "strings" @@ -45,6 +46,7 @@ type User struct { Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` + Avatar string `json:"avatar,omitempty" gorm:"type:longtext;column:avatar"` } func (user *User) ToBaseUser() *UserBase { @@ -92,7 +94,7 @@ func (user *User) SetSetting(setting dto.UserSetting) { } // 根据用户角色生成默认的边栏配置 -func generateDefaultSidebarConfigForRole(userRole int) string { +func GenerateDefaultSidebarConfigForRole(userRole int) string { defaultConfig := map[string]interface{}{} // 聊天区域 - 所有用户都可以访问 @@ -127,8 +129,11 @@ func generateDefaultSidebarConfigForRole(userRole int) string { "channel": true, "models": true, "redemption": true, - "user": true, - "setting": false, // 管理员不能访问系统设置 + "user": map[string]interface{}{ + "enabled": true, + "groupManagement": true, // 管理员默认可以访问分组管理 + }, + "setting": false, // 管理员不能访问系统设置 } } else if userRole == common.RoleRootUser { // 超级管理员可以访问所有功能 @@ -137,8 +142,11 @@ func generateDefaultSidebarConfigForRole(userRole int) string { "channel": true, "models": true, "redemption": true, - "user": true, - "setting": true, + "user": map[string]interface{}{ + "enabled": true, + "groupManagement": true, // 超级管理员默认可以访问分组管理 + }, + "setting": true, } } // 普通用户不包含admin区域 @@ -400,7 +408,7 @@ func (user *User) Insert(inviterId int) error { var createdUser User if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil { // 生成基于角色的默认边栏配置 - defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role) + defaultSidebarConfig := GenerateDefaultSidebarConfigForRole(createdUser.Role) if defaultSidebarConfig != "" { currentSetting := createdUser.GetSetting() currentSetting.SidebarModules = defaultSidebarConfig @@ -414,13 +422,18 @@ func (user *User) Insert(inviterId int) error { RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) } if inviterId != 0 { - if common.QuotaForInvitee > 0 { - _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) - RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) - } - if common.QuotaForInviter > 0 { - //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) - RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + // 检查邀请功能是否启用 + generalSetting := operation_setting.GetGeneralSetting() + if generalSetting.InvitationEnabled { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + } + // 无论是否有奖励额度,都要更新邀请者的邀请统计 _ = inviteUser(inviterId) } } @@ -445,6 +458,16 @@ func (user *User) Update(updatePassword bool) error { return updateUserCache(*user) } +func (user *User) UpdateAvatar() error { + if err := DB.Model(&User{}).Where("id = ?", user.Id).Update("avatar", user.Avatar).Error; err != nil { + return err + } + + // Update cache + DB.First(&user, user.Id) + return updateUserCache(*user) +} + func (user *User) Edit(updatePassword bool) error { var err error if updatePassword { @@ -461,6 +484,7 @@ func (user *User) Edit(updatePassword bool) error { "group": newUser.Group, "quota": newUser.Quota, "remark": newUser.Remark, + "avatar": newUser.Avatar, } if updatePassword { updates["password"] = newUser.Password @@ -915,3 +939,72 @@ func RootUserExists() bool { } return true } + +// UpdateUsersGroupName 更新所有使用指定分组名的用户的分组名称 +func UpdateUsersGroupName(oldGroupName, newGroupName string) error { + if oldGroupName == "" || newGroupName == "" { + return errors.New("分组名称不能为空") + } + + common.SysLog(fmt.Sprintf("开始更新用户分组名称: '%s' -> '%s'", oldGroupName, newGroupName)) + + // 先查询有多少用户使用旧分组名 + var count int64 + if err := DB.Model(&User{}).Where(commonGroupCol+" = ?", oldGroupName).Count(&count).Error; err != nil { + common.SysLog(fmt.Sprintf("查询使用分组 '%s' 的用户数量失败: %s", oldGroupName, err.Error())) + return err + } + common.SysLog(fmt.Sprintf("找到 %d 个用户使用分组 '%s'", count, oldGroupName)) + + // 使用事务确保数据一致性 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 更新所有使用旧分组名的用户 + result := tx.Model(&User{}).Where(commonGroupCol+" = ?", oldGroupName).Update(commonGroupCol, newGroupName) + if result.Error != nil { + tx.Rollback() + common.SysLog(fmt.Sprintf("更新用户分组名称失败: %s", result.Error.Error())) + return result.Error + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + common.SysLog(fmt.Sprintf("提交事务失败: %s", err.Error())) + return err + } + + // 刷新缓存(异步),避免旧分组缓存导致读取不一致 + if common.RedisEnabled { + gopool.Go(func() { + var ids []int + if err := DB.Model(&User{}). + Where(commonGroupCol+" = ?", newGroupName). + Pluck("id", &ids).Error; err == nil { + for _, id := range ids { + _ = invalidateUserCache(id) + } + } + }) + } + + common.SysLog(fmt.Sprintf("成功更新 %d 个用户的分组名称从 '%s' 到 '%s'", result.RowsAffected, oldGroupName, newGroupName)) + + // 验证更新结果 + var newCount int64 + if err := DB.Model(&User{}).Where(commonGroupCol+" = ?", newGroupName).Count(&newCount).Error; err != nil { + common.SysLog(fmt.Sprintf("验证更新结果失败: %s", err.Error())) + } else { + common.SysLog(fmt.Sprintf("验证: 现在有 %d 个用户使用分组 '%s'", newCount, newGroupName)) + } + + return nil +} diff --git a/model/user_group.go b/model/user_group.go new file mode 100644 index 000000000000..7f60316c9cf3 --- /dev/null +++ b/model/user_group.go @@ -0,0 +1,139 @@ +package model + +import ( + "one-api/common" + "gorm.io/gorm" +) + +type UserGroup struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_user_group_name,where:deleted_at IS NULL"` + Description string `json:"description,omitempty" gorm:"type:varchar(255)"` + Ratio float64 `json:"ratio" gorm:"default:1.0"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Insert 创建新的用户分组 +func (g *UserGroup) Insert() error { + now := common.GetTimestamp() + g.CreatedTime = now + g.UpdatedTime = now + return DB.Create(g).Error +} + +// Update 更新用户分组 + func (g *UserGroup) Update() error { + g.UpdatedTime = common.GetTimestamp() + return DB.Model(&UserGroup{}). + Where("id = ?", g.Id). + Updates(map[string]any{ + "name": g.Name, + "description": g.Description, + "ratio": g.Ratio, + "updated_time": g.UpdatedTime, + }).Error + } + +// UpdateTx 在事务中更新用户分组 +func (g *UserGroup) UpdateTx(tx *gorm.DB) error { + g.UpdatedTime = common.GetTimestamp() + return tx.Model(g).Updates(g).Error +} + +// Delete 硬删除用户分组 +func (g *UserGroup) Delete() error { + return DB.Unscoped().Delete(g).Error +} + +// GetAllUserGroups 获取所有用户分组 +func GetAllUserGroups() ([]*UserGroup, error) { + var groups []*UserGroup + err := DB.Order("created_time desc").Find(&groups).Error + return groups, err +} + +// GetUserGroupById 根据ID获取用户分组 +func GetUserGroupById(id int) (*UserGroup, error) { + var group UserGroup + err := DB.First(&group, id).Error + if err != nil { + return nil, err + } + return &group, nil +} + +// GetUserGroupByName 根据名称获取用户分组 +func GetUserGroupByName(name string) (*UserGroup, error) { + var group UserGroup + err := DB.Where("name = ?", name).First(&group).Error + if err != nil { + return nil, err + } + return &group, nil +} + +// IsUserGroupNameDuplicated 检查用户分组名称是否重复 +func IsUserGroupNameDuplicated(id int, name string) (bool, error) { + var count int64 + query := DB.Model(&UserGroup{}).Where("name = ?", name) + if id != 0 { + query = query.Where("id != ?", id) + } + err := query.Count(&count).Error + return count > 0, err +} + +// IsUserGroupInUse 检查用户分组是否正在被使用 +func IsUserGroupInUse(name string) (bool, error) { + var count int64 + err := DB.Model(&User{}).Where("`group` = ?", name).Count(&count).Error + return count > 0, err +} + +// GetUserGroupNames 获取所有用户分组名称 +func GetUserGroupNames() ([]string, error) { + var names []string + err := DB.Model(&UserGroup{}).Pluck("name", &names).Error + return names, err +} + +// InitDefaultUserGroups 初始化默认用户分组 +func InitDefaultUserGroups() error { + // 检查是否已经存在默认分组 + var count int64 + DB.Model(&UserGroup{}).Count(&count) + if count > 0 { + return nil // 已经有分组了,不需要初始化 + } + + // 创建默认分组 + defaultGroups := []*UserGroup{ + { + Name: "default", + Description: "默认分组", + Ratio: 1.0, + }, + { + Name: "vip", + Description: "VIP分组", + Ratio: 1.0, + }, + { + Name: "svip", + Description: "SVIP分组", + Ratio: 1.0, + }, + } + + for _, group := range defaultGroups { + if err := group.Insert(); err != nil { + return err + } + } + + return nil +} + + diff --git a/router/api-router.go b/router/api-router.go index e16d06628955..c1302f261c9f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -55,6 +55,7 @@ func SetApiRouter(router *gin.Engine) { { selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) + selfRoute.GET("/avatar", controller.GetUserAvatar) selfRoute.GET("/models", controller.GetUserModels) selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) @@ -79,6 +80,7 @@ func SetApiRouter(router *gin.Engine) { adminRoute := userRoute.Group("/") adminRoute.Use(middleware.AdminAuth()) + adminRoute.Use(middleware.ModuleAuth("admin.user")) { adminRoute.GET("/", controller.GetAllUsers) adminRoute.GET("/search", controller.SearchUsers) @@ -109,6 +111,7 @@ func SetApiRouter(router *gin.Engine) { } channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) + channelRoute.Use(middleware.ModuleAuth("admin.channel")) { channelRoute.GET("/", controller.GetAllChannels) channelRoute.GET("/search", controller.SearchChannels) @@ -138,6 +141,7 @@ func SetApiRouter(router *gin.Engine) { } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) + tokenRoute.Use(middleware.ModuleAuth("console.token")) { tokenRoute.GET("/", controller.GetAllTokens) tokenRoute.GET("/search", controller.SearchTokens) @@ -160,6 +164,7 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute := apiRouter.Group("/redemption") redemptionRoute.Use(middleware.AdminAuth()) + redemptionRoute.Use(middleware.ModuleAuth("admin.redemption")) { redemptionRoute.GET("/", controller.GetAllRedemptions) redemptionRoute.GET("/search", controller.SearchRedemptions) @@ -170,17 +175,17 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute.DELETE("/:id", controller.DeleteRedemption) } logRoute := apiRouter.Group("/log") - logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) - logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) - logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) - logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) - logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) - logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) - logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) + logRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.GetAllLogs) + logRoute.DELETE("/", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.DeleteHistoryLogs) + logRoute.GET("/stat", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.GetLogsStat) + logRoute.GET("/self/stat", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.GetLogsSelfStat) + logRoute.GET("/search", middleware.AdminAuth(), middleware.ModuleAuth("console.log"), controller.SearchAllLogs) + logRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.GetUserLogs) + logRoute.GET("/self/search", middleware.UserAuth(), middleware.ModuleAuth("console.log"), controller.SearchUserLogs) dataRoute := apiRouter.Group("/data") - dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) - dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) + dataRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.detail"), controller.GetAllQuotaDates) + dataRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.detail"), controller.GetUserQuotaDates) logRoute.Use(middleware.CORS()) { @@ -201,14 +206,27 @@ func SetApiRouter(router *gin.Engine) { prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup) } + userGroupRoute := apiRouter.Group("/user_group") + userGroupRoute.Use(middleware.AdminAuth()) + userGroupRoute.Use(middleware.ModuleAuth("admin.user.groupManagement")) + { + userGroupRoute.GET("/", controller.GetAllUserGroups) + userGroupRoute.POST("/", controller.CreateUserGroup) + userGroupRoute.PUT("/", controller.UpdateUserGroup) + userGroupRoute.DELETE("/:id", controller.DeleteUserGroup) + userGroupRoute.POST("/migrate", controller.MigrateUserGroupData) + userGroupRoute.GET("/options", controller.GetUserGroupsAsOptions) + userGroupRoute.PUT("/batch", controller.BatchUpdateUserGroups) + } + mjRoute := apiRouter.Group("/mj") - mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) - mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) + mjRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.midjourney"), controller.GetUserMidjourney) + mjRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.midjourney"), controller.GetAllMidjourney) taskRoute := apiRouter.Group("/task") { - taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) - taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) + taskRoute.GET("/self", middleware.UserAuth(), middleware.ModuleAuth("console.task"), controller.GetUserTask) + taskRoute.GET("/", middleware.AdminAuth(), middleware.ModuleAuth("console.task"), controller.GetAllTask) } vendorRoute := apiRouter.Group("/vendors") @@ -224,6 +242,7 @@ func SetApiRouter(router *gin.Engine) { modelsRoute := apiRouter.Group("/models") modelsRoute.Use(middleware.AdminAuth()) + modelsRoute.Use(middleware.ModuleAuth("admin.models")) { modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview) modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels) diff --git a/service/cf_worker.go b/service/cf_worker.go index 4a7b437603ef..d60b6fad5f78 100644 --- a/service/cf_worker.go +++ b/service/cf_worker.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "one-api/common" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -21,14 +21,14 @@ type WorkerRequest struct { // DoWorkerRequest 通过Worker发送请求 func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { - if !setting.EnableWorker() { + if !system_setting.EnableWorker() { return nil, fmt.Errorf("worker not enabled") } - if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { return nil, fmt.Errorf("only support https url") } - workerUrl := setting.WorkerUrl + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" } @@ -43,11 +43,11 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { } func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) { - if setting.EnableWorker() { + if system_setting.EnableWorker() { common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) req := &WorkerRequest{ URL: originUrl, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, } return DoWorkerRequest(req) } else { diff --git a/service/user_group_service.go b/service/user_group_service.go new file mode 100644 index 000000000000..377830d399aa --- /dev/null +++ b/service/user_group_service.go @@ -0,0 +1,216 @@ +package service + +import ( + "encoding/json" + "fmt" + "one-api/common" + "one-api/model" + "one-api/setting" + "one-api/setting/ratio_setting" +) + +// MigrateUserGroupsFromOptions 从 options 表迁移用户分组数据到 UserGroup 表 +func MigrateUserGroupsFromOptions() error { + common.SysLog("开始迁移用户分组数据...") + + // 获取当前 options 中的分组数据 + groupRatioStr := common.OptionMap["GroupRatio"] + userUsableGroupsStr := common.OptionMap["UserUsableGroups"] + topupGroupRatioStr := common.OptionMap["TopupGroupRatio"] + + // 解析分组倍率 + var groupRatio map[string]float64 + if groupRatioStr != "" { + if err := json.Unmarshal([]byte(groupRatioStr), &groupRatio); err != nil { + common.SysLog("解析 GroupRatio 失败: " + err.Error()) + groupRatio = make(map[string]float64) + } + } else { + groupRatio = make(map[string]float64) + } + + // 解析用户可选分组 + var userUsableGroups map[string]string + if userUsableGroupsStr != "" { + if err := json.Unmarshal([]byte(userUsableGroupsStr), &userUsableGroups); err != nil { + common.SysLog("解析 UserUsableGroups 失败: " + err.Error()) + userUsableGroups = make(map[string]string) + } + } else { + userUsableGroups = make(map[string]string) + } + + // 解析充值分组倍率 + var topupGroupRatio map[string]float64 + if topupGroupRatioStr != "" { + if err := json.Unmarshal([]byte(topupGroupRatioStr), &topupGroupRatio); err != nil { + common.SysLog("解析 TopupGroupRatio 失败: " + err.Error()) + topupGroupRatio = make(map[string]float64) + } + } else { + topupGroupRatio = make(map[string]float64) + } + + // 合并所有分组名称 + allGroups := make(map[string]bool) + for name := range groupRatio { + allGroups[name] = true + } + for name := range userUsableGroups { + allGroups[name] = true + } + for name := range topupGroupRatio { + allGroups[name] = true + } + + // 添加默认分组 + defaultGroups := []string{"default", "vip", "svip"} + for _, name := range defaultGroups { + allGroups[name] = true + } + + // 检查并创建/更新分组 + migratedCount := 0 + for groupName := range allGroups { + // 检查分组是否已存在 + existingGroup, err := model.GetUserGroupByName(groupName) + if err != nil && err.Error() != "record not found" { + common.SysLog("检查分组 " + groupName + " 时出错: " + err.Error()) + continue + } + + ratio := groupRatio[groupName] + if ratio == 0 { + ratio = 1.0 // 默认倍率 + } + + description := userUsableGroups[groupName] + if description == "" { + // 为默认分组设置描述 + switch groupName { + case "default": + description = "默认分组" + case "vip": + description = "VIP分组" + case "svip": + description = "SVIP分组" + default: + description = groupName + "分组" + } + } + + if existingGroup == nil { + // 创建新分组 + newGroup := &model.UserGroup{ + Name: groupName, + Description: description, + Ratio: ratio, + } + if err := newGroup.Insert(); err != nil { + common.SysLog("创建分组 " + groupName + " 失败: " + err.Error()) + continue + } + migratedCount++ + common.SysLog("创建分组: " + groupName + " (倍率: " + fmt.Sprintf("%.4f", ratio) + ")") + } else { + // 更新现有分组(如果数据不同) + needUpdate := false + if existingGroup.Ratio != ratio { + existingGroup.Ratio = ratio + needUpdate = true + } + if existingGroup.Description != description { + existingGroup.Description = description + needUpdate = true + } + + if needUpdate { + if err := existingGroup.Update(); err != nil { + common.SysLog("更新分组 " + groupName + " 失败: " + err.Error()) + continue + } + migratedCount++ + common.SysLog("更新分组: " + groupName + " (倍率: " + fmt.Sprintf("%.4f", ratio) + ")") + } + } + } + + common.SysLog("用户分组数据迁移完成,处理了 " + fmt.Sprintf("%d", migratedCount) + " 个分组") + + // 设置迁移完成标记 + if err := model.UpdateOption("UserGroupMigrationCompleted", "true"); err != nil { + common.SysLog("设置迁移完成标记失败: " + err.Error()) + } + + return nil +} + +// SyncUserGroupsToMemory 将 UserGroup 表数据同步到内存中的设置 +func SyncUserGroupsToMemory() error { + groups, err := model.GetAllUserGroups() + if err != nil { + return err + } + + // 构建分组倍率映射 + groupRatio := make(map[string]float64) + userUsableGroups := make(map[string]string) + topupGroupRatio := make(map[string]float64) + + for _, group := range groups { + groupRatio[group.Name] = group.Ratio + userUsableGroups[group.Name] = group.Description + topupGroupRatio[group.Name] = group.Ratio // 充值倍率使用相同的倍率 + } + + // 更新内存中的设置 + if groupRatioJson, err := json.Marshal(groupRatio); err == nil { + ratio_setting.UpdateGroupRatioByJSONString(string(groupRatioJson)) + } + + if userUsableGroupsJson, err := json.Marshal(userUsableGroups); err == nil { + setting.UpdateUserUsableGroupsByJSONString(string(userUsableGroupsJson)) + } + + if topupGroupRatioJson, err := json.Marshal(topupGroupRatio); err == nil { + common.UpdateTopupGroupRatioByJSONString(string(topupGroupRatioJson)) + } + + return nil +} + +// GetUserGroupsAsOptions 获取用户分组数据并转换为 options 格式 +func GetUserGroupsAsOptions() (map[string]string, error) { + groups, err := model.GetAllUserGroups() + if err != nil { + return nil, err + } + + options := make(map[string]string) + + // 构建分组倍率 + groupRatio := make(map[string]float64) + userUsableGroups := make(map[string]string) + topupGroupRatio := make(map[string]float64) + + for _, group := range groups { + groupRatio[group.Name] = group.Ratio + userUsableGroups[group.Name] = group.Description + topupGroupRatio[group.Name] = group.Ratio + } + + // 转换为 JSON 字符串 + if groupRatioJson, err := json.Marshal(groupRatio); err == nil { + options["GroupRatio"] = string(groupRatioJson) + } + + if userUsableGroupsJson, err := json.Marshal(userUsableGroups); err == nil { + options["UserUsableGroups"] = string(userUsableGroupsJson) + } + + if topupGroupRatioJson, err := json.Marshal(topupGroupRatio); err == nil { + options["TopupGroupRatio"] = string(topupGroupRatioJson) + } + + return options, nil +} diff --git a/service/user_notify.go b/service/user_notify.go index c4a3ea91f9de..972ca655c52d 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -7,7 +7,7 @@ import ( "one-api/common" "one-api/dto" "one-api/model" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { var resp *http.Response var err error - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 使用worker发送请求 workerReq := &WorkerRequest{ URL: finalURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodGet, Headers: map[string]string{ "User-Agent": "OneAPI-Bark-Notify/1.0", diff --git a/service/webhook.go b/service/webhook.go index 8faccda30102..9c6ec8102783 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" "one-api/dto" - "one-api/setting" + "one-api/setting/system_setting" "time" ) @@ -56,11 +56,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error var req *http.Request var resp *http.Response - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 构建worker请求数据 workerReq := &WorkerRequest{ URL: webhookURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodPost, Headers: map[string]string{ "Content-Type": "application/json", diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go index ae0c436ecefd..fc9190f4d1cf 100644 --- a/setting/operation_setting/general_setting.go +++ b/setting/operation_setting/general_setting.go @@ -6,6 +6,7 @@ type GeneralSetting struct { DocsLink string `json:"docs_link"` PingIntervalEnabled bool `json:"ping_interval_enabled"` PingIntervalSeconds int `json:"ping_interval_seconds"` + InvitationEnabled bool `json:"invitation_enabled"` } // 默认配置 @@ -13,6 +14,7 @@ var generalSetting = GeneralSetting{ DocsLink: "https://docs.newapi.pro", PingIntervalEnabled: false, PingIntervalSeconds: 60, + InvitationEnabled: true, } func init() { diff --git a/web/index.html b/web/index.html index 09d87ae1a890..d54a471b3631 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,30 @@ content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" /> New API + diff --git a/web/src/App.jsx b/web/src/App.jsx index 635742f9161e..ed2261ad569b 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -22,6 +22,7 @@ import { Route, Routes, useLocation } from 'react-router-dom'; import Loading from './components/common/ui/Loading'; import User from './pages/User'; import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers'; +import ModuleRoute from './components/auth/ModuleRoute'; import RegisterForm from './components/auth/RegisterForm'; import LoginForm from './components/auth/LoginForm'; import NotFound from './pages/NotFound'; @@ -58,7 +59,7 @@ function App() { // 获取模型广场权限配置 const pricingRequireAuth = useMemo(() => { - const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + const headerNavModulesConfig = statusState?.status?.header_nav_modules; if (headerNavModulesConfig) { try { const modules = JSON.parse(headerNavModulesConfig); @@ -76,7 +77,7 @@ function App() { } } return false; // 默认不需要登录 - }, [statusState?.status?.HeaderNavModules]); + }, [statusState?.status?.header_nav_modules]); return ( @@ -102,7 +103,9 @@ function App() { path='/console/models' element={ - + + + } /> @@ -110,7 +113,9 @@ function App() { path='/console/channel' element={ - + + + } /> @@ -118,7 +123,9 @@ function App() { path='/console/token' element={ - + + + } /> @@ -126,7 +133,9 @@ function App() { path='/console/playground' element={ - + + + } /> @@ -134,7 +143,9 @@ function App() { path='/console/redemption' element={ - + + + } /> @@ -142,7 +153,9 @@ function App() { path='/console/user' element={ - + + + } /> @@ -210,9 +223,11 @@ function App() { path='/console/setting' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -220,9 +235,11 @@ function App() { path='/console/personal' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -230,9 +247,11 @@ function App() { path='/console/topup' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -240,7 +259,9 @@ function App() { path='/console/log' element={ - + + + } /> @@ -248,9 +269,11 @@ function App() { path='/console' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -258,9 +281,11 @@ function App() { path='/console/midjourney' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -268,9 +293,11 @@ function App() { path='/console/task' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> @@ -304,9 +331,11 @@ function App() { } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> {/* 方便使用chat2link直接跳转聊天... */} @@ -314,9 +343,11 @@ function App() { path='/chat2link' element={ - } key={location.pathname}> - - + + } key={location.pathname}> + + + } /> diff --git a/web/src/components/auth/AuthPageLayout.jsx b/web/src/components/auth/AuthPageLayout.jsx new file mode 100644 index 000000000000..b2c027c73d68 --- /dev/null +++ b/web/src/components/auth/AuthPageLayout.jsx @@ -0,0 +1,39 @@ +/* +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 from 'react'; + +const AuthPageLayout = ({ children }) => { + return ( +
+ {/* 背景模糊晕染球 */} +
+
+ {children} +
+ ); +}; + +export default AuthPageLayout; diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 32087ab02484..8714aeaae3d2 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -45,6 +45,7 @@ import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; import TwoFAVerification from './TwoFAVerification'; import { useTranslation } from 'react-i18next'; +import AuthPageLayout from './AuthPageLayout'; const LoginForm = () => { let navigate = useNavigate(); @@ -603,17 +604,8 @@ const LoginForm = () => { }; return ( -
- {/* 背景模糊晕染球 */} -
-
-
+ +
{showEmailLogin || !( status.github_oauth || @@ -638,7 +630,7 @@ const LoginForm = () => {
)}
-
+ ); }; diff --git a/web/src/components/auth/ModuleRoute.jsx b/web/src/components/auth/ModuleRoute.jsx new file mode 100644 index 000000000000..4c4bd359f929 --- /dev/null +++ b/web/src/components/auth/ModuleRoute.jsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { Navigate } from 'react-router-dom'; +import { StatusContext } from '../../context/Status'; +import Loading from '../common/ui/Loading'; +import { useSidebar } from '../../hooks/common/useSidebar'; +import { USER_ROLES } from '../../constants/user.constants'; + +/** + * ModuleRoute - 基于功能模块权限的路由保护组件 + * + * @param {Object} props + * @param {React.ReactNode} props.children - 要保护的子组件 + * @param {string} props.modulePath - 模块权限路径,如 "admin.channel", "console.token" + * @param {React.ReactNode} props.fallback - 无权限时显示的组件,默认跳转到 /forbidden + * @returns {React.ReactNode} + */ +const ModuleRoute = ({ children, modulePath, fallback = }) => { + const [hasPermission, setHasPermission] = useState(null); + const [statusState] = useContext(StatusContext); + + // 复用 useSidebar 钩子的配置数据,避免重复 API 调用 + const { loading: sidebarLoading, finalConfig } = useSidebar(); + + // 获取用户信息的辅助函数 + const getUserFromStorage = () => { + try { + return JSON.parse(localStorage.getItem('user') || 'null'); + } catch { + return null; + } + }; + + useEffect(() => { + let cancelled = false; + + const checkPermission = async () => { + const userObj = getUserFromStorage(); + const permission = await checkModulePermission(userObj); + + if (!cancelled) { + setHasPermission(permission); + } + }; + + checkPermission(); + + return () => { + cancelled = true; + }; + }, [modulePath, statusState?.status, sidebarLoading, finalConfig]); // 依赖 sidebar 配置变化 + + const checkModulePermission = async (userObj) => { + try { + // 检查用户是否已登录 + if (!userObj) { + return false; + } + + // 不再基于本地角色直接授予 ROOT 权限,统一依赖服务端下发的最终配置 + const userRole = userObj.role; + + // 如果 sidebar 配置还在加载中,返回 null 表示需要等待 + if (sidebarLoading || !finalConfig) { + return null; + } + + // 检查模块权限 + return checkModulePermissionInConfig(userRole, modulePath); + } catch (error) { + console.error('检查模块权限失败:', error); + // 出错时采用安全优先策略,拒绝访问 + return false; + } + }; + + const checkModulePermissionInConfig = (userRole, modulePath) => { + // 数据看板始终允许访问,不受控制台区域开关影响 + if (modulePath === 'console.detail') { + return true; + } + + // 解析模块路径 + const pathParts = modulePath.split('.'); + if (pathParts.length < 2) { + console.warn(`无效的模块路径: ${modulePath}`); + return false; + } + + // 所有用户角色统一依赖服务端下发的最终配置进行权限检查 + if (userRole === USER_ROLES.COMMON) { + // 普通用户:使用最终计算的配置进行权限检查 + return checkModuleInSidebarConfig(finalConfig, modulePath); + } else if (userRole === USER_ROLES.ADMIN) { + // 管理员:不能访问系统设置,其他基于配置检查 + if (modulePath === 'admin.setting') { + return false; + } + return checkModuleInSidebarConfig(finalConfig, modulePath); + } else if (userRole === USER_ROLES.ROOT) { + // 超级管理员:也依赖服务端配置,不再客户端直接授权 + return checkModuleInSidebarConfig(finalConfig, modulePath); + } + + // 未知角色,拒绝访问 + return false; + }; + + // 检查sidebar_config结构中的模块权限 + const checkModuleInSidebarConfig = (sidebarConfig, modulePath) => { + const parts = modulePath.split('.'); + if (parts.length !== 2) { + return false; + } + + const [sectionKey, moduleKey] = parts; + const section = sidebarConfig[sectionKey]; + + // 检查区域是否存在且启用 + if (!section || !section.enabled) { + return false; + } + + // 检查模块是否启用 + const moduleValue = section[moduleKey]; + // 处理布尔值和嵌套对象两种情况 + if (typeof moduleValue === 'boolean') { + return moduleValue === true; + } else if (typeof moduleValue === 'object' && moduleValue !== null) { + // 对于嵌套对象,检查其enabled状态 + return moduleValue.enabled === true; + } + return false; + }; + + // 权限检查中 + if (hasPermission === null) { + return ; + } + + // 无权限 + if (!hasPermission) { + return fallback; + } + + // 有权限,渲染子组件 + return children; +}; + +export default ModuleRoute; \ No newline at end of file diff --git a/web/src/components/auth/PasswordResetConfirm.jsx b/web/src/components/auth/PasswordResetConfirm.jsx index 9bc37b3ce560..fd48b6011904 100644 --- a/web/src/components/auth/PasswordResetConfirm.jsx +++ b/web/src/components/auth/PasswordResetConfirm.jsx @@ -30,6 +30,7 @@ import { useSearchParams, Link } from 'react-router-dom'; import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; +import AuthPageLayout from './AuthPageLayout'; const { Text, Title } = Typography; @@ -104,17 +105,8 @@ const PasswordResetConfirm = () => { } return ( -
- {/* 背景模糊晕染球 */} -
-
-
+ +
@@ -213,7 +205,7 @@ const PasswordResetConfirm = () => {
-
+
); }; diff --git a/web/src/components/auth/PasswordResetForm.jsx b/web/src/components/auth/PasswordResetForm.jsx index 92afc2afa439..c07d8bef6b3f 100644 --- a/web/src/components/auth/PasswordResetForm.jsx +++ b/web/src/components/auth/PasswordResetForm.jsx @@ -31,6 +31,7 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; import { IconMail } from '@douyinfe/semi-icons'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import AuthPageLayout from './AuthPageLayout'; const { Text, Title } = Typography; @@ -104,17 +105,8 @@ const PasswordResetForm = () => { } return ( -
- {/* 背景模糊晕染球 */} -
-
-
+ +
@@ -186,7 +178,7 @@ const PasswordResetForm = () => {
-
+
); }; diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 9c98bdc3a0e9..e3c59d930647 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -51,6 +51,7 @@ import WeChatIcon from '../common/logo/WeChatIcon'; import TelegramLoginButton from 'react-telegram-login/src'; import { UserContext } from '../../context/User'; import { useTranslation } from 'react-i18next'; +import AuthPageLayout from './AuthPageLayout'; const RegisterForm = () => { let navigate = useNavigate(); @@ -601,17 +602,8 @@ const RegisterForm = () => { }; return ( -
- {/* 背景模糊晕染球 */} -
-
-
+ +
{showEmailRegister || !( status.github_oauth || @@ -635,7 +627,7 @@ const RegisterForm = () => {
)}
-
+ ); }; diff --git a/web/src/components/layout/Footer.jsx b/web/src/components/layout/Footer.jsx index 5c210fca89bf..59b25ddd8608 100644 --- a/web/src/components/layout/Footer.jsx +++ b/web/src/components/layout/Footer.jsx @@ -221,7 +221,7 @@ const FooterBar = () => { }, []); return ( -
+
{footer ? (
{ overflow: isMobile ? 'visible' : 'auto', display: 'flex', flexDirection: 'column', + height: '100%', + flex: '1 1 auto', }} > {showSider && ( diff --git a/web/src/components/layout/headerbar/LanguageSelector.jsx b/web/src/components/layout/headerbar/LanguageSelector.jsx index cbfd69b35706..3952771eecdd 100644 --- a/web/src/components/layout/headerbar/LanguageSelector.jsx +++ b/web/src/components/layout/headerbar/LanguageSelector.jsx @@ -32,8 +32,8 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => { onClick={() => onLanguageChange('zh')} className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`} > - - 中文 + + 简体中文 onLanguageChange('en')} diff --git a/web/src/components/layout/headerbar/UserArea.jsx b/web/src/components/layout/headerbar/UserArea.jsx index 8ea70f47f586..e89129d0fb10 100644 --- a/web/src/components/layout/headerbar/UserArea.jsx +++ b/web/src/components/layout/headerbar/UserArea.jsx @@ -120,10 +120,11 @@ const UserArea = ({ > - {userState.user.username[0].toUpperCase()} + {!userState.user?.avatar && userState.user.username[0].toUpperCase()} diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx index 18119d2427db..46d22b696a26 100644 --- a/web/src/components/settings/OtherSetting.jsx +++ b/web/src/components/settings/OtherSetting.jsx @@ -106,6 +106,11 @@ const OtherSetting = () => { SystemName: true, })); await updateOption('SystemName', inputs.SystemName); + // 更新localStorage并触发title更新事件 + localStorage.setItem('system_name', inputs.SystemName); + window.dispatchEvent(new CustomEvent('systemNameUpdated', { + detail: { systemName: inputs.SystemName } + })); showSuccess(t('系统名称已更新')); } catch (error) { console.error(t('系统名称更新失败'), error); diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 3ba8dcfd33d2..92c294834592 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -23,6 +23,7 @@ import { API, copy, showError, showInfo, showSuccess } from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; import { useTranslation } from 'react-i18next'; +import { getUserData as fetchUserData } from '../../helpers/userDataManager'; // 导入子组件 import UserInfoHeader from './personal/components/UserInfoHeader'; @@ -80,8 +81,9 @@ const PersonalSetting = () => { setTurnstileSiteKey(status.turnstile_site_key); } } - getUserData().then((res) => { - console.log(userState); + + getUserData().then(() => { + console.log('用户数据已加载'); }); }, []); @@ -132,12 +134,17 @@ const PersonalSetting = () => { }; const getUserData = async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); + try { + // 使用用户数据获取(包括头像) + const result = await fetchUserData(); + if (result.success) { + userDispatch({ type: 'login', payload: result.data }); + } else { + showError(result.message); + } + } catch (error) { + console.error('获取用户数据失败:', error); + showError('获取用户数据失败'); } }; @@ -302,12 +309,14 @@ const PersonalSetting = () => { } }; + + return (
{/* 顶部用户信息区域 */} - + {/* 账户管理和其他设置 */}
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 0b097eaff232..61e4341cce8f 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer'; import { StatusContext } from '../../../../context/Status'; import { UserContext } from '../../../../context/User'; import { useUserPermissions } from '../../../../hooks/common/useUserPermissions'; +import { useSidebar } from '../../../../hooks/common/useSidebar'; const NotificationSettings = ({ t, @@ -52,8 +53,6 @@ const NotificationSettings = ({ saveNotificationSettings, }) => { const formApiRef = useRef(null); - const [statusState] = useContext(StatusContext); - const [userState] = useContext(UserContext); // 左侧边栏设置相关状态 const [sidebarLoading, setSidebarLoading] = useState(false); @@ -97,6 +96,9 @@ const NotificationSettings = ({ isSidebarModuleAllowed, } = useUserPermissions(); + // 使用useSidebar钩子获取刷新方法 + const { refreshUserConfig } = useSidebar(); + // 左侧边栏设置处理函数 const handleSectionChange = (sectionKey) => { return (checked) => { @@ -132,6 +134,9 @@ const NotificationSettings = ({ }); if (res.data.success) { showSuccess(t('侧边栏设置保存成功')); + + // 刷新useSidebar钩子中的用户配置,实现实时更新 + await refreshUserConfig(); } else { showError(res.data.message); } @@ -165,29 +170,85 @@ const NotificationSettings = ({ setSidebarModulesUser(defaultConfig); }; + // 获取默认系统配置 + const getDefaultSystemConfig = () => { + return { + 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 + } + }; + }; + // 加载左侧边栏配置 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); + if (userRes.data.success) { + // 从setting字段中获取系统配置和用户偏好设置 + if (userRes.data.data.setting) { + try { + const setting = JSON.parse(userRes.data.data.setting); + + // 获取系统配置(用于权限检查) + const systemConfig = setting.sidebar_system_config ? + JSON.parse(setting.sidebar_system_config) : + getDefaultSystemConfig(); + setAdminConfig(systemConfig); + + // 获取用户偏好设置(用于显示当前状态) + if (setting.sidebar_modules) { + let userConf; + if (typeof setting.sidebar_modules === 'string') { + userConf = JSON.parse(setting.sidebar_modules); + } else { + userConf = setting.sidebar_modules; + } + setSidebarModulesUser(userConf); + } + } catch (error) { + console.error('解析用户设置失败:', error); + // 出错时使用默认配置 + setAdminConfig(getDefaultSystemConfig()); + } + } else { + // 没有setting时使用默认配置 + setAdminConfig(getDefaultSystemConfig()); + } } } catch (error) { console.error('加载边栏配置失败:', error); + // 出错时使用默认配置 + setAdminConfig(getDefaultSystemConfig()); } }; loadSidebarConfigs(); - }, [statusState]); + }, []); // 初始化表单值 useEffect(() => { @@ -334,7 +395,7 @@ const NotificationSettings = ({ loading={sidebarLoading} className='!rounded-lg' > - {t('保存边栏设置')} + {t('保存设置')} ) : ( @@ -664,7 +725,7 @@ const NotificationSettings = ({ color: 'var(--semi-color-text-2)', }} > - {t('您可以个性化设置侧边栏的要显示功能')} + {t('您可以个性化设置侧边栏要显示的功能')}
{/* 边栏设置功能区域容器 */} diff --git a/web/src/components/settings/personal/components/UserInfoHeader.jsx b/web/src/components/settings/personal/components/UserInfoHeader.jsx index 3209a17ee84a..81954b97f877 100644 --- a/web/src/components/settings/personal/components/UserInfoHeader.jsx +++ b/web/src/components/settings/personal/components/UserInfoHeader.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useState } from 'react'; import { Avatar, Card, @@ -25,7 +25,11 @@ import { Divider, Typography, Badge, + Upload, + Toast, + Modal, } from '@douyinfe/semi-ui'; +import { IconCamera } from '@douyinfe/semi-icons'; import { isRoot, isAdmin, @@ -33,8 +37,30 @@ import { stringToColor, } from '../../../../helpers'; import { Coins, BarChart2, Users } from 'lucide-react'; +import { updateUserAvatar } from '../../../../helpers/userDataManager'; + +// 添加样式确保头像上传的点击热区是圆形 +const avatarUploadStyle = ` +.avatar-upload .semi-upload-add { + border-radius: 50% !important; +} +`; + +// 注入样式 +if (typeof document !== 'undefined') { + const styleElement = document.createElement('style'); + styleElement.textContent = avatarUploadStyle; + if (!document.head.querySelector('style[data-avatar-upload]')) { + styleElement.setAttribute('data-avatar-upload', 'true'); + document.head.appendChild(styleElement); + } +} + +const UserInfoHeader = ({ t, userState, onUserDataUpdate }) => { + const [previewVisible, setPreviewVisible] = useState(false); + const [previewImage, setPreviewImage] = useState(''); + const [uploading, setUploading] = useState(false); -const UserInfoHeader = ({ t, userState }) => { const getUsername = () => { if (userState.user) { return userState.user.username; @@ -51,6 +77,127 @@ const UserInfoHeader = ({ t, userState }) => { return 'NA'; }; + // 将文件转换为base64 + const fileToBase64 = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = (error) => reject(error); + }); + }; + + // 上传前验证 + const beforeUpload = ({ file }) => { + console.log('beforeUpload file:', file); // 调试日志 + + // 获取文件类型,可能在 file.type 或 file.fileInstance.type 中 + const fileType = file.type || (file.fileInstance && file.fileInstance.type) || ''; + const fileSize = file.size || (file.fileInstance && file.fileInstance.size) || 0; + + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(fileType.toLowerCase())) { + console.log('File type validation failed:', fileType); // 调试日志 + Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP')); + return { + autoRemove: true, + shouldUpload: false, + status: 'validateFail', + validateMessage: t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP') + }; + } + + // 考虑base64编码会增加约33%的大小,所以原始文件限制为1.5MB + const maxSize = 1.5 * 1024 * 1024; // 1.5MB + if (fileSize > maxSize) { + Toast.error(t('图片文件大小不能超过1.5MB')); + return { + autoRemove: true, + shouldUpload: false, + status: 'validateFail', + validateMessage: t('图片文件大小不能超过1.5MB') + }; + } + + return { + shouldUpload: false, // 不直接上传,而是先预览 + status: 'success' + }; + }; + + // 处理文件选择 + const handleFileChange = async ({ currentFile }) => { + console.log('handleFileChange currentFile:', currentFile); // 调试日志 + + if (currentFile && currentFile.fileInstance) { + // 再次验证文件类型(客户端验证) + const fileType = currentFile.fileInstance.type; + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + + if (!allowedTypes.includes(fileType.toLowerCase())) { + Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP')); + return; + } + + try { + setUploading(true); + const base64Data = await fileToBase64(currentFile.fileInstance); + + // 验证base64数据大小(约2MB限制) + const base64Size = base64Data.length; + const maxBase64Size = 2 * 1024 * 1024; // 2MB + + if (base64Size > maxBase64Size) { + Toast.error(t('图片编码后数据过大,请选择更小的图片')); + return; + } + + setPreviewImage(base64Data); + setPreviewVisible(true); + } catch (error) { + Toast.error(t('图片处理失败,请重试')); + console.error('File processing error:', error); + } finally { + setUploading(false); + } + } + }; + + // 确认上传头像 + const handleConfirmUpload = async () => { + if (!previewImage) return; + + try { + setUploading(true); + + // 使用新的用户数据管理器上传头像 + const result = await updateUserAvatar(previewImage, { + id: userState.user?.id, + username: userState.user?.username || '', + display_name: userState.user?.display_name || '', + }); + + if (result.success) { + setPreviewVisible(false); + setPreviewImage(''); + Toast.success(t('头像更新成功')); + // 刷新用户数据 + if (onUserDataUpdate) { + await onUserDataUpdate(); + } + } else { + Toast.error(result.message || t('头像更新失败,请重试')); + } + } catch (error) { + Toast.error(t('头像更新失败,请重试')); + console.error('Avatar update error:', error); + } finally { + setUploading(false); + } + }; + + + return ( {
- - {getAvatarText()} - + { + Toast.error(t('图片文件大小不能超过1.5MB')); + }} + onAcceptInvalid={() => { + Toast.error(t('不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP')); + }} + disabled={uploading} + > + + +
+ } + style={{ cursor: 'pointer' }} + > + {!userState.user?.avatar && getAvatarText()} + +
{ {t('用户分组')} - {userState?.user?.group || t('默认')} + {userState?.user?.group === 'default' ? t('默认') : (userState?.user?.group || t('默认'))}
@@ -207,12 +388,38 @@ const UserInfoHeader = ({ t, userState }) => {
- {userState?.user?.group || t('默认')} + {userState?.user?.group === 'default' ? t('默认') : (userState?.user?.group || t('默认'))}
+ + {/* 头像预览模态框 */} + { + setPreviewVisible(false); + setPreviewImage(''); + }} + onOk={handleConfirmUpload} + okText={t('确认上传')} + cancelText={t('取消')} + confirmLoading={uploading} + width={400} + > +
+ +

+ {t('确认要使用这张图片作为头像吗?')} +

+
+
); }; diff --git a/web/src/components/table/users/UsersActions.jsx b/web/src/components/table/users/UsersActions.jsx index c3f2602a2596..2241ea8d06b2 100644 --- a/web/src/components/table/users/UsersActions.jsx +++ b/web/src/components/table/users/UsersActions.jsx @@ -17,21 +17,90 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import { useState } from 'react'; import { Button } from '@douyinfe/semi-ui'; +import UserGroupManagement from './modals/UserGroupManagement'; +import { useSidebar } from '../../../hooks/common/useSidebar'; + +const UsersActions = ({ setShowAddUser, onRefreshUsers, t }) => { + const [showGroupManagement, setShowGroupManagement] = useState(false); + const { finalConfig, loading: sidebarLoading } = useSidebar(); + + // 检查用户权限 + const getUserRole = () => { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + return user?.role || 0; + }; + + const isAdmin = () => getUserRole() >= 10; + const isRoot = () => getUserRole() >= 100; + + // 检查分组管理功能是否可见 + const canShowGroupManagement = () => { + // 如果侧边栏配置还在加载中,暂时不显示按钮 + if (sidebarLoading) { + return false; + } + + // 超级管理员始终可以看到分组管理按钮 + if (isRoot()) { + return true; + } + + // 管理员需要检查系统设置中的分组管理开关 + if (isAdmin()) { + // 从useSidebar钩子获取最终的权限配置 + const userSection = finalConfig?.admin?.user; + + // 检查用户管理模块是否启用 + if (!userSection || userSection.enabled === false) { + return false; + } + + // 检查分组管理子功能是否启用 + return userSection.groupManagement === true; + } + + // 普通用户无权访问 + return false; + }; -const UsersActions = ({ setShowAddUser, t }) => { // Add new user const handleAddUser = () => { setShowAddUser(true); }; + // Show group management + const handleGroupManagement = () => { + setShowGroupManagement(true); + }; + return ( -
- -
+ <> +
+ + {canShowGroupManagement() && ( + + )} +
+ + {canShowGroupManagement() && ( + setShowGroupManagement(false)} + onGroupUpdated={onRefreshUsers} + /> + )} + ); }; diff --git a/web/src/components/table/users/index.jsx b/web/src/components/table/users/index.jsx index 59e12a4e5fc0..0f8fc08a9752 100644 --- a/web/src/components/table/users/index.jsx +++ b/web/src/components/table/users/index.jsx @@ -88,7 +88,11 @@ const UsersPage = () => { } actionsArea={
- + . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { @@ -42,15 +42,34 @@ const AddUserModal = (props) => { const { t } = useTranslation(); const formApiRef = useRef(null); const [loading, setLoading] = useState(false); + const [groupOptions, setGroupOptions] = useState([]); const isMobile = useIsMobile(); const getInitValues = () => ({ username: '', display_name: '', password: '', + group: 'default', // 默认分组 remark: '', }); + // 获取分组列表 + const fetchGroups = async () => { + try { + const res = await API.get('/api/group/'); + if (res.data.success) { + setGroupOptions( + res.data.data.map((group) => ({ + label: group, + value: group, + })) + ); + } + } catch (error) { + showError(t('获取分组列表失败')); + } + }; + const submit = async (values) => { setLoading(true); const res = await API.post(`/api/user/`, values); @@ -70,6 +89,13 @@ const AddUserModal = (props) => { props.handleClose(); }; + // 组件加载时获取分组列表 + useEffect(() => { + if (props.visible) { + fetchGroups(); + } + }, [props.visible]); + return ( <> { showClear /> + + + . + +For commercial licensing, please contact support@quantumnous.com ++*/ + +import React, { useState, useRef, useEffect } from 'react'; +import { + Modal, + Form, + Button, + Space, + Spin, + Avatar, + Typography, + Tag, +} from '@douyinfe/semi-ui'; +import { + IconSave, + IconClose, + IconUserGroup, +} from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { useSidebar } from '../../../../hooks/common/useSidebar'; + +const EditUserGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => { + const { t } = useTranslation(); + const formApiRef = useRef(null); + const [loading, setLoading] = useState(false); + const { finalConfig, loading: sidebarLoading } = useSidebar(); + + // 检查用户权限 + const getUserRole = () => { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + return user?.role || 0; + }; + + const isRoot = () => getUserRole() >= 100; + const isAdmin = () => getUserRole() >= 10; + + // 检查是否有分组管理权限 + const hasGroupManagementPermission = () => { + // 如果侧边栏配置还在加载中,暂时拒绝访问 + if (sidebarLoading) { + return false; + } + + // 超级管理员始终有权限 + if (isRoot()) { + return true; + } + + // 管理员需要检查权限配置 + if (isAdmin()) { + // 从useSidebar钩子获取最终的权限配置 + const userSection = finalConfig?.admin?.user; + + // 检查用户管理模块是否启用 + if (!userSection || userSection.enabled === false) { + return false; + } + + // 检查分组管理子功能是否启用 + return userSection.groupManagement === true; + } + + // 普通用户无权访问 + return false; + }; + + const isEdit = editingGroup && editingGroup.id; + const isSystemGroup = editingGroup && ( + editingGroup.name === 'default' || + editingGroup.name === 'vip' || + editingGroup.name === 'svip' + ); + + // 获取分组显示名称 + const getGroupDisplayName = (groupName) => { + if (groupName === 'default') { + return t('默认'); + } + return groupName; + }; + + const getInitValues = () => ({ + name: editingGroup?.name || '', + description: editingGroup?.description || '', + ratio: editingGroup?.ratio ?? 1.0, + }); + + const submit = async (values) => { + // 检查权限 + if (!hasGroupManagementPermission()) { + showError(t('无权访问分组管理功能')); + onClose(); + return; + } + + setLoading(true); + try { + const ratioNum = + typeof values.ratio === 'number' ? values.ratio : parseFloat(values.ratio); + const data = { + ...values, + ratio: Number.isFinite(ratioNum) ? ratioNum : 1.0, + }; + + if (isEdit) { + data.id = editingGroup.id; + } + + const url = isEdit ? '/api/user_group' : '/api/user_group'; + const method = isEdit ? 'PUT' : 'POST'; + + const res = await API[method.toLowerCase()](url, data); + const { success, message } = res.data; + + if (success) { + showSuccess(isEdit ? t('分组更新成功!') : t('分组创建成功!')); + onSuccess(); + } else { + showError(message); + } + } catch (error) { + if (error.response?.status === 403) { + showError(t('无权访问分组管理功能')); + onClose(); + } else { + showError(isEdit ? t('分组更新失败') : t('分组创建失败')); + } + } + setLoading(false); + }; + + const handleCancel = () => { + onClose(); + }; + + // 重置表单当编辑分组改变时 + useEffect(() => { + if (visible && formApiRef.current) { + formApiRef.current.setValues(getInitValues()); + } + }, [visible, editingGroup]); + + return ( + + + + + + {isEdit ? t('编辑分组') : t('新建分组')} + + {isEdit && ( + + {getGroupDisplayName(editingGroup.name)} + + )} + + } + visible={visible} + onCancel={handleCancel} + width={500} + footer={ +
+ + + + +
+ } + closeIcon={null} + > + +
(formApiRef.current = api)} + onSubmit={submit} + onSubmitFail={(errs) => { + const first = Object.values(errs)[0]; + if (first) showError(Array.isArray(first) ? first[0] : first); + formApiRef.current?.scrollToError(); + }} + > +
+ + + + + + + {isSystemGroup && ( +
+ + {t('提示:')} + {t('这是系统默认分组,只能修改描述和倍率,不能修改名称或删除。')} + +
+ )} +
+
+
+
+ ); +}; + +export default EditUserGroupModal; diff --git a/web/src/components/table/users/modals/UserGroupManagement.jsx b/web/src/components/table/users/modals/UserGroupManagement.jsx new file mode 100644 index 000000000000..b2fbc564a9b0 --- /dev/null +++ b/web/src/components/table/users/modals/UserGroupManagement.jsx @@ -0,0 +1,378 @@ +/* +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 } from 'react'; +import { + SideSheet, + Button, + Space, + Card, + Avatar, + Typography, + Spin, + Empty, + Popconfirm, + Tag, +} from '@douyinfe/semi-ui'; +import { + IconUserGroup, + IconPlus, +} from '@douyinfe/semi-icons'; +import { + IllustrationNoResult, + IllustrationNoResultDark, +} from '@douyinfe/semi-illustrations'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '../../../../hooks/common/useIsMobile'; +import { API, showError, showSuccess } from '../../../../helpers'; +import CardTable from '../../../common/ui/CardTable'; +import EditUserGroupModal from './EditUserGroupModal'; +import { useSidebar } from '../../../../hooks/common/useSidebar'; + +const UserGroupManagement = ({ visible, onClose, onGroupUpdated }) => { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const [loading, setLoading] = useState(false); + const [groups, setGroups] = useState([]); + const [showEdit, setShowEdit] = useState(false); + const [editingGroup, setEditingGroup] = useState({ id: undefined }); + const { finalConfig, loading: sidebarLoading } = useSidebar(); + + // 检查用户权限 + const getUserRole = () => { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + return user?.role || 0; + }; + + const isRoot = () => getUserRole() >= 100; + const isAdmin = () => getUserRole() >= 10; + + // 检查是否有分组管理权限 + const hasGroupManagementPermission = () => { + // 如果侧边栏配置还在加载中,返回null表示未判定 + if (sidebarLoading) { + return null; + } + + // 超级管理员始终有权限 + if (isRoot()) { + return true; + } + + // 管理员需要检查权限配置 + if (isAdmin()) { + // 从useSidebar钩子获取最终的权限配置 + const userSection = finalConfig?.admin?.user; + + // 检查用户管理模块是否启用 + if (!userSection || userSection.enabled === false) { + return false; + } + + // 检查分组管理子功能是否启用 + return userSection.groupManagement === true; + } + + // 普通用户无权访问 + return false; + }; + + // 加载分组列表 + const loadGroups = async () => { + // 检查权限 + const perm = hasGroupManagementPermission(); + if (perm === null) { + return; // 等待权限加载完成后再拉取 + } + if (perm === false) { + showError(t('无权访问分组管理功能')); + onClose(); + return; + } + + setLoading(true); + try { + const res = await API.get('/api/user_group'); + if (res.data.success) { + setGroups(res.data.data || []); + } else { + showError(res.data.message || t('获取分组列表失败')); + // 如果是权限错误,关闭模态框 + if (res.status === 403) { + onClose(); + } + } + } catch (error) { + showError(t('获取分组列表失败')); + // 如果是权限错误,关闭模态框 + if (error.response?.status === 403) { + onClose(); + } + } + setLoading(false); + }; + + // 删除分组 + const deleteGroup = async (id) => { + // 检查权限 + if (!hasGroupManagementPermission()) { + showError(t('无权访问分组管理功能')); + return; + } + + try { + const res = await API.delete(`/api/user_group/${id}`); + if (res.data.success) { + showSuccess(t('删除成功')); + loadGroups(); + } else { + showError(res.data.message || t('删除失败')); + } + } catch (error) { + if (error.response?.status === 403) { + showError(t('无权访问分组管理功能')); + onClose(); + } else { + showError(t('删除失败')); + } + } + }; + + // 编辑分组 + const handleEdit = (group = {}) => { + // 检查权限 + const perm = hasGroupManagementPermission(); + if (perm === null) return; // 等待权限加载 + if (perm === false) { + showError(t('无权访问分组管理功能')); + return; + } + + setEditingGroup(group); + setShowEdit(true); + }; + + // 关闭编辑 + const closeEdit = () => { + setShowEdit(false); + setTimeout(() => { + setEditingGroup({ id: undefined }); + }, 300); + }; + + // 编辑成功回调 + const handleEditSuccess = () => { + closeEdit(); + loadGroups(); + // 通知父组件刷新用户数据 + if (onGroupUpdated) { + onGroupUpdated(); + } + }; + + // 获取分组显示名称 + const getGroupDisplayName = (groupName) => { + if (groupName === 'default') { + return t('默认'); + } + return groupName; + }; + + // 获取分组描述的翻译 + const getGroupDescription = (groupName, originalDescription) => { + // 对于系统默认分组,使用翻译 + if (groupName === 'default' && originalDescription === '默认分组') { + return t('默认分组'); + } + if (groupName === 'vip' && originalDescription === 'VIP分组') { + return t('VIP分组'); + } + if (groupName === 'svip' && originalDescription === 'SVIP分组') { + return t('SVIP分组'); + } + // 对于用户自定义分组,使用原始描述 + return originalDescription; + }; + + // 表格列定义 + const columns = [ + { + title: 'ID', + dataIndex: 'id', + width: 80, + }, + { + title: t('分组名称'), + dataIndex: 'name', + render: (text, record) => ( +
+ + {getGroupDisplayName(text)} + + {(record.name === 'default' || record.name === 'vip' || record.name === 'svip') && ( + + {t('系统默认')} + + )} +
+ ), + }, + { + title: t('分组描述'), + dataIndex: 'description', + render: (text, record) => { + const translatedDescription = getGroupDescription(record.name, text); + return translatedDescription || {t('无描述')}; + }, + }, + { + title: t('分组倍率'), + dataIndex: 'ratio', + width: 100, + render: (text) => ( + + {text} + + ), + }, + { + title: t('创建时间'), + dataIndex: 'created_time', + width: 150, + render: (text) => new Date(text * 1000).toLocaleString(), + }, + { + title: '', + key: 'action', + fixed: 'right', + width: 140, + render: (_, record) => ( + + + {record.name !== 'default' && record.name !== 'vip' && record.name !== 'svip' && ( + deleteGroup(record.id)} + > + + + )} + + ), + }, + ]; + + useEffect(() => { + if (visible && !sidebarLoading) { + loadGroups(); + } + }, [visible, sidebarLoading]); + + return ( + <> + + + + + {t('用户分组管理')} + + } + visible={visible} + onCancel={onClose} + width={isMobile ? '100%' : 1000} + bodyStyle={{ padding: '0' }} + closeIcon={null} + > + +
+ +
+ + + +
+ {t('分组列表')} +
+ {t('管理用户分组,设置分组倍率')} +
+
+
+
+ +
+ {groups.length > 0 ? ( + + ) : ( + + } + darkModeImage={ + + } + description={t('暂无用户分组')} + style={{ padding: 30 }} + /> + )} +
+
+
+
+ + {/* 编辑组件 */} + + + ); +}; + +export default UserGroupManagement; diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index 929a47e39b04..32d7dfae4bf1 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -60,6 +60,8 @@ const TopUp = () => { const [enableStripeTopUp, setEnableStripeTopUp] = useState( statusState?.status?.enable_stripe_topup || false, ); + const [invitationEnabled, setInvitationEnabled] = useState(false); // 初始为false,避免闪烁 + const [invitationConfigLoaded, setInvitationConfigLoaded] = useState(false); // 添加配置加载状态 const [statusLoading, setStatusLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -359,6 +361,44 @@ const TopUp = () => { } }; + // 获取邀请功能配置状态 + const getInvitationConfig = async () => { + try { + const res = await API.get('/api/status'); + const { success, data } = res.data; + if (success) { + //console.log('从status接口获取到的邀请功能配置:', data.invitation_enabled); + const enabled = data.invitation_enabled === true; + //console.log('邀请功能状态:', enabled); + setInvitationEnabled(enabled); + setInvitationConfigLoaded(true); + // 只有在邀请功能启用时才获取邀请链接 + if (enabled && !affFetchedRef.current) { + affFetchedRef.current = true; + getAffLink(); + } + } else { + // API调用失败,使用后端默认值(true) + //console.log('status接口调用失败,使用默认值true'); + setInvitationEnabled(true); + setInvitationConfigLoaded(true); + if (!affFetchedRef.current) { + affFetchedRef.current = true; + getAffLink(); + } + } + } catch (error) { + //console.error('获取邀请功能配置失败:', error); + // 出错时使用后端默认值(true) + setInvitationEnabled(true); + setInvitationConfigLoaded(true); + if (!affFetchedRef.current) { + affFetchedRef.current = true; + getAffLink(); + } + } + }; + // 划转邀请额度 const transfer = async () => { if (transferAmount < getQuotaPerUnit()) { @@ -389,18 +429,55 @@ const TopUp = () => { getUserQuota().then(); } setTransferAmount(getQuotaPerUnit()); - }, []); + getInvitationConfig().then(); - useEffect(() => { - if (affFetchedRef.current) return; - affFetchedRef.current = true; - getAffLink().then(); - }, []); + let payMethods = localStorage.getItem('pay_methods'); + try { + payMethods = JSON.parse(payMethods); + if (payMethods && payMethods.length > 0) { + // 检查name和type是否为空 + payMethods = payMethods.filter((method) => { + return method.name && method.type; + }); + // 如果没有color,则设置默认颜色 + payMethods = payMethods.map((method) => { + if (!method.color) { + if (method.type === 'alipay') { + method.color = 'rgba(var(--semi-blue-5), 1)'; + } else if (method.type === 'wxpay') { + method.color = 'rgba(var(--semi-green-5), 1)'; + } else if (method.type === 'stripe') { + method.color = 'rgba(var(--semi-purple-5), 1)'; + } else { + method.color = 'rgba(var(--semi-primary-5), 1)'; + } + } + return method; + }); + } else { + payMethods = []; + } - // 在 statusState 可用时获取充值信息 - useEffect(() => { - getTopupInfo().then(); - }, []); + // 如果启用了 Stripe 支付,添加到支付方法列表 + if (statusState?.status?.enable_stripe_topup) { + const hasStripe = payMethods.some((method) => method.type === 'stripe'); + if (!hasStripe) { + payMethods.push({ + name: 'Stripe', + type: 'stripe', + color: 'rgba(var(--semi-purple-5), 1)', + }); + } + } + + setPayMethods(payMethods); + } catch (e) { + console.log(e); + showError(t('支付方式配置错误, 请联系管理员')); + } + }, [statusState?.status?.enable_stripe_topup]); + + // 移除独立的getAffLink调用,现在由getInvitationConfig统一处理 useEffect(() => { if (statusState?.status) { @@ -537,9 +614,9 @@ const TopUp = () => { {/* 用户信息头部 */}
-
+
{/* 左侧充值区域 */} -
+
{ />
- {/* 右侧信息区域 */} -
- -
+ {/* 右侧信息区域 - 仅在配置加载完成且邀请功能启用时显示 */} + {invitationConfigLoaded && invitationEnabled && ( +
+ +
+ )}
diff --git a/web/src/constants/user.constants.js b/web/src/constants/user.constants.js index 05d3e1fa6e52..4cf1f7a51f5e 100644 --- a/web/src/constants/user.constants.js +++ b/web/src/constants/user.constants.js @@ -36,3 +36,62 @@ export const userConstants = { DELETE_SUCCESS: 'USERS_DELETE_SUCCESS', DELETE_FAILURE: 'USERS_DELETE_FAILURE', }; + +/** + * 用户角色常量 - 与后端保持一致 + * 对应后端 common/constants.go 中的角色定义 + */ +export const USER_ROLES = { + GUEST: 0, // RoleGuestUser + COMMON: 1, // RoleCommonUser + ADMIN: 10, // RoleAdminUser + ROOT: 100, // RoleRootUser +}; + +/** + * 检查用户是否为管理员(包括超级管理员) + * @param {number} role - 用户角色 + * @returns {boolean} + */ +export const isAdmin = (role) => { + return role === USER_ROLES.ADMIN || role === USER_ROLES.ROOT; +}; + +/** + * 检查用户是否为超级管理员 + * @param {number} role - 用户角色 + * @returns {boolean} + */ +export const isRoot = (role) => { + return role === USER_ROLES.ROOT; +}; + +/** + * 检查用户是否为普通用户 + * @param {number} role - 用户角色 + * @returns {boolean} + */ +export const isCommonUser = (role) => { + return role === USER_ROLES.COMMON; +}; + +/** + * 获取角色显示名称 + * @param {number} role - 用户角色 + * @param {function} t - 翻译函数 + * @returns {string} + */ +export const getRoleDisplayName = (role, t) => { + switch (role) { + case USER_ROLES.COMMON: + return t('普通用户'); + case USER_ROLES.ADMIN: + return t('管理员'); + case USER_ROLES.ROOT: + return t('超级管理员'); + case USER_ROLES.GUEST: + return t('访客'); + default: + return t('未知身份'); + } +}; diff --git a/web/src/context/User/reducer.js b/web/src/context/User/reducer.js index 80275e1fa926..2e8f2db8af64 100644 --- a/web/src/context/User/reducer.js +++ b/web/src/context/User/reducer.js @@ -20,11 +20,18 @@ For commercial licensing, please contact support@quantumnous.com export const reducer = (state, action) => { switch (action.type) { case 'login': + // 用户登录时,头像数据通过独立端点获取和缓存 return { ...state, user: action.payload, }; case 'logout': + // 当用户登出时,清理头像缓存和会话标记 + if (state.user?.id) { + import('../../helpers/userDataManager').then(({ cleanupOnLogout }) => { + cleanupOnLogout(state.user.id); + }); + } return { ...state, user: undefined, diff --git a/web/src/helpers/api.js b/web/src/helpers/api.js index b7092fe775e2..84ab6c4e02ac 100644 --- a/web/src/helpers/api.js +++ b/web/src/helpers/api.js @@ -25,6 +25,7 @@ import { } from './utils'; import axios from 'axios'; import { MESSAGE_ROLES } from '../constants/playground.constants'; +import i18next from 'i18next'; export let API = axios.create({ baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL @@ -185,15 +186,34 @@ export const processModelsData = (data, currentModel) => { return { modelOptions, selectedModel }; }; +// 获取分组描述的翻译 +const getGroupDescription = (groupName, originalDescription) => { + // 对于系统默认分组,使用翻译 + if (groupName === 'default' && originalDescription === '默认分组') { + return i18next.t('默认分组'); + } + if (groupName === 'vip' && originalDescription === 'VIP分组') { + return i18next.t('VIP分组'); + } + if (groupName === 'svip' && originalDescription === 'SVIP分组') { + return i18next.t('SVIP分组'); + } + // 对于用户自定义分组,使用原始描述 + return originalDescription; +}; + // 处理分组数据 export const processGroupsData = (data, userGroup) => { - let groupOptions = Object.entries(data).map(([group, info]) => ({ - label: - info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc, - value: group, - ratio: info.ratio, - fullLabel: info.desc, - })); + let groupOptions = Object.entries(data).map(([group, info]) => { + const translatedDesc = getGroupDescription(group, info.desc); + return { + label: + translatedDesc.length > 20 ? translatedDesc.substring(0, 20) + '...' : translatedDesc, + value: group, + ratio: info.ratio, + fullLabel: translatedDesc, + }; + }); if (groupOptions.length === 0) { groupOptions = [ diff --git a/web/src/helpers/avatarCache.js b/web/src/helpers/avatarCache.js new file mode 100644 index 000000000000..c4af14cea456 --- /dev/null +++ b/web/src/helpers/avatarCache.js @@ -0,0 +1,72 @@ +/* +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 +*/ + +/** + * 头像缓存管理工具 + * 核心功能:缓存存储、读取、清理 + */ + +const AVATAR_CACHE_KEY = 'user_avatar'; + +/** + * 获取缓存的头像数据 + * @param {number} userId - 用户ID + * @returns {string|null} 头像数据或null + */ +export const getCachedAvatar = (userId) => { + try { + const avatarData = localStorage.getItem(`${AVATAR_CACHE_KEY}_${userId}`); + return avatarData || null; + } catch (error) { + console.error('获取头像缓存失败:', error); + return null; + } +}; + +/** + * 缓存头像数据 + * @param {number} userId - 用户ID + * @param {string} avatarData - 头像数据(base64) + */ +export const cacheAvatar = (userId, avatarData) => { + try { + if (!avatarData) { + clearAvatarCache(userId); + return; + } + + localStorage.setItem(`${AVATAR_CACHE_KEY}_${userId}`, avatarData); + } catch (error) { + console.error('缓存头像失败:', error); + } +}; + +/** + * 清除指定用户的头像缓存 + * @param {number} userId - 用户ID + */ +export const clearAvatarCache = (userId) => { + try { + localStorage.removeItem(`${AVATAR_CACHE_KEY}_${userId}`); + } catch (error) { + console.error('清除头像缓存失败:', error); + } +}; + + diff --git a/web/src/helpers/data.js b/web/src/helpers/data.js index b894a953c318..9c938246b9b8 100644 --- a/web/src/helpers/data.js +++ b/web/src/helpers/data.js @@ -19,8 +19,18 @@ For commercial licensing, please contact support@quantumnous.com export function setStatusData(data) { localStorage.setItem('status', JSON.stringify(data)); - localStorage.setItem('system_name', data.system_name); localStorage.setItem('logo', data.logo); + + // 同步系统名称并触发事件(支持清空还原默认标题) + const name = (data.system_name ?? '').toString().trim(); + if (name) { + localStorage.setItem('system_name', name); + } else { + localStorage.removeItem('system_name'); + } + window.dispatchEvent(new CustomEvent('systemNameUpdated', { + detail: { systemName: name } + })); localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('quota_per_unit', data.quota_per_unit); localStorage.setItem('display_in_currency', data.display_in_currency); diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 65332701bbb6..8de251036853 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -629,6 +629,14 @@ export function renderGroup(group) { premium: 'red', }; + // 获取分组显示名称 + const getGroupDisplayName = (groupName) => { + if (groupName === 'default') { + return i18next.t('默认'); + } + return groupName; + }; + const groups = group.split(',').sort(); return ( @@ -650,7 +658,7 @@ export function renderGroup(group) { } }} > - {group} + {getGroupDisplayName(group)} ))} diff --git a/web/src/helpers/userDataManager.js b/web/src/helpers/userDataManager.js new file mode 100644 index 000000000000..0ae2b43a3bc9 --- /dev/null +++ b/web/src/helpers/userDataManager.js @@ -0,0 +1,114 @@ +/* +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 { API } from './api'; +import { + getCachedAvatar, + cacheAvatar, + clearAvatarCache +} from './avatarCache'; + +/** + * 用户数据管理器 + */ + +/** + * 获取用户数据(包含头像) + * @returns {Promise} 用户数据 + */ +export const getUserData = async () => { + try { + // 获取基础用户数据 + const res = await API.get('/api/user/self'); + const { success, message, data } = res.data; + + if (!success) { + throw new Error(message); + } + + // 如果有用户ID,尝试获取头像 + if (data.id) { + const cachedAvatar = getCachedAvatar(data.id); + if (cachedAvatar) { + data.avatar = cachedAvatar; + } else { + // 如果没有缓存,尝试从头像端点获取 + try { + const avatarRes = await API.get('/api/user/avatar'); + if (avatarRes.data.success && avatarRes.data.data.avatar) { + data.avatar = avatarRes.data.data.avatar; + cacheAvatar(data.id, data.avatar); + } + } catch (avatarError) { + console.log('获取头像失败,使用默认头像'); + data.avatar = ''; + } + } + } + + return { success: true, data }; + } catch (error) { + console.error('获取用户数据失败:', error); + return { success: false, message: error.message }; + } +}; + +/** + * 更新用户头像 + * @param {string} avatarData - 头像数据(base64) + * @param {Object} userInfo - 用户基本信息(仅用于缓存) + * @returns {Promise} 更新结果 + */ +export const updateUserAvatar = async (avatarData, userInfo = {}) => { + try { + const payload = { + avatar: avatarData, + }; + + const res = await API.put('/api/user/self', payload); + const { success, message } = res.data; + + if (success && userInfo.id) { + // 更新成功后,立即更新缓存 + cacheAvatar(userInfo.id, avatarData); + console.log(`头像上传成功并已更新缓存 - 用户ID: ${userInfo.id}`); + } + + return { success, message }; + } catch (error) { + console.error('更新用户头像失败:', error); + return { success: false, message: error.message }; + } +}; + +/** + * 用户登出时的清理工作 + * @param {number} userId - 用户ID + */ +export const cleanupOnLogout = (userId) => { + try { + // 清除头像缓存 + if (userId) { + clearAvatarCache(userId); + console.log(`用户登出,已清除头像缓存 - 用户ID: ${userId}`); + } + } catch (error) { + console.error('登出清理失败:', error); + } +}; diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index e446ea69d370..e7c4a126d1e2 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -294,16 +294,17 @@ export function setPromptShown(id) { export function compareObjects(oldObject, newObject) { const changedProperties = []; - // 比较两个对象的属性 - for (const key in oldObject) { - if (oldObject.hasOwnProperty(key) && newObject.hasOwnProperty(key)) { - if (oldObject[key] !== newObject[key]) { - changedProperties.push({ - key: key, - oldValue: oldObject[key], - newValue: newObject[key], - }); - } + // 获取两个对象的所有键 + const allKeys = new Set([...Object.keys(oldObject), ...Object.keys(newObject)]); + + // 比较所有键的值 + for (const key of allKeys) { + if (oldObject[key] !== newObject[key]) { + changedProperties.push({ + key: key, + oldValue: oldObject[key], + newValue: newObject[key], + }); } } diff --git a/web/src/hooks/common/useHeaderBar.js b/web/src/hooks/common/useHeaderBar.js index 3458a1d163da..375e57dd355d 100644 --- a/web/src/hooks/common/useHeaderBar.js +++ b/web/src/hooks/common/useHeaderBar.js @@ -52,7 +52,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; // 获取顶栏模块配置 - const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + const headerNavModulesConfig = statusState?.status?.header_nav_modules; // 使用useMemo确保headerNavModules正确响应statusState变化 const headerNavModules = useMemo(() => { diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 5dce44f9ec2e..8c8fd7f5f502 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/web/src/hooks/common/useSidebar.js @@ -17,17 +17,22 @@ 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 { useState, useEffect } from 'react'; import { API } from '../../helpers'; +// 创建一个全局事件系统来同步所有useSidebar实例 +if (!window.sidebarEventTarget) { + window.sidebarEventTarget = new EventTarget(); +} +const sidebarEventTarget = window.sidebarEventTarget; +const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; + export const useSidebar = () => { - const [statusState] = useContext(StatusContext); - const [userConfig, setUserConfig] = useState(null); + const [sidebarConfig, setSidebarConfig] = useState(null); const [loading, setLoading] = useState(true); // 默认配置 - const defaultAdminConfig = { + const defaultSidebarConfig = { chat: { enabled: true, playground: true, @@ -51,144 +56,73 @@ export const useSidebar = () => { 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; - } + user: { + enabled: true, + groupManagement: true // 默认启用分组管理 + }, + setting: true } - return defaultAdminConfig; - }, [statusState?.status?.SidebarModulesAdmin]); + }; - // 加载用户配置的通用方法 - const loadUserConfig = async () => { + // 加载侧边栏配置的方法 + const loadSidebarConfig = 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); + if (res.data.success && res.data.data.sidebar_config) { + setSidebarConfig(res.data.data.sidebar_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); + // 使用默认配置 + setSidebarConfig(defaultSidebarConfig); } } 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); + // 出错时使用默认配置 + setSidebarConfig(defaultSidebarConfig); } finally { setLoading(false); } }; - // 刷新用户配置的方法(供外部调用) + // 刷新侧边栏配置的方法(供外部调用) const refreshUserConfig = async () => { - if (Object.keys(adminConfig).length > 0) { - await loadUserConfig(); - } + await loadSidebarConfig(); + // 触发全局刷新事件,通知所有useSidebar实例更新 + sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT)); }; - // 加载用户配置 + // 初始加载配置 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; - } + loadSidebarConfig(); + }, []); - // 遍历所有区域 - 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; + // 监听全局刷新事件 + useEffect(() => { + const handleRefresh = () => { + loadSidebarConfig(); + }; - const adminAllowed = adminSection[moduleKey]; - // 当userSection存在时检查模块状态,否则默认为true - const userAllowed = userSection - ? userSection[moduleKey] !== false - : true; + sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); - result[sectionKey][moduleKey] = - adminAllowed && userAllowed && sectionEnabled; - }); - }); + return () => { + sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh); + }; + }, []); - return result; - }, [adminConfig, userConfig]); + // 直接使用后端计算好的最终配置 + const finalConfig = sidebarConfig || {}; // 检查特定功能是否应该显示 const isModuleVisible = (sectionKey, moduleKey = null) => { if (moduleKey) { - return finalConfig[sectionKey]?.[moduleKey] === true; + const moduleValue = finalConfig[sectionKey]?.[moduleKey]; + // 处理布尔值和嵌套对象两种情况 + if (typeof moduleValue === 'boolean') { + return moduleValue === true; + } else if (typeof moduleValue === 'object' && moduleValue !== null) { + // 对于嵌套对象,检查其enabled状态 + return moduleValue.enabled === true; + } + return false; } else { return finalConfig[sectionKey]?.enabled === true; } @@ -199,9 +133,19 @@ export const useSidebar = () => { const section = finalConfig[sectionKey]; if (!section?.enabled) return false; - return Object.keys(section).some( - (key) => key !== 'enabled' && section[key] === true, - ); + return Object.keys(section).some(key => { + if (key === 'enabled') return false; + + const moduleValue = section[key]; + // 处理布尔值和嵌套对象两种情况 + if (typeof moduleValue === 'boolean') { + return moduleValue === true; + } else if (typeof moduleValue === 'object' && moduleValue !== null) { + // 对于嵌套对象,检查其enabled状态 + return moduleValue.enabled === true; + } + return false; + }); }; // 获取区域的可见功能列表 @@ -209,15 +153,24 @@ export const useSidebar = () => { const section = finalConfig[sectionKey]; if (!section?.enabled) return []; - return Object.keys(section).filter( - (key) => key !== 'enabled' && section[key] === true, - ); + return Object.keys(section).filter(key => { + if (key === 'enabled') return false; + + const moduleValue = section[key]; + // 处理布尔值和嵌套对象两种情况 + if (typeof moduleValue === 'boolean') { + return moduleValue === true; + } else if (typeof moduleValue === 'object' && moduleValue !== null) { + // 对于嵌套对象,检查其enabled状态 + return moduleValue.enabled === true; + } + return false; + }); }; return { loading, - adminConfig, - userConfig, + sidebarConfig, finalConfig, isModuleVisible, hasSectionVisibleModules, diff --git a/web/src/hooks/common/useUserPermissions.js b/web/src/hooks/common/useUserPermissions.js index 8d57f972ef49..1428fcc2da5c 100644 --- a/web/src/hooks/common/useUserPermissions.js +++ b/web/src/hooks/common/useUserPermissions.js @@ -37,14 +37,14 @@ export const useUserPermissions = () => { if (res.data.success) { const userPermissions = res.data.data.permissions; setPermissions(userPermissions); - console.log('用户权限加载成功:', userPermissions); + //console.log('用户权限加载成功:', userPermissions); } else { setError(res.data.message || '获取权限失败'); - console.error('获取权限失败:', res.data.message); + //console.error('获取权限失败:', res.data.message); } } catch (error) { setError('网络错误,请重试'); - console.error('加载用户权限异常:', error); + //console.error('加载用户权限异常:', error); } finally { setLoading(false); } diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index b51bcc40c5c2..05e8e37e528a 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -214,12 +214,18 @@ export const useDashboardData = (userState, userDispatch, statusState) => { }, [activeUptimeTab]); const getUserData = useCallback(async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); + try { + // 使用用户数据获取(包括头像) + const { getUserData } = await import('../../helpers/userDataManager'); + const result = await getUserData(); + if (result.success) { + userDispatch({ type: 'login', payload: result.data }); + } else { + showError(result.message); + } + } catch (error) { + console.error('获取用户数据失败:', error); + showError('获取用户数据失败'); } }, [userDispatch]); diff --git a/web/src/hooks/model-pricing/useModelPricingData.jsx b/web/src/hooks/model-pricing/useModelPricingData.jsx index 799cdc1367df..37cca3856e87 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.jsx +++ b/web/src/hooks/model-pricing/useModelPricingData.jsx @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useState, useEffect, useContext, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { API, copy, showError, showInfo, showSuccess } from '../../helpers'; import { Modal } from '@douyinfe/semi-ui'; import { UserContext } from '../../context/User'; @@ -26,6 +27,7 @@ import { StatusContext } from '../../context/Status'; export const useModelPricingData = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const [searchValue, setSearchValue] = useState(''); const compositionRef = useRef({ isComposition: false }); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -195,35 +197,50 @@ export const useModelPricingData = () => { const loadPricing = async () => { setLoading(true); - let url = '/api/pricing'; - const res = await API.get(url); - const { - success, - message, - data, - vendors, - group_ratio, - usable_group, - supported_endpoint, - auto_groups, - } = res.data; - if (success) { - setGroupRatio(group_ratio); - setUsableGroup(usable_group); - setSelectedGroup('all'); - // 构建供应商 Map 方便查找 - const vendorMap = {}; - if (Array.isArray(vendors)) { - vendors.forEach((v) => { - vendorMap[v.id] = v; - }); + try { + let url = '/api/pricing'; + const res = await API.get(url); + const { + success, + message, + data, + vendors, + group_ratio, + usable_group, + supported_endpoint, + auto_groups, + } = res.data; + if (success) { + setGroupRatio(group_ratio); + setUsableGroup(usable_group); + setSelectedGroup('all'); + // 构建供应商 Map 方便查找 + const vendorMap = {}; + if (Array.isArray(vendors)) { + vendors.forEach((v) => { + vendorMap[v.id] = v; + }); + } + setVendorsMap(vendorMap); + setEndpointMap(supported_endpoint || {}); + setAutoGroups(auto_groups || []); + setModelsFormat(data, group_ratio, vendorMap); + } else { + showError(message); } - setVendorsMap(vendorMap); - setEndpointMap(supported_endpoint || {}); - setAutoGroups(auto_groups || []); - setModelsFormat(data, group_ratio, vendorMap); - } else { - showError(message); + } catch (error) { + // 检查是否是403权限错误 + if (error.response && error.response.status === 403) { + // 未登录用户跳转登录页;已登录但无权限(理论上极少见)跳转禁止访问 + if (!userState?.user) { + navigate('/login'); + } else { + navigate('/forbidden'); + } + return; + } + // 其他错误正常处理 + showError(error.message || t('加载模型广场数据失败')); } setLoading(false); }; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c86fb0e7f608..d5c9eda5dc6b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -384,6 +384,7 @@ "普通用户": "Normal User", "管理员": "Admin", "超级管理员": "Super Admin", + "访客": "Guest", "未知身份": "Unknown Identity", "已激活": "Activated", "已封禁": "Banned", @@ -696,7 +697,6 @@ "显": "show", "当前分组可用": "Available in current group", "当前分组不可用": "The current group is unavailable", - "提示:": "input:", "输入:": "input:", "补全:": "output:", "输出:": "output:", @@ -814,7 +814,6 @@ "删除所选令牌": "Delete selected token", "请先选择要删除的令牌!": "Please select the token to be deleted!", "已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!", - "删除失败": "Delete failed", "复制令牌": "Copy token", "请选择你的复制方式": "Please select your copy method", "名称+密钥": "Name + key", @@ -2009,9 +2008,9 @@ "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}", "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}", "为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}", - "顶栏管理": "Header Management", - "控制顶栏模块显示状态,全局生效": "Control header module display status, global effect", - "用户主页,展示系统信息": "User homepage, displaying system information", + "导航栏管理": "Navigation bar management", + "控制导航栏模块显示状态,全局生效": "Control header module display status, global effect", + "系统主页": "System homepage", "用户控制面板,管理账户": "User control panel for account management", "模型广场": "Model Marketplace", "模型定价,需要登录访问": "Model pricing, requires login to access", @@ -2062,7 +2061,7 @@ "系统设置": "System Settings", "系统参数配置": "System parameter configuration", "边栏设置": "Sidebar Settings", - "您可以个性化设置侧边栏的要显示功能": "You can customize the sidebar functions to display", + "您可以个性化设置侧边栏要显示的功能": "You can customize which features are shown in the sidebar", "保存边栏设置": "Save Sidebar Settings", "侧边栏设置保存成功": "Sidebar settings saved successfully", "需要登录访问": "Require Login", @@ -2091,8 +2090,63 @@ "模型社区需要大家的共同维护,如发现数据有误或想贡献新的模型数据,请访问:": "The model community needs everyone's contribution. If you find incorrect data or want to contribute new models, please visit:", "是": "Yes", "否": "No", - "原价": "Original price", - "优惠": "Discount", - "折": "% off", - "节省": "Save" + "邀请功能": "Invitation Feature", + "关闭后:不再启用邀请奖励功能": "When disabled: invitation reward feature will not be enabled", + "分组管理": "Group Management", + "用户分组管理": "User Group Management", + "分组列表": "Group List", + "管理用户分组,设置分组倍率": "Manage user groups and set group ratios", + "新建分组": "Create Group", + "编辑分组": "Edit Group", + "请输入分组名称": "Please enter group name", + "分组名称不能为空": "Group name cannot be empty", + "分组名称不能超过64个字符": "Group name cannot exceed 64 characters", + "分组名称只能包含字母、数字、下划线和连字符": "Group name can only contain letters, numbers, underscores and hyphens", + "系统默认分组,名称不可修改": "System default group, name cannot be modified", + "分组描述": "Group Description", + "请输入分组描述(可选)": "Please enter group description (optional)", + "分组的详细描述,用于说明分组用途": "Detailed description of the group, used to explain the group purpose", + "请输入分组倍率": "Please enter group ratio", + "分组倍率不能为空": "Group ratio cannot be empty", + "分组倍率不能小于0": "Group ratio cannot be less than 0", + "分组的计费倍率,影响该分组用户的费用计算": "Billing ratio of the group, affects cost calculation for users in this group", + "倍": "x", + "系统默认": "System Default", + "无描述": "No Description", + "确定删除此分组?": "Are you sure to delete this group?", + "删除后无法恢复,请确认该分组未被用户使用": "Cannot be recovered after deletion, please confirm that this group is not being used by users", + "删除成功": "Deleted successfully", + "删除失败": "Delete failed", + "分组名称已存在": "Group name already exists", + "缺少分组 ID": "Missing group ID", + "该分组正在被用户使用,无法删除": "This group is being used by users and cannot be deleted", + "不能删除系统默认分组": "Cannot delete system default groups", + "分组创建成功!": "Group created successfully!", + "分组更新成功!": "Group updated successfully!", + "分组创建失败": "Group creation failed", + "分组更新失败": "Group update failed", + "获取分组列表失败": "Failed to get group list", + "暂无用户分组": "No user groups", + "提示:": "Note: ", + "这是系统默认分组,只能修改描述和倍率,不能修改名称或删除。": "This is a system default group. You can only modify the description and ratio, but cannot modify the name or delete it.", + "控制管理员是否可以访问分组管理功能": "Control whether administrators can access group management features", + "默认分组": "Default Group", + "VIP分组": "VIP Group", + "SVIP分组": "SVIP Group", + "头像管理": "Avatar Management", + "上传头像": "Upload Avatar", + "移除头像": "Remove Avatar", + "预览头像": "Preview Avatar", + "确认上传": "Confirm Upload", + "确认要使用这张图片作为头像吗?": "Are you sure you want to use this image as your avatar?", + "支持 JPEG、PNG、GIF、WebP 格式": "Supports JPEG, PNG, GIF, WebP formats", + "文件大小不超过 1.5MB": "File size should not exceed 1.5MB", + "不支持的图片格式,仅支持 JPEG、PNG、GIF、WebP": "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported", + "图片文件大小不能超过1.5MB": "Image file size cannot exceed 1.5MB", + "图片编码后数据过大,请选择更小的图片": "Encoded image data is too large. Please select a smaller image.", + "图片处理失败,请重试": "Image processing failed, please try again", + "头像更新成功": "Avatar updated successfully", + "头像更新失败,请重试": "Avatar update failed, please try again", + "头像已移除": "Avatar removed", + "头像移除失败,请重试": "Avatar removal failed, please try again" } diff --git a/web/src/pages/Home/index.jsx b/web/src/pages/Home/index.jsx index 19681639ab6b..c556ec27832e 100644 --- a/web/src/pages/Home/index.jsx +++ b/web/src/pages/Home/index.jsx @@ -149,20 +149,20 @@ const Home = () => { }, [endpointItems.length]); return ( -
+
setNoticeVisible(false)} isMobile={isMobile} /> {homePageContentLoaded && homePageContent === '' ? ( -
+
{/* Banner 部分 */} -
+
{/* 背景模糊晕染球 */}
-
+
{/* 居中内容区 */}
@@ -343,15 +343,15 @@ const Home = () => {
) : ( -
+
{homePageContent.startsWith('https://') ? (