feat(theme): 用户自定义主题色 (#4613)

* 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
This commit is contained in:
Teo 2025-05-29 15:29:35 +08:00 committed by GitHub
parent 2e8cbdc4aa
commit c5208eeaef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 107 additions and 9 deletions

View File

@ -206,8 +206,14 @@
.ant-collapse { .ant-collapse {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
.ant-color-picker & {
border: none;
}
} }
.ant-collapse-content { .ant-collapse-content {
border-top: 1px solid var(--color-border) !important; border-top: 1px solid var(--color-border) !important;
.ant-color-picker & {
border-top: none !important;
}
} }

View File

@ -1,9 +1,8 @@
import { RightOutlined } from '@ant-design/icons' import { RightOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { classNames } from '@renderer/utils' import { classNames } from '@renderer/utils'
import { Flex } from 'antd' import { Flex } from 'antd'
import { theme } from 'antd'
import Color from 'color'
import { t } from 'i18next' import { t } from 'i18next'
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
@ -40,8 +39,7 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
throw new Error('QuickPanel must be used within a QuickPanelProvider') throw new Error('QuickPanel must be used within a QuickPanelProvider')
} }
const { token } = theme.useToken() const { colorPrimary } = useUserTheme()
const colorPrimary = Color(token.colorPrimary || '#008000')
const selectedColor = colorPrimary.alpha(0.15).toString() const selectedColor = colorPrimary.alpha(0.15).toString()
const selectedColorHover = colorPrimary.alpha(0.2).toString() const selectedColorHover = colorPrimary.alpha(0.2).toString()

View File

@ -15,13 +15,18 @@ import { FC, PropsWithChildren } from 'react'
import { useTheme } from './ThemeProvider' import { useTheme } from './ThemeProvider'
const AntdProvider: FC<PropsWithChildren> = ({ children }) => { const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
const { language } = useSettings() const {
language,
userTheme: { colorPrimary }
} = useSettings()
const { theme: _theme } = useTheme() const { theme: _theme } = useTheme()
return ( return (
<ConfigProvider <ConfigProvider
locale={getAntdLocale(language)} locale={getAntdLocale(language)}
theme={{ theme={{
cssVar: true,
hashed: false,
algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm], algorithm: [_theme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm],
components: { components: {
Menu: { Menu: {
@ -43,7 +48,7 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
} }
}, },
token: { token: {
colorPrimary: '#00b96b', colorPrimary: colorPrimary,
fontFamily: 'var(--font-family)' fontFamily: 'var(--font-family)'
} }
}}> }}>

View File

@ -1,5 +1,6 @@
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react' import React, { createContext, PropsWithChildren, use, useEffect, useState } from 'react'
@ -23,6 +24,7 @@ interface ThemeProviderProps extends PropsWithChildren {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
const { theme, setTheme } = useSettings() const { theme, setTheme } = useSettings()
const [effectiveTheme, setEffectiveTheme] = useState(theme) const [effectiveTheme, setEffectiveTheme] = useState(theme)
const { initUserTheme } = useUserTheme()
const toggleTheme = () => { const toggleTheme = () => {
// 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题 // 主题顺序是light, dark, auto, 所以需要先判断当前主题,然后取下一个主题
@ -60,6 +62,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
} }
}) })
useEffect(() => {
initUserTheme()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext> return <ThemeContext value={{ theme: effectiveTheme, settingTheme: theme, toggleTheme }}>{children}</ThemeContext>
} }

View File

@ -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)
}
}
}

View File

@ -1695,6 +1695,7 @@
"theme.dark": "Dark", "theme.dark": "Dark",
"theme.light": "Light", "theme.light": "Light",
"theme.title": "Theme", "theme.title": "Theme",
"theme.color_primary": "Primary Color",
"theme.window.style.opaque": "Opaque Window", "theme.window.style.opaque": "Opaque Window",
"theme.window.style.title": "Window Style", "theme.window.style.title": "Window Style",
"theme.window.style.transparent": "Transparent Window", "theme.window.style.transparent": "Transparent Window",

View File

@ -1685,6 +1685,7 @@
"theme.dark": "ダーク", "theme.dark": "ダーク",
"theme.light": "ライト", "theme.light": "ライト",
"theme.title": "テーマ", "theme.title": "テーマ",
"theme.color_primary": "テーマ色",
"theme.window.style.opaque": "不透明ウィンドウ", "theme.window.style.opaque": "不透明ウィンドウ",
"theme.window.style.title": "ウィンドウスタイル", "theme.window.style.title": "ウィンドウスタイル",
"theme.window.style.transparent": "透明ウィンドウ", "theme.window.style.transparent": "透明ウィンドウ",

View File

@ -1686,6 +1686,7 @@
"theme.dark": "Темная", "theme.dark": "Темная",
"theme.light": "Светлая", "theme.light": "Светлая",
"theme.title": "Тема", "theme.title": "Тема",
"theme.color_primary": "Цвет темы",
"theme.window.style.opaque": "Непрозрачное окно", "theme.window.style.opaque": "Непрозрачное окно",
"theme.window.style.title": "Стиль окна", "theme.window.style.title": "Стиль окна",
"theme.window.style.transparent": "Прозрачное окно", "theme.window.style.transparent": "Прозрачное окно",

View File

@ -1695,6 +1695,7 @@
"theme.dark": "深色", "theme.dark": "深色",
"theme.light": "浅色", "theme.light": "浅色",
"theme.title": "主题", "theme.title": "主题",
"theme.color_primary": "主题颜色",
"theme.window.style.opaque": "不透明窗口", "theme.window.style.opaque": "不透明窗口",
"theme.window.style.title": "窗口样式", "theme.window.style.title": "窗口样式",
"theme.window.style.transparent": "透明窗口", "theme.window.style.transparent": "透明窗口",

View File

@ -1688,6 +1688,7 @@
"theme.dark": "深色", "theme.dark": "深色",
"theme.light": "淺色", "theme.light": "淺色",
"theme.title": "主題", "theme.title": "主題",
"theme.color_primary": "主題顏色",
"theme.window.style.opaque": "不透明視窗", "theme.window.style.opaque": "不透明視窗",
"theme.window.style.title": "視窗樣式", "theme.window.style.title": "視窗樣式",
"theme.window.style.transparent": "透明視窗", "theme.window.style.transparent": "透明視窗",

View File

@ -2,6 +2,7 @@ import { SyncOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import useUserTheme from '@renderer/hooks/useUserTheme'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { import {
AssistantIconType, AssistantIconType,
@ -14,9 +15,9 @@ import {
setSidebarIcons setSidebarIcons
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import { Button, Input, Segmented, Switch } from 'antd'
import { Minus, Plus, RotateCcw } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -36,12 +37,14 @@ const DisplaySettings: FC = () => {
pinTopicsToTop, pinTopicsToTop,
customCss, customCss,
sidebarIcons, sidebarIcons,
assistantIconType assistantIconType,
userTheme
} = useSettings() } = useSettings()
const { theme: themeMode } = useTheme() const { theme: themeMode } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [currentZoom, setCurrentZoom] = useState(1.0) const [currentZoom, setCurrentZoom] = useState(1.0)
const { setUserTheme } = useUserTheme()
const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS) const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || []) const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
@ -53,6 +56,16 @@ const DisplaySettings: FC = () => {
[setWindowStyle] [setWindowStyle]
) )
const handleColorPrimaryChange = useCallback(
(colorHex: string) => {
setUserTheme({
...userTheme,
colorPrimary: colorHex
})
},
[setUserTheme, userTheme]
)
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setVisibleIcons([...DEFAULT_SIDEBAR_ICONS]) setVisibleIcons([...DEFAULT_SIDEBAR_ICONS])
setDisabledIcons([]) setDisabledIcons([])
@ -149,6 +162,22 @@ const DisplaySettings: FC = () => {
/> />
</ZoomButtonGroup> </ZoomButtonGroup>
</SettingRow> </SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.theme.color_primary')}</SettingRowTitle>
<ColorPicker
className="color-picker"
value={userTheme.colorPrimary}
onChange={(color) => handleColorPrimaryChange(color.toHexString())}
showText
presets={[
{
label: 'Presets',
colors: ['#007BFF', '#F74F9E', '#FF5257', '#F7821B', '#FFC600', '#62BA46', '#000000']
}
]}
/>
</SettingRow>
{isMac && ( {isMac && (
<> <>
<SettingDivider /> <SettingDivider />

View File

@ -1465,6 +1465,14 @@ const migrateConfig = {
} catch (error) { } catch (error) {
return state return state
} }
},
'109': (state: RootState) => {
try {
state.settings.userTheme = settingsInitialState.userTheme
return state
} catch (error) {
return state
}
} }
} }

View File

@ -31,6 +31,10 @@ export interface NutstoreSyncRuntime extends WebDAVSyncState {}
export type AssistantIconType = 'model' | 'emoji' | 'none' export type AssistantIconType = 'model' | 'emoji' | 'none'
export type UserTheme = {
colorPrimary: string
}
export interface SettingsState { export interface SettingsState {
showAssistants: boolean showAssistants: boolean
showTopics: boolean showTopics: boolean
@ -49,6 +53,7 @@ export interface SettingsState {
trayOnClose: boolean trayOnClose: boolean
tray: boolean tray: boolean
theme: ThemeMode theme: ThemeMode
userTheme: UserTheme
windowStyle: 'transparent' | 'opaque' windowStyle: 'transparent' | 'opaque'
fontSize: number fontSize: number
topicPosition: 'left' | 'right' topicPosition: 'left' | 'right'
@ -191,6 +196,9 @@ export const initialState: SettingsState = {
trayOnClose: true, trayOnClose: true,
tray: true, tray: true,
theme: ThemeMode.auto, theme: ThemeMode.auto,
userTheme: {
colorPrimary: '#00b96b'
},
windowStyle: 'opaque', windowStyle: 'opaque',
fontSize: 14, fontSize: 14,
topicPosition: 'left', topicPosition: 'left',
@ -368,6 +376,9 @@ const settingsSlice = createSlice({
setCustomCss: (state, action: PayloadAction<string>) => { setCustomCss: (state, action: PayloadAction<string>) => {
state.customCss = action.payload state.customCss = action.payload
}, },
setUserTheme: (state, action: PayloadAction<UserTheme>) => {
state.userTheme = action.payload
},
setFontSize: (state, action: PayloadAction<number>) => { setFontSize: (state, action: PayloadAction<number>) => {
state.fontSize = action.payload state.fontSize = action.payload
}, },
@ -662,6 +673,7 @@ export const {
setTrayOnClose, setTrayOnClose,
setTray, setTray,
setTheme, setTheme,
setUserTheme,
setFontSize, setFontSize,
setWindowStyle, setWindowStyle,
setTopicPosition, setTopicPosition,