diff --git a/common/frontend_theme.go b/common/frontend_theme.go new file mode 100644 index 00000000000..5183d65c881 --- /dev/null +++ b/common/frontend_theme.go @@ -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) +} diff --git a/controller/user.go b/controller/user.go index b5722668632..e5c1e3ade24 100644 --- a/controller/user.go +++ b/controller/user.go @@ -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) @@ -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{}{ @@ -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 } @@ -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 @@ -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 diff --git a/dto/user_settings.go b/dto/user_settings.go index dbf555fadfa..5c398872d4f 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -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 ( diff --git a/model/user.go b/model/user.go index 4f1e5c3bd26..1551c187e6d 100644 --- a/model/user.go +++ b/model/user.go @@ -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()) } @@ -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 @@ -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 + } // Update cache return updateUserCache(*user) diff --git a/router/web-router.go b/router/web-router.go index 0d475e90d54..fea3916b1a6 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -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" ) @@ -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() +} + +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 +} diff --git a/web/classic/package.json b/web/classic/package.json index 83b5d23049b..163d50bdc99 100644 --- a/web/classic/package.json +++ b/web/classic/package.json @@ -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", diff --git a/web/classic/src/components/layout/PageLayout.jsx b/web/classic/src/components/layout/PageLayout.jsx index ca38ed506e6..971497503c9 100644 --- a/web/classic/src/components/layout/PageLayout.jsx +++ b/web/classic/src/components/layout/PageLayout.jsx @@ -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 = () => { @@ -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 } diff --git a/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx b/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx index b30550bec2f..8f18844c86f 100644 --- a/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx +++ b/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx @@ -17,166 +17,283 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useEffect, useContext } from "react"; -import { Card, Select, Typography, Avatar } from "@douyinfe/semi-ui"; -import { Languages } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { API, showSuccess, showError } from "../../../../helpers"; -import { UserContext } from "../../../../context/User"; -import { normalizeLanguage } from "../../../../i18n/language"; - -// Language options with native names +import React, { useContext, useEffect, useState } from 'react'; +import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui'; +import { Languages } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess } from '../../../../helpers'; +import { UserContext } from '../../../../context/User'; +import { normalizeLanguage } from '../../../../i18n/language'; + const languageOptions = [ - { value: "zh-CN", label: "简体中文" }, - { value: "zh-TW", label: "繁體中文" }, - { value: "en", label: "English" }, - { value: 'fr', label: 'Français'}, - { value: 'ru', label: 'Русский'}, - { value: 'ja', label: '日本語'}, - { value: "vi", label: "Tiếng Việt" }, + { value: 'zh-CN', label: '简体中文' }, + { value: 'zh-TW', label: '繁體中文' }, + { value: 'en', label: 'English' }, + { value: 'fr', label: 'Français' }, + { value: 'ru', label: 'Русский' }, + { value: 'ja', label: '日本語' }, + { value: 'vi', label: 'Tiếng Việt' }, +]; + +const FRONTEND_THEME_COOKIE_NAME = 'frontend_theme'; +const FRONTEND_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; + +const frontendThemeOptions = [ + { value: 'default', label: '新版本 UI' }, + { value: 'classic', label: '旧版本 UI' }, ]; +const getFrontendTheme = () => { + if (typeof document === 'undefined') return 'default'; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${FRONTEND_THEME_COOKIE_NAME}=`); + if (parts.length !== 2) return 'default'; + const theme = parts.pop()?.split(';').shift(); + return theme === '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 getFrontendThemeSettingsPath = (theme) => { + return theme === 'classic' ? '/console/personal' : '/profile'; +}; + +const updateFrontendThemePreference = async (theme, userId) => { + const response = await fetch('/api/user/self', { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'New-API-User': String(userId ?? -1), + }, + body: JSON.stringify({ + frontend_theme: theme, + }), + }); + + const data = await response.json(); + if (!response.ok || !data?.success) { + throw new Error(data?.message || 'save frontend theme failed'); + } + return data; +}; + const PreferencesSettings = ({ t }) => { - const { i18n } = useTranslation(); - const [userState, userDispatch] = useContext(UserContext); - const [currentLanguage, setCurrentLanguage] = useState( - normalizeLanguage(i18n.language) || "zh-CN", - ); - const [loading, setLoading] = useState(false); - - // Load saved language preference from user settings - useEffect(() => { - if (userState?.user?.setting) { - try { - const settings = JSON.parse(userState.user.setting); - if (settings.language) { - const lang = normalizeLanguage(settings.language); - setCurrentLanguage(lang); - // Sync i18n with saved preference - if (i18n.language !== lang) { - i18n.changeLanguage(lang); - } - } - } catch (e) { - // Ignore parse errors - } - } - }, [userState?.user?.setting, i18n]); - - const handleLanguagePreferenceChange = async (lang) => { - if (lang === currentLanguage) return; - - setLoading(true); - const previousLang = currentLanguage; - - try { - // Update language immediately for responsive UX - setCurrentLanguage(lang); - i18n.changeLanguage(lang); - localStorage.setItem('i18nextLng', lang); - - // Save to backend - const res = await API.put("/api/user/self", { - language: lang, - }); - - if (res.data.success) { - showSuccess(t("语言偏好已保存")); - // Keep backend preference, context state, and local cache aligned. - let settings = {}; - if (userState?.user?.setting) { - try { - settings = JSON.parse(userState.user.setting) || {}; - } catch (e) { - settings = {}; - } - } - settings.language = lang; - const nextUser = { - ...userState.user, - setting: JSON.stringify(settings), - }; - userDispatch({ - type: "login", - payload: nextUser, - }); - localStorage.setItem("user", JSON.stringify(nextUser)); - } else { - showError(res.data.message || t("保存失败")); - // Revert on error - setCurrentLanguage(previousLang); - i18n.changeLanguage(previousLang); - localStorage.setItem("i18nextLng", previousLang); - } - } catch (error) { - showError(t("保存失败,请重试")); - // Revert on error - setCurrentLanguage(previousLang); - i18n.changeLanguage(previousLang); - localStorage.setItem("i18nextLng", previousLang); - } finally { - setLoading(false); - } - }; - - return ( - - {/* Card Header */} -
- - - -
- - {t("偏好设置")} - -
- {t("界面语言和其他个人偏好")} -
-
-
- {/* Language Setting Card */} - -
-
-
- -
-
- - {t("语言偏好")} - - - {t("选择您的首选界面语言,设置将自动保存并同步到所有设备")} - -
-
- ({ + value: opt.value, + label: opt.label, + }))} + /> +
+
+ + +
+
+
+ +
+
+ + {t('界面风格')} + + + {t('可在新版本 UI 和旧版本 UI 之间切换,保存后页面会立即跳转')} + +
+
+ + + + + + {FRONTEND_THEME_OPTIONS.map((option) => ( + + {t(option.label)} + + ))} + + +
+ + + ) +} diff --git a/web/default/src/features/profile/components/language-preferences-card.tsx b/web/default/src/features/profile/components/language-preferences-card.tsx index c0d307f27e4..06394ef4c91 100644 --- a/web/default/src/features/profile/components/language-preferences-card.tsx +++ b/web/default/src/features/profile/components/language-preferences-card.tsx @@ -14,6 +14,7 @@ import { TitledCard } from '@/components/ui/titled-card' import { updateUserLanguage } from '../api' import { parseUserSettings } from '../lib' import type { UserProfile } from '../types' +import { setFrontendTheme, normalizeFrontendTheme } from '@/lib/frontend-theme' const LANGUAGE_OPTIONS = [ { value: 'zh', label: '简体中文' }, @@ -83,6 +84,13 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) { }) } + if (typeof document !== 'undefined') { + const setting = parseUserSettings(props.profile?.setting) + if (setting.frontend_theme) { + setFrontendTheme(normalizeFrontendTheme(setting.frontend_theme)) + } + } + props.onProfileUpdate() toast.success(t('Language preference saved')) } catch (_error) { diff --git a/web/default/src/features/profile/index.tsx b/web/default/src/features/profile/index.tsx index 903a23985c4..2df9605162b 100644 --- a/web/default/src/features/profile/index.tsx +++ b/web/default/src/features/profile/index.tsx @@ -6,6 +6,7 @@ import { CardStaggerItem, } from '@/components/page-transition' import { CheckinCalendarCard } from './components/checkin-calendar-card' +import { FrontendThemeCard } from './components/frontend-theme-card' import { LanguagePreferencesCard } from './components/language-preferences-card' import { PasskeyCard } from './components/passkey-card' import { ProfileHeader } from './components/profile-header' @@ -45,6 +46,7 @@ export function Profile() { loading={loading} onProfileUpdate={refreshProfile} /> +