From c5208eeaefb3ab5bdd2b838dca1c852f519265bd Mon Sep 17 00:00:00 2001 From: Teo Date: Thu, 29 May 2025 15:29:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E7=94=A8=E6=88=B7=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=BB=E9=A2=98=E8=89=B2=20(#4613)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(theme): 用户自定义主题色 * refactor(QuickPanel): integrate user theme for dynamic color handling * refactor(ThemeProvider): separate user theme initialization into its own useEffect * refactor(useUserTheme): move theme initialization logic into a dedicated function * feat(settings): enhance color picker with presets and update styles for ant-collapse * feat: Refactor theme management to use userTheme object for colorPrimary --- src/renderer/src/assets/styles/ant.scss | 6 ++++ .../src/components/QuickPanel/view.tsx | 6 ++-- src/renderer/src/context/AntdProvider.tsx | 9 +++-- src/renderer/src/context/ThemeProvider.tsx | 7 ++++ src/renderer/src/hooks/useUserTheme.ts | 28 +++++++++++++++ src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../DisplaySettings/DisplaySettings.tsx | 35 +++++++++++++++++-- src/renderer/src/store/migrate.ts | 8 +++++ src/renderer/src/store/settings.ts | 12 +++++++ 13 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/hooks/useUserTheme.ts diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 3dd08edc02..d4788439e5 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -206,8 +206,14 @@ .ant-collapse { border: 1px solid var(--color-border); + .ant-color-picker & { + border: none; + } } .ant-collapse-content { border-top: 1px solid var(--color-border) !important; + .ant-color-picker & { + border-top: none !important; + } } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 390087d91d..0dbeebaee8 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,9 +1,8 @@ import { RightOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' +import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' import { Flex } from 'antd' -import { theme } from 'antd' -import Color from 'color' import { t } from 'i18next' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' @@ -40,8 +39,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { throw new Error('QuickPanel must be used within a QuickPanelProvider') } - const { token } = theme.useToken() - const colorPrimary = Color(token.colorPrimary || '#008000') + const { colorPrimary } = useUserTheme() const selectedColor = colorPrimary.alpha(0.15).toString() const selectedColorHover = colorPrimary.alpha(0.2).toString() diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index fdeed3d688..737b260448 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react' import { useTheme } from './ThemeProvider' const AntdProvider: FC = ({ children }) => { - const { language } = useSettings() + const { + language, + userTheme: { colorPrimary } + } = useSettings() const { theme: _theme } = useTheme() return ( = ({ children }) => { } }, token: { - colorPrimary: '#00b96b', + colorPrimary: colorPrimary, fontFamily: 'var(--font-family)' } }}> diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index 5908e8feba..968a0df81f 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -1,5 +1,6 @@ import { isMac } from '@renderer/config/constant' import { useSettings } from '@renderer/hooks/useSettings' +import useUserTheme from '@renderer/hooks/useUserTheme' import { ThemeMode } from '@renderer/types' import { IpcChannel } from '@shared/IpcChannel' import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react' @@ -23,6 +24,7 @@ interface ThemeProviderProps extends PropsWithChildren { export const ThemeProvider: React.FC = ({ children, defaultTheme }) => { const { theme, setTheme } = useSettings() const [effectiveTheme, setEffectiveTheme] = useState(theme) + const { initUserTheme } = useUserTheme() const toggleTheme = () => { // 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题 @@ -60,6 +62,11 @@ export const ThemeProvider: React.FC = ({ children, defaultT } }) + useEffect(() => { + initUserTheme() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return {children} } diff --git a/src/renderer/src/hooks/useUserTheme.ts b/src/renderer/src/hooks/useUserTheme.ts new file mode 100644 index 0000000000..42ba13dd8f --- /dev/null +++ b/src/renderer/src/hooks/useUserTheme.ts @@ -0,0 +1,28 @@ +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { setUserTheme, UserTheme } from '@renderer/store/settings' +import Color from 'color' + +export default function useUserTheme() { + const userTheme = useAppSelector((state) => state.settings.userTheme) + const dispatch = useAppDispatch() + + const initUserTheme = (theme: UserTheme = userTheme) => { + const colorPrimary = Color(theme.colorPrimary) + + document.body.style.setProperty('--color-primary', colorPrimary.toString()) + document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString()) + document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) + } + + return { + colorPrimary: Color(userTheme.colorPrimary), + + initUserTheme, + + setUserTheme(userTheme: UserTheme) { + dispatch(setUserTheme(userTheme)) + + initUserTheme(userTheme) + } + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 72f7bf6fec..3c0f143fd1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1695,6 +1695,7 @@ "theme.dark": "Dark", "theme.light": "Light", "theme.title": "Theme", + "theme.color_primary": "Primary Color", "theme.window.style.opaque": "Opaque Window", "theme.window.style.title": "Window Style", "theme.window.style.transparent": "Transparent Window", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e40f4a65ec..88d6d25923 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1685,6 +1685,7 @@ "theme.dark": "ダーク", "theme.light": "ライト", "theme.title": "テーマ", + "theme.color_primary": "テーマ色", "theme.window.style.opaque": "不透明ウィンドウ", "theme.window.style.title": "ウィンドウスタイル", "theme.window.style.transparent": "透明ウィンドウ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index e33e84af1f..3a19d0bd72 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1686,6 +1686,7 @@ "theme.dark": "Темная", "theme.light": "Светлая", "theme.title": "Тема", + "theme.color_primary": "Цвет темы", "theme.window.style.opaque": "Непрозрачное окно", "theme.window.style.title": "Стиль окна", "theme.window.style.transparent": "Прозрачное окно", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0dd071f2c2..75c110da68 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1695,6 +1695,7 @@ "theme.dark": "深色", "theme.light": "浅色", "theme.title": "主题", + "theme.color_primary": "主题颜色", "theme.window.style.opaque": "不透明窗口", "theme.window.style.title": "窗口样式", "theme.window.style.transparent": "透明窗口", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 027e92dcf9..8f52eb4878 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1688,6 +1688,7 @@ "theme.dark": "深色", "theme.light": "淺色", "theme.title": "主題", + "theme.color_primary": "主題顏色", "theme.window.style.opaque": "不透明視窗", "theme.window.style.title": "視窗樣式", "theme.window.style.transparent": "透明視窗", diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index b48633abba..3de36804a2 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -2,6 +2,7 @@ import { SyncOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' +import useUserTheme from '@renderer/hooks/useUserTheme' import { useAppDispatch } from '@renderer/store' import { AssistantIconType, @@ -14,9 +15,9 @@ import { setSidebarIcons } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' -import { Button, Input, Segmented, Switch } from 'antd' import { Minus, Plus, RotateCcw } from 'lucide-react' -import { FC, useCallback, useEffect, useMemo, useState } from 'react' +import { Button, ColorPicker, Input, Segmented, Switch } from 'antd' +import { FC, useCallback, useMemo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -36,12 +37,14 @@ const DisplaySettings: FC = () => { pinTopicsToTop, customCss, sidebarIcons, - assistantIconType + assistantIconType, + userTheme } = useSettings() const { theme: themeMode } = useTheme() const { t } = useTranslation() const dispatch = useAppDispatch() const [currentZoom, setCurrentZoom] = useState(1.0) + const { setUserTheme } = useUserTheme() const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS) const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || []) @@ -53,6 +56,16 @@ const DisplaySettings: FC = () => { [setWindowStyle] ) + const handleColorPrimaryChange = useCallback( + (colorHex: string) => { + setUserTheme({ + ...userTheme, + colorPrimary: colorHex + }) + }, + [setUserTheme, userTheme] + ) + const handleReset = useCallback(() => { setVisibleIcons([...DEFAULT_SIDEBAR_ICONS]) setDisabledIcons([]) @@ -149,6 +162,22 @@ const DisplaySettings: FC = () => { /> + + + {t('settings.theme.color_primary')} + handleColorPrimaryChange(color.toHexString())} + showText + presets={[ + { + label: 'Presets', + colors: ['#007BFF', '#F74F9E', '#FF5257', '#F7821B', '#FFC600', '#62BA46', '#000000'] + } + ]} + /> + {isMac && ( <> diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 6edf827440..a17587c3db 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1465,6 +1465,14 @@ const migrateConfig = { } catch (error) { return state } + }, + '109': (state: RootState) => { + try { + state.settings.userTheme = settingsInitialState.userTheme + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 5a9c490f85..41c165b9a2 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -31,6 +31,10 @@ export interface NutstoreSyncRuntime extends WebDAVSyncState {} export type AssistantIconType = 'model' | 'emoji' | 'none' +export type UserTheme = { + colorPrimary: string +} + export interface SettingsState { showAssistants: boolean showTopics: boolean @@ -49,6 +53,7 @@ export interface SettingsState { trayOnClose: boolean tray: boolean theme: ThemeMode + userTheme: UserTheme windowStyle: 'transparent' | 'opaque' fontSize: number topicPosition: 'left' | 'right' @@ -191,6 +196,9 @@ export const initialState: SettingsState = { trayOnClose: true, tray: true, theme: ThemeMode.auto, + userTheme: { + colorPrimary: '#00b96b' + }, windowStyle: 'opaque', fontSize: 14, topicPosition: 'left', @@ -368,6 +376,9 @@ const settingsSlice = createSlice({ setCustomCss: (state, action: PayloadAction) => { state.customCss = action.payload }, + setUserTheme: (state, action: PayloadAction) => { + state.userTheme = action.payload + }, setFontSize: (state, action: PayloadAction) => { state.fontSize = action.payload }, @@ -662,6 +673,7 @@ export const { setTrayOnClose, setTray, setTheme, + setUserTheme, setFontSize, setWindowStyle, setTopicPosition,