Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions common/frontend_theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package common

import (
"strings"

"github.com/gin-gonic/gin"
)

const FrontendThemeCookieName = "frontend_theme"
const FrontendThemeCookieMaxAge = 60 * 60 * 24 * 365

func NormalizeFrontendTheme(theme string) string {
theme = strings.ToLower(strings.TrimSpace(theme))
if theme == "default" || theme == "classic" {
return theme
}
return ""
}

func SetFrontendThemeCookie(c *gin.Context, theme string) {
theme = NormalizeFrontendTheme(theme)
if theme == "" {
return
}
c.SetCookie(FrontendThemeCookieName, theme, FrontendThemeCookieMaxAge, "/", "", false, false)
}
45 changes: 41 additions & 4 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ func Login(c *gin.Context) {
// setup session & cookies and then return user info
func setupLogin(user *model.User, c *gin.Context) {
model.UpdateUserLastLoginAt(user.Id)
if theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme); theme != "" {
common.SetFrontendThemeCookie(c, theme)
}
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
Expand Down Expand Up @@ -387,6 +390,9 @@ func GetSelf(c *gin.Context) {

// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
if theme := common.NormalizeFrontendTheme(userSetting.FrontendTheme); theme != "" {
common.SetFrontendThemeCookie(c, theme)
}

// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
Expand Down Expand Up @@ -625,8 +631,7 @@ func AdminClearUserBinding(c *gin.Context) {

func UpdateSelf(c *gin.Context) {
var requestData map[string]interface{}
err := json.NewDecoder(c.Request.Body).Decode(&requestData)
if err != nil {
if err := common.DecodeJson(c.Request.Body, &requestData); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
Expand Down Expand Up @@ -654,6 +659,38 @@ func UpdateSelf(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
return
}
common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
return
}

// 检查是否是 UI 风格更新请求
if frontendTheme, themeExists := requestData["frontend_theme"]; themeExists {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, false)
if err != nil {
common.ApiError(c, err)
return
}

currentSetting := user.GetSetting()
themeStr, ok := frontendTheme.(string)
if !ok {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
themeStr = strings.ToLower(strings.TrimSpace(themeStr))
if themeStr != "default" && themeStr != "classic" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
currentSetting.FrontendTheme = themeStr

user.SetSetting(currentSetting)
if err := user.Update(false); err != nil {
common.ApiErrorI18n(c, i18n.MsgUpdateFailed)
return
}
common.SetFrontendThemeCookie(c, themeStr)

common.ApiSuccessI18n(c, i18n.MsgUpdateSuccess, nil)
return
Expand Down Expand Up @@ -689,12 +726,12 @@ func UpdateSelf(c *gin.Context) {

// 原有的用户信息更新逻辑
var user model.User
requestDataBytes, err := json.Marshal(requestData)
requestDataBytes, err := common.Marshal(requestData)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
err = json.Unmarshal(requestDataBytes, &user)
err = common.Unmarshal(requestDataBytes, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
Expand Down
1 change: 1 addition & 0 deletions dto/user_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type UserSetting struct {
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
FrontendTheme string `json:"frontend_theme,omitempty"` // FrontendTheme 用户前端风格偏好 (default, classic)
}

var (
Expand Down
7 changes: 5 additions & 2 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (user *User) SetAccessToken(token string) {
func (user *User) GetSetting() dto.UserSetting {
setting := dto.UserSetting{}
if user.Setting != "" {
err := json.Unmarshal([]byte(user.Setting), &setting)
err := common.Unmarshal([]byte(user.Setting), &setting)
if err != nil {
common.SysLog("failed to unmarshal setting: " + err.Error())
}
Expand All @@ -90,7 +90,7 @@ func (user *User) GetSetting() dto.UserSetting {
}

func (user *User) SetSetting(setting dto.UserSetting) {
settingBytes, err := json.Marshal(setting)
settingBytes, err := common.Marshal(setting)
if err != nil {
common.SysLog("failed to marshal setting: " + err.Error())
return
Expand Down Expand Up @@ -511,6 +511,9 @@ func (user *User) Update(updatePassword bool) error {
if err = DB.Model(user).Updates(newUser).Error; err != nil {
return err
}
if err = DB.First(user, user.Id).Error; err != nil {
return err
}
Comment on lines +514 to +516
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Adding a DB.First call inside the general Update method introduces an extra database query for every user update operation across the entire application. This is inefficient and usually unnecessary as GORM's Updates already modifies the fields in the local struct instance. If a specific caller needs to ensure the object is fully synced with database-side changes (like triggers or default values), they should perform the reload explicitly.


// Update cache
return updateUserCache(*user)
Expand Down
91 changes: 87 additions & 4 deletions router/web-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package router
import (
"embed"
"net/http"
"net/url"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/gzip"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
Expand All @@ -24,23 +27,103 @@ type ThemeAssets struct {
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)

router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
router.Use(middleware.Cache())
router.Use(static.Serve("/", themeFS))
router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web")
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") {
controller.RelayNotFound(c)
return
}

theme := resolveFrontendTheme(c)
if redirectPath := mapFrontendPath(theme, c.Request.URL.Path); redirectPath != "" && redirectPath != c.Request.URL.Path {
if c.Request.URL.RawQuery != "" {
redirectPath = redirectPath + "?" + c.Request.URL.RawQuery
}
c.Redirect(http.StatusFound, redirectPath)
return
}
themeFS := selectFrontendFS(theme, defaultFS, classicFS)
requestPath := strings.TrimPrefix(c.Request.URL.Path, "/")
if requestPath != "" && themeFS.Exists("/", requestPath) {
http.FileServer(themeFS).ServeHTTP(c.Writer, c.Request)
return
}

c.Header("Cache-Control", "no-cache")
if common.GetTheme() == "classic" {
if theme == "classic" {
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
} else {
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
}
})
}

func resolveFrontendTheme(c *gin.Context) string {
session := sessions.Default(c)
if sessionID := session.Get("id"); sessionID != nil {
if userID, ok := sessionID.(int); ok && userID > 0 {
user, err := model.GetUserById(userID, false)
if err == nil {
theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme)
if theme != "" {
common.SetFrontendThemeCookie(c, theme)
return theme
}
}
}
}
themeCookie, err := c.Cookie(common.FrontendThemeCookieName)
if err == nil {
theme := common.NormalizeFrontendTheme(themeCookie)
if theme != "" {
return theme
}
}
return common.GetTheme()
}
Comment on lines +65 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of resolveFrontendTheme performs a database query (model.GetUserById) on every request that hits NoRoute, which includes all frontend routes and static assets. This will cause significant performance degradation under load.

It is highly recommended to check the frontend_theme cookie first. If the cookie is present and valid, use it to avoid the database lookup. Only query the database if the cookie is missing and a valid session exists.

func resolveFrontendTheme(c *gin.Context) string {
	if themeCookie, err := c.Cookie(common.FrontendThemeCookieName); err == nil {
		if theme := common.NormalizeFrontendTheme(themeCookie); theme != "" {
			return theme
		}
	}
	session := sessions.Default(c)
	if sessionID := session.Get("id"); sessionID != nil {
		if userID, ok := sessionID.(int); ok && userID > 0 {
			user, err := model.GetUserById(userID, false)
			if err == nil {
				theme := common.NormalizeFrontendTheme(user.GetSetting().FrontendTheme)
				if theme != "" {
					common.SetFrontendThemeCookie(c, theme)
					return theme
				}
			}
		}
	}
	return common.GetTheme()
}


func mapFrontendPath(theme string, path string) string {
normalizedPath := path
if normalizedPath == "" {
normalizedPath = "/"
}
unescapedPath, err := url.PathUnescape(normalizedPath)
if err == nil && unescapedPath != "" {
normalizedPath = unescapedPath
}
normalizedPath = strings.TrimSuffix(normalizedPath, "/")
if normalizedPath == "" {
normalizedPath = "/"
}

if theme == "classic" {
switch normalizedPath {
case "/dashboard":
return "/console"
case "/profile":
return "/console/personal"
}
}

if theme == "default" {
switch normalizedPath {
case "/console":
return "/dashboard"
case "/console/personal":
return "/profile"
}
}

return ""
}

func selectFrontendFS(theme string, defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
if theme == "classic" {
return classicFS
}
return defaultFS
}
1 change: 1 addition & 0 deletions web/classic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"type": "module",
"dependencies": {
"antd": "^5.29.3",
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
Expand Down
14 changes: 14 additions & 0 deletions web/classic/src/components/layout/PageLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useLocation } from 'react-router-dom';
import { normalizeLanguage } from '../../i18n/language';
const FRONTEND_THEME_COOKIE_NAME = 'frontend_theme';
const FRONTEND_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;

const normalizeFrontendTheme = (value) => {
return value === 'classic' ? 'classic' : 'default';
};

const setFrontendTheme = (theme) => {
if (typeof document === 'undefined') return;
document.cookie = `${FRONTEND_THEME_COOKIE_NAME}=${theme}; path=/; max-age=${FRONTEND_THEME_COOKIE_MAX_AGE}`;
};
const { Sider, Content, Header } = Layout;

const PageLayout = () => {
Expand Down Expand Up @@ -124,6 +135,9 @@ const PageLayout = () => {
try {
const settings = JSON.parse(userState.user.setting);
preferredLang = normalizeLanguage(settings.language);
if (settings.frontend_theme) {
setFrontendTheme(normalizeFrontendTheme(settings.frontend_theme));
}
} catch (e) {
// Ignore parse errors
}
Expand Down
Loading