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 {
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;
}
}

View File

@ -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<Props> = ({ 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()

View File

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

View File

@ -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<ThemeProviderProps> = ({ 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<ThemeProviderProps> = ({ children, defaultT
}
})
useEffect(() => {
initUserTheme()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
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.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",

View File

@ -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": "透明ウィンドウ",

View File

@ -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": "Прозрачное окно",

View File

@ -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": "透明窗口",

View File

@ -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": "透明視窗",

View File

@ -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 = () => {
/>
</ZoomButtonGroup>
</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 && (
<>
<SettingDivider />

View File

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

View File

@ -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<string>) => {
state.customCss = action.payload
},
setUserTheme: (state, action: PayloadAction<UserTheme>) => {
state.userTheme = action.payload
},
setFontSize: (state, action: PayloadAction<number>) => {
state.fontSize = action.payload
},
@ -662,6 +673,7 @@ export const {
setTrayOnClose,
setTray,
setTheme,
setUserTheme,
setFontSize,
setWindowStyle,
setTopicPosition,