mirror of https://github.com/Mai-with-u/MaiBot.git
Merge branch 'r-dev' of github.com:Mai-with-u/MaiBot into r-dev
commit
6bcd7cbebb
|
|
@ -11,6 +11,17 @@
|
||||||
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MaiBot Dashboard</title>
|
<title>MaiBot Dashboard</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const mode = localStorage.getItem('maibot-theme-mode')
|
||||||
|
|| localStorage.getItem('ui-theme')
|
||||||
|
|| localStorage.getItem('maibot-ui-theme');
|
||||||
|
const theme = mode === 'system' || !mode
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
: mode;
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="notranslate"></div>
|
<div id="root" class="notranslate"></div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,6 +13,7 @@
|
||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
|
@ -77,6 +78,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { python } from '@codemirror/lang-python'
|
import { css } from '@codemirror/lang-css'
|
||||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||||
|
import { python } from '@codemirror/lang-python'
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { StreamLanguage } from '@codemirror/language'
|
import { StreamLanguage } from '@codemirror/language'
|
||||||
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
||||||
|
|
||||||
export type Language = 'python' | 'json' | 'toml' | 'text'
|
import { useTheme } from '@/components/use-theme'
|
||||||
|
|
||||||
|
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
value: string
|
value: string
|
||||||
|
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
language?: Language
|
language?: Language
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
|
@ -27,6 +31,7 @@ const languageExtensions: Record<Language, any[]> = {
|
||||||
python: [python()],
|
python: [python()],
|
||||||
json: [json(), jsonParseLinter()],
|
json: [json(), jsonParseLinter()],
|
||||||
toml: [StreamLanguage.define(tomlMode)],
|
toml: [StreamLanguage.define(tomlMode)],
|
||||||
|
css: [css()],
|
||||||
text: [],
|
text: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,10 +44,11 @@ export function CodeEditor({
|
||||||
minHeight,
|
minHeight,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
placeholder,
|
placeholder,
|
||||||
theme = 'dark',
|
theme,
|
||||||
className = '',
|
className = '',
|
||||||
}: CodeEditorProps) {
|
}: CodeEditorProps) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
|
|
@ -81,6 +87,9 @@ export function CodeEditor({
|
||||||
extensions.push(EditorView.editable.of(false))
|
extensions.push(EditorView.editable.of(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
|
||||||
|
const effectiveTheme = theme ?? resolvedTheme
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
|
@ -88,7 +97,7 @@ export function CodeEditor({
|
||||||
height={height}
|
height={height}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
theme={theme === 'dark' ? oneDark : undefined}
|
theme={effectiveTheme === 'dark' ? oneDark : undefined}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { screen } from '@testing-library/dom'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
import { DynamicConfigForm } from '../DynamicConfigForm'
|
import { DynamicConfigForm } from '../DynamicConfigForm'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { screen } from '@testing-library/dom'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
import { DynamicField } from '../DynamicField'
|
import { DynamicField } from '../DynamicField'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||||
|
import type { UserThemeConfig } from '@/lib/theme/tokens'
|
||||||
|
import {
|
||||||
|
THEME_STORAGE_KEYS,
|
||||||
|
loadThemeConfig,
|
||||||
|
migrateOldKeys,
|
||||||
|
resetThemeToDefault,
|
||||||
|
saveThemePartial,
|
||||||
|
} from '@/lib/theme/storage'
|
||||||
|
import { applyThemePipeline, removeCustomCSS } from '@/lib/theme/pipeline'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
|
@ -13,126 +23,74 @@ type ThemeProviderProps = {
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = 'system',
|
defaultTheme = 'system',
|
||||||
storageKey = 'ui-theme',
|
storageKey: _storageKey,
|
||||||
...props
|
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [themeMode, setThemeMode] = useState<Theme>(() => {
|
||||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null
|
||||||
)
|
return saved || defaultTheme
|
||||||
|
})
|
||||||
|
const [themeConfig, setThemeConfig] = useState<UserThemeConfig>(() => loadThemeConfig())
|
||||||
|
const [systemThemeTick, setSystemThemeTick] = useState(0)
|
||||||
|
|
||||||
|
const resolvedTheme = useMemo<'dark' | 'light'>(() => {
|
||||||
|
if (themeMode !== 'system') return themeMode
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}, [themeMode, systemThemeTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement
|
migrateOldKeys()
|
||||||
|
|
||||||
root.classList.remove('light', 'dark')
|
|
||||||
|
|
||||||
if (theme === 'system') {
|
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light'
|
|
||||||
|
|
||||||
root.classList.add(systemTheme)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
root.classList.add(theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
// 应用保存的主题色
|
|
||||||
useEffect(() => {
|
|
||||||
const savedAccentColor = localStorage.getItem('accent-color')
|
|
||||||
if (savedAccentColor) {
|
|
||||||
const root = document.documentElement
|
|
||||||
const colors = {
|
|
||||||
blue: {
|
|
||||||
hsl: '221.2 83.2% 53.3%',
|
|
||||||
darkHsl: '217.2 91.2% 59.8%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
hsl: '271 91% 65%',
|
|
||||||
darkHsl: '270 95% 75%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
hsl: '142 71% 45%',
|
|
||||||
darkHsl: '142 76% 36%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
orange: {
|
|
||||||
hsl: '25 95% 53%',
|
|
||||||
darkHsl: '20 90% 48%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
pink: {
|
|
||||||
hsl: '330 81% 60%',
|
|
||||||
darkHsl: '330 85% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
hsl: '0 84% 60%',
|
|
||||||
darkHsl: '0 90% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
|
|
||||||
// 渐变色
|
|
||||||
'gradient-sunset': {
|
|
||||||
hsl: '15 95% 60%',
|
|
||||||
darkHsl: '15 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-ocean': {
|
|
||||||
hsl: '200 90% 55%',
|
|
||||||
darkHsl: '200 90% 60%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-forest': {
|
|
||||||
hsl: '150 70% 45%',
|
|
||||||
darkHsl: '150 75% 40%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-aurora': {
|
|
||||||
hsl: '310 85% 65%',
|
|
||||||
darkHsl: '310 90% 70%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-fire': {
|
|
||||||
hsl: '15 95% 55%',
|
|
||||||
darkHsl: '15 95% 60%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-twilight': {
|
|
||||||
hsl: '250 90% 60%',
|
|
||||||
darkHsl: '250 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedColor = colors[savedAccentColor as keyof typeof colors]
|
|
||||||
if (selectedColor) {
|
|
||||||
root.style.setProperty('--primary', selectedColor.hsl)
|
|
||||||
|
|
||||||
// 设置渐变(如果有)
|
|
||||||
if (selectedColor.gradient) {
|
|
||||||
root.style.setProperty('--primary-gradient', selectedColor.gradient)
|
|
||||||
root.classList.add('has-gradient')
|
|
||||||
} else {
|
|
||||||
root.style.removeProperty('--primary-gradient')
|
|
||||||
root.classList.remove('has-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = {
|
useEffect(() => {
|
||||||
theme,
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
setTheme: (theme: Theme) => {
|
const handleChange = () => {
|
||||||
localStorage.setItem(storageKey, theme)
|
if (themeMode === 'system') {
|
||||||
setTheme(theme)
|
setSystemThemeTick((prev) => prev + 1)
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
mediaQuery.addEventListener('change', handleChange)
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||||
|
}, [themeMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(resolvedTheme)
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
applyThemePipeline(themeConfig, isDark)
|
||||||
|
}, [resolvedTheme, themeConfig])
|
||||||
|
|
||||||
|
const setTheme = useCallback((mode: Theme) => {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, mode)
|
||||||
|
setThemeMode(mode)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateThemeConfig = useCallback((partial: Partial<UserThemeConfig>) => {
|
||||||
|
saveThemePartial(partial)
|
||||||
|
setThemeConfig((prev) => ({ ...prev, ...partial }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetTheme = useCallback(() => {
|
||||||
|
resetThemeToDefault()
|
||||||
|
removeCustomCSS()
|
||||||
|
setThemeConfig(loadThemeConfig())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
theme: themeMode,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
themeConfig,
|
||||||
|
updateThemeConfig,
|
||||||
|
resetTheme,
|
||||||
|
}),
|
||||||
|
[themeMode, resolvedTheme, setTheme, themeConfig, updateThemeConfig, resetTheme],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
<ThemeProviderContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProviderContext.Provider>
|
</ThemeProviderContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { useTour } from './use-tour'
|
||||||
const joyrideStyles = {
|
const joyrideStyles = {
|
||||||
options: {
|
options: {
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
primaryColor: 'hsl(var(--primary))',
|
primaryColor: 'hsl(var(--color-primary))',
|
||||||
textColor: 'hsl(var(--foreground))',
|
textColor: 'hsl(var(--color-foreground))',
|
||||||
backgroundColor: 'hsl(var(--background))',
|
backgroundColor: 'hsl(var(--color-background))',
|
||||||
arrowColor: 'hsl(var(--background))',
|
arrowColor: 'hsl(var(--color-background))',
|
||||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
|
@ -30,23 +30,23 @@ const joyrideStyles = {
|
||||||
padding: '0.5rem 0',
|
padding: '0.5rem 0',
|
||||||
},
|
},
|
||||||
buttonNext: {
|
buttonNext: {
|
||||||
backgroundColor: 'hsl(var(--primary))',
|
backgroundColor: 'hsl(var(--color-primary))',
|
||||||
color: 'hsl(var(--primary-foreground))',
|
color: 'hsl(var(--color-primary-foreground))',
|
||||||
borderRadius: 'calc(var(--radius) - 2px)',
|
borderRadius: 'calc(var(--radius) - 2px)',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
},
|
},
|
||||||
buttonBack: {
|
buttonBack: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
marginRight: '0.5rem',
|
marginRight: '0.5rem',
|
||||||
},
|
},
|
||||||
buttonSkip: {
|
buttonSkip: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
},
|
},
|
||||||
buttonClose: {
|
buttonClose: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
},
|
},
|
||||||
spotlight: {
|
spotlight: {
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ export function WavesBackground() {
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '0.5rem',
|
width: '0.5rem',
|
||||||
height: '0.5rem',
|
height: '0.5rem',
|
||||||
background: 'hsl(var(--primary) / 0.3)',
|
background: 'hsl(var(--color-primary) / 0.3)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
|
|
@ -372,7 +372,7 @@ export function WavesBackground() {
|
||||||
<style>{`
|
<style>{`
|
||||||
path {
|
path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: hsl(var(--primary) / 0.20);
|
stroke: hsl(var(--color-primary) / 0.20);
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
|
||||||
|
|
@ -13,60 +13,183 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* Color Tokens */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--color-primary: 221.2 83.2% 53.3%;
|
||||||
--card: 0 0% 100%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--color-primary-gradient: none;
|
||||||
--popover: 0 0% 100%;
|
--color-secondary: 210 40% 96.1%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--color-secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--color-muted: 210 40% 96.1%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--color-muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--primary-gradient: none; /* 默认无渐变 */
|
--color-accent: 210 40% 96.1%;
|
||||||
--secondary: 210 40% 96.1%;
|
--color-accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--color-destructive: 0 84.2% 60.2%;
|
||||||
--muted: 210 40% 96.1%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--color-background: 0 0% 100%;
|
||||||
--accent: 210 40% 96.1%;
|
--color-foreground: 222.2 84% 4.9%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--color-card: 0 0% 100%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--color-card-foreground: 222.2 84% 4.9%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--color-popover: 0 0% 100%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--color-popover-foreground: 222.2 84% 4.9%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--color-border: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--color-input: 214.3 31.8% 91.4%;
|
||||||
--radius: 0.5rem;
|
--color-ring: 221.2 83.2% 53.3%;
|
||||||
--chart-1: 221.2 83.2% 53.3%;
|
--color-chart-1: 221.2 83.2% 53.3%;
|
||||||
--chart-2: 160 60% 45%;
|
--color-chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--color-chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--color-chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--color-chart-5: 340 75% 55%;
|
||||||
|
|
||||||
|
/* Typography Tokens */
|
||||||
|
--typography-font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--typography-font-family-code: "JetBrains Mono", "Monaco", "Courier New", monospace;
|
||||||
|
--typography-font-size-xs: 0.75rem;
|
||||||
|
--typography-font-size-sm: 0.875rem;
|
||||||
|
--typography-font-size-base: 1rem;
|
||||||
|
--typography-font-size-lg: 1.125rem;
|
||||||
|
--typography-font-size-xl: 1.25rem;
|
||||||
|
--typography-font-size-2xl: 1.5rem;
|
||||||
|
--typography-font-weight-normal: 400;
|
||||||
|
--typography-font-weight-medium: 500;
|
||||||
|
--typography-font-weight-semibold: 600;
|
||||||
|
--typography-font-weight-bold: 700;
|
||||||
|
--typography-line-height-tight: 1.2;
|
||||||
|
--typography-line-height-normal: 1.5;
|
||||||
|
--typography-line-height-relaxed: 1.75;
|
||||||
|
--typography-letter-spacing-tight: -0.02em;
|
||||||
|
--typography-letter-spacing-normal: 0em;
|
||||||
|
--typography-letter-spacing-wide: 0.02em;
|
||||||
|
|
||||||
|
/* Visual Tokens */
|
||||||
|
--visual-radius-sm: 0.25rem;
|
||||||
|
--visual-radius-md: 0.375rem;
|
||||||
|
--visual-radius-lg: 0.5rem;
|
||||||
|
--visual-radius-xl: 0.75rem;
|
||||||
|
--visual-radius-full: 9999px;
|
||||||
|
--visual-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--visual-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-blur-sm: 4px;
|
||||||
|
--visual-blur-md: 12px;
|
||||||
|
--visual-blur-lg: 24px;
|
||||||
|
--visual-opacity-disabled: 0.5;
|
||||||
|
--visual-opacity-hover: 0.8;
|
||||||
|
--visual-opacity-overlay: 0.75;
|
||||||
|
|
||||||
|
/* Layout Tokens */
|
||||||
|
--layout-space-unit: 0.25rem;
|
||||||
|
--layout-space-xs: 0.5rem;
|
||||||
|
--layout-space-sm: 0.75rem;
|
||||||
|
--layout-space-md: 1rem;
|
||||||
|
--layout-space-lg: 1.5rem;
|
||||||
|
--layout-space-xl: 2rem;
|
||||||
|
--layout-space-2xl: 3rem;
|
||||||
|
--layout-sidebar-width: 16rem;
|
||||||
|
--layout-header-height: 3.5rem;
|
||||||
|
--layout-max-content-width: 1280px;
|
||||||
|
|
||||||
|
/* Animation Tokens */
|
||||||
|
--animation-anim-duration-fast: 150ms;
|
||||||
|
--animation-anim-duration-normal: 300ms;
|
||||||
|
--animation-anim-duration-slow: 500ms;
|
||||||
|
--animation-anim-easing-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-anim-easing-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
--animation-anim-easing-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--animation-anim-easing-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-colors: color 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-transform: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-opacity: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Legacy Aliases (backward compatibility) */
|
||||||
|
--background: var(--color-background);
|
||||||
|
--foreground: var(--color-foreground);
|
||||||
|
--card: var(--color-card);
|
||||||
|
--card-foreground: var(--color-card-foreground);
|
||||||
|
--popover: var(--color-popover);
|
||||||
|
--popover-foreground: var(--color-popover-foreground);
|
||||||
|
--primary: var(--color-primary);
|
||||||
|
--primary-foreground: var(--color-primary-foreground);
|
||||||
|
--primary-gradient: var(--color-primary-gradient);
|
||||||
|
--secondary: var(--color-secondary);
|
||||||
|
--secondary-foreground: var(--color-secondary-foreground);
|
||||||
|
--muted: var(--color-muted);
|
||||||
|
--muted-foreground: var(--color-muted-foreground);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-accent-foreground);
|
||||||
|
--destructive: var(--color-destructive);
|
||||||
|
--destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--border: var(--color-border);
|
||||||
|
--input: var(--color-input);
|
||||||
|
--ring: var(--color-ring);
|
||||||
|
--radius: var(--visual-radius-lg);
|
||||||
|
--chart-1: var(--color-chart-1);
|
||||||
|
--chart-2: var(--color-chart-2);
|
||||||
|
--chart-3: var(--color-chart-3);
|
||||||
|
--chart-4: var(--color-chart-4);
|
||||||
|
--chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* Color Tokens */
|
||||||
--foreground: 210 40% 98%;
|
--color-primary: 217.2 91.2% 59.8%;
|
||||||
--card: 222.2 84% 4.9%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--card-foreground: 210 40% 98%;
|
--color-primary-gradient: none;
|
||||||
--popover: 222.2 84% 4.9%;
|
--color-secondary: 217.2 32.6% 17.5%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--color-secondary-foreground: 210 40% 98%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--color-muted: 217.2 32.6% 17.5%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--color-muted-foreground: 215 20.2% 65.1%;
|
||||||
--primary-gradient: none; /* 默认无渐变 */
|
--color-accent: 217.2 32.6% 17.5%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--color-accent-foreground: 210 40% 98%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--color-destructive: 0 62.8% 30.6%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--color-background: 222.2 84% 4.9%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--color-foreground: 210 40% 98%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--color-card: 222.2 84% 4.9%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--color-card-foreground: 210 40% 98%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--color-popover: 222.2 84% 4.9%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--color-popover-foreground: 210 40% 98%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--color-border: 217.2 32.6% 17.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--color-input: 217.2 32.6% 17.5%;
|
||||||
--chart-1: 217.2 91.2% 59.8%;
|
--color-ring: 224.3 76.3% 48%;
|
||||||
--chart-2: 160 60% 50%;
|
--color-chart-1: 217.2 91.2% 59.8%;
|
||||||
--chart-3: 30 80% 60%;
|
--color-chart-2: 160 60% 50%;
|
||||||
--chart-4: 280 65% 65%;
|
--color-chart-3: 30 80% 60%;
|
||||||
--chart-5: 340 75% 60%;
|
--color-chart-4: 280 65% 65%;
|
||||||
|
--color-chart-5: 340 75% 60%;
|
||||||
|
|
||||||
|
/* Visual Tokens (dark mode shadows) */
|
||||||
|
--visual-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
--visual-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--visual-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
|
--visual-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Legacy Aliases (backward compatibility) */
|
||||||
|
--background: var(--color-background);
|
||||||
|
--foreground: var(--color-foreground);
|
||||||
|
--card: var(--color-card);
|
||||||
|
--card-foreground: var(--color-card-foreground);
|
||||||
|
--popover: var(--color-popover);
|
||||||
|
--popover-foreground: var(--color-popover-foreground);
|
||||||
|
--primary: var(--color-primary);
|
||||||
|
--primary-foreground: var(--color-primary-foreground);
|
||||||
|
--primary-gradient: var(--color-primary-gradient);
|
||||||
|
--secondary: var(--color-secondary);
|
||||||
|
--secondary-foreground: var(--color-secondary-foreground);
|
||||||
|
--muted: var(--color-muted);
|
||||||
|
--muted-foreground: var(--color-muted-foreground);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-accent-foreground);
|
||||||
|
--destructive: var(--color-destructive);
|
||||||
|
--destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--border: var(--color-border);
|
||||||
|
--input: var(--color-input);
|
||||||
|
--ring: var(--color-ring);
|
||||||
|
--chart-1: var(--color-chart-1);
|
||||||
|
--chart-2: var(--color-chart-2);
|
||||||
|
--chart-3: var(--color-chart-3);
|
||||||
|
--chart-4: var(--color-chart-4);
|
||||||
|
--chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,28 +215,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* 渐变色背景工具类 */
|
|
||||||
.bg-primary-gradient {
|
.bg-primary-gradient {
|
||||||
background: var(--primary-gradient, hsl(var(--primary)));
|
background: var(--color-primary-gradient, hsl(var(--color-primary)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
|
|
||||||
.text-primary-gradient {
|
.text-primary-gradient {
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--color-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
|
|
||||||
.has-gradient .text-primary-gradient {
|
.has-gradient .text-primary-gradient {
|
||||||
background: var(--primary-gradient);
|
background: var(--color-primary-gradient);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 渐变色边框工具类 */
|
|
||||||
.border-primary-gradient {
|
.border-primary-gradient {
|
||||||
border-image: var(--primary-gradient, linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary)))) 1;
|
border-image: var(--color-primary-gradient, linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-primary)))) 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,10 +289,9 @@
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义滚动条样式 */
|
|
||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: hsl(var(--border)) transparent;
|
scrollbar-color: hsl(var(--color-border)) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
|
@ -187,14 +305,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: hsl(var(--border));
|
background: hsl(var(--color-border));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
background: hsl(var(--color-muted-foreground) / 0.5);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
// 所有设置的 key 定义
|
// 所有设置的 key 定义
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
// 外观设置
|
// 外观设置
|
||||||
|
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.MODE */
|
||||||
THEME: 'maibot-ui-theme',
|
THEME: 'maibot-ui-theme',
|
||||||
|
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.ACCENT */
|
||||||
ACCENT_COLOR: 'accent-color',
|
ACCENT_COLOR: 'accent-color',
|
||||||
ENABLE_ANIMATIONS: 'maibot-animations',
|
ENABLE_ANIMATIONS: 'maibot-animations',
|
||||||
ENABLE_WAVES_BACKGROUND: 'maibot-waves-background',
|
ENABLE_WAVES_BACKGROUND: 'maibot-waves-background',
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,30 @@
|
||||||
import { createContext } from 'react'
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import type { UserThemeConfig } from './theme/tokens'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
export type ThemeProviderState = {
|
export type ThemeProviderState = {
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
resolvedTheme: 'dark' | 'light'
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
|
themeConfig: UserThemeConfig
|
||||||
|
updateThemeConfig: (partial: Partial<UserThemeConfig>) => void
|
||||||
|
resetTheme: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ThemeProviderState = {
|
const initialState: ThemeProviderState = {
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
|
resolvedTheme: 'light',
|
||||||
setTheme: () => null,
|
setTheme: () => null,
|
||||||
|
themeConfig: {
|
||||||
|
selectedPreset: 'light',
|
||||||
|
accentColor: '',
|
||||||
|
tokenOverrides: {},
|
||||||
|
customCSS: '',
|
||||||
|
},
|
||||||
|
updateThemeConfig: () => null,
|
||||||
|
resetTheme: () => null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import type { ColorTokens } from './tokens'
|
||||||
|
|
||||||
|
type HSL = {
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (value < min) return min
|
||||||
|
if (value > max) return max
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundToTenth = (value: number): number => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
const wrapHue = (value: number): number => ((value % 360) + 360) % 360
|
||||||
|
|
||||||
|
export const parseHSL = (hslStr: string): HSL => {
|
||||||
|
const cleaned = hslStr
|
||||||
|
.trim()
|
||||||
|
.replace(/^hsl\(/i, '')
|
||||||
|
.replace(/\)$/i, '')
|
||||||
|
.replace(/,/g, ' ')
|
||||||
|
const parts = cleaned.split(/\s+/).filter(Boolean)
|
||||||
|
const rawH = parts[0] ?? '0'
|
||||||
|
const rawS = parts[1] ?? '0%'
|
||||||
|
const rawL = parts[2] ?? '0%'
|
||||||
|
|
||||||
|
const h = Number.parseFloat(rawH)
|
||||||
|
const s = Number.parseFloat(rawS.replace('%', ''))
|
||||||
|
const l = Number.parseFloat(rawL.replace('%', ''))
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Number.isNaN(h) ? 0 : h,
|
||||||
|
s: Number.isNaN(s) ? 0 : s,
|
||||||
|
l: Number.isNaN(l) ? 0 : l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatHSL = (h: number, s: number, l: number): string => {
|
||||||
|
const safeH = roundToTenth(wrapHue(h))
|
||||||
|
const safeS = roundToTenth(clamp(s, 0, 100))
|
||||||
|
const safeL = roundToTenth(clamp(l, 0, 100))
|
||||||
|
return `${safeH} ${safeS}% ${safeL}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hexToHSL = (hex: string): string => {
|
||||||
|
let cleaned = hex.trim().replace('#', '')
|
||||||
|
if (cleaned.length === 3) {
|
||||||
|
cleaned = cleaned
|
||||||
|
.split('')
|
||||||
|
.map((char) => `${char}${char}`)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.length !== 6) {
|
||||||
|
return formatHSL(0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Number.parseInt(cleaned.slice(0, 2), 16) / 255
|
||||||
|
const g = Number.parseInt(cleaned.slice(2, 4), 16) / 255
|
||||||
|
const b = Number.parseInt(cleaned.slice(4, 6), 16) / 255
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
const delta = max - min
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
let h = 0
|
||||||
|
let s = 0
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / delta + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / delta + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / delta + 4
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h *= 60
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatHSL(h, s * 100, l * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustLightness = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, l + amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustSaturation = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s + amount, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateHue = (hsl: string, degrees: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h + degrees, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLightness = (hsl: string, lightness: number): string => {
|
||||||
|
const { h, s } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, lightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSaturation = (hsl: string, saturation: number): string => {
|
||||||
|
const { h, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, saturation, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadableForeground = (hsl: string): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
const neutralSaturation = clamp(s * 0.15, 6, 20)
|
||||||
|
return l > 60
|
||||||
|
? formatHSL(h, neutralSaturation, 10)
|
||||||
|
: formatHSL(h, neutralSaturation, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens => {
|
||||||
|
const accent = parseHSL(accentHSL)
|
||||||
|
const primary = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
|
||||||
|
const background = isDark ? '222.2 84% 4.9%' : '0 0% 100%'
|
||||||
|
const foreground = isDark ? '210 40% 98%' : '222.2 84% 4.9%'
|
||||||
|
|
||||||
|
const secondary = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.35, 8, 40),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const muted = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.12, 2, 18),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const accentVariant = formatHSL(
|
||||||
|
accent.h + 35,
|
||||||
|
clamp(accent.s * 0.6, 20, 85),
|
||||||
|
isDark ? clamp(accent.l * 0.6 + 8, 25, 60) : clamp(accent.l * 0.8 + 14, 40, 75),
|
||||||
|
)
|
||||||
|
|
||||||
|
const destructive = formatHSL(
|
||||||
|
0,
|
||||||
|
clamp(accent.s, 60, 90),
|
||||||
|
isDark ? 30.6 : 60.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
const border = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.2, 5, 25),
|
||||||
|
isDark ? 17.5 : 91.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mutedForeground = setSaturation(
|
||||||
|
setLightness(muted, isDark ? 65.1 : 46.9),
|
||||||
|
clamp(accent.s * 0.2, 10, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartBase = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
const chartSteps = [0, 72, 144, 216, 288]
|
||||||
|
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
||||||
|
|
||||||
|
const card = adjustLightness(background, isDark ? 2 : -1)
|
||||||
|
const popover = adjustLightness(background, isDark ? 3 : -0.5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
'primary-foreground': getReadableForeground(primary),
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary,
|
||||||
|
'secondary-foreground': getReadableForeground(secondary),
|
||||||
|
muted,
|
||||||
|
'muted-foreground': mutedForeground,
|
||||||
|
accent: accentVariant,
|
||||||
|
'accent-foreground': getReadableForeground(accentVariant),
|
||||||
|
destructive,
|
||||||
|
'destructive-foreground': getReadableForeground(destructive),
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
card,
|
||||||
|
'card-foreground': foreground,
|
||||||
|
popover,
|
||||||
|
'popover-foreground': foreground,
|
||||||
|
border,
|
||||||
|
input: border,
|
||||||
|
ring: primary,
|
||||||
|
'chart-1': charts[0],
|
||||||
|
'chart-2': charts[1],
|
||||||
|
'chart-3': charts[2],
|
||||||
|
'chart-4': charts[3],
|
||||||
|
'chart-5': charts[4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import type { ThemeTokens, UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
|
import { generatePalette } from './palette'
|
||||||
|
import { getPresetById } from './presets'
|
||||||
|
import { sanitizeCSS } from './sanitizer'
|
||||||
|
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
|
||||||
|
|
||||||
|
const CUSTOM_CSS_ID = 'maibot-custom-css'
|
||||||
|
|
||||||
|
const mergeTokens = (base: ThemeTokens, overrides: Partial<ThemeTokens>): ThemeTokens => {
|
||||||
|
return {
|
||||||
|
color: {
|
||||||
|
...base.color,
|
||||||
|
...(overrides.color ?? {}),
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
...base.typography,
|
||||||
|
...(overrides.typography ?? {}),
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
...base.visual,
|
||||||
|
...(overrides.visual ?? {}),
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
...base.layout,
|
||||||
|
...(overrides.layout ?? {}),
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
...base.animation,
|
||||||
|
...(overrides.animation ?? {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTokens = (config: UserThemeConfig, isDark: boolean): ThemeTokens => {
|
||||||
|
const baseTokens = isDark ? defaultDarkTokens : defaultLightTokens
|
||||||
|
let mergedTokens = mergeTokens(baseTokens, {})
|
||||||
|
|
||||||
|
if (config.accentColor) {
|
||||||
|
const paletteTokens = generatePalette(config.accentColor, isDark)
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, { color: paletteTokens })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.selectedPreset) {
|
||||||
|
const preset = getPresetById(config.selectedPreset)
|
||||||
|
if (preset?.tokens) {
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, preset.tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.tokenOverrides) {
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, config.tokenOverrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputedTokens(config: UserThemeConfig, isDark: boolean): ThemeTokens {
|
||||||
|
return buildTokens(config, isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectTokensAsCSS(tokens: ThemeTokens, target: HTMLElement): void {
|
||||||
|
Object.entries(tokens.color).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('color', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.typography).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('typography', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.visual).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('visual', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.layout).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('layout', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.animation).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('animation', key), String(value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectCustomCSS(css: string): void {
|
||||||
|
if (css.trim().length === 0) {
|
||||||
|
removeCustomCSS()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.getElementById(CUSTOM_CSS_ID)
|
||||||
|
if (existing) {
|
||||||
|
existing.textContent = css
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = CUSTOM_CSS_ID
|
||||||
|
style.textContent = css
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomCSS(): void {
|
||||||
|
const existing = document.getElementById(CUSTOM_CSS_ID)
|
||||||
|
if (existing) {
|
||||||
|
existing.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void {
|
||||||
|
const root = document.documentElement
|
||||||
|
const tokens = buildTokens(config, isDark)
|
||||||
|
|
||||||
|
injectTokensAsCSS(tokens, root)
|
||||||
|
|
||||||
|
if (config.customCSS) {
|
||||||
|
const sanitized = sanitizeCSS(config.customCSS)
|
||||||
|
if (sanitized.css.trim().length > 0) {
|
||||||
|
injectCustomCSS(sanitized.css)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCustomCSS()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Theme Presets 定义
|
||||||
|
* 提供内置的亮色和暗色主题预设
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultDarkTokens,
|
||||||
|
defaultLightTokens,
|
||||||
|
} from './tokens'
|
||||||
|
import type { ThemePreset } from './tokens'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Light Preset
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultLightPreset: ThemePreset = {
|
||||||
|
id: 'light',
|
||||||
|
name: '默认亮色',
|
||||||
|
description: '默认亮色主题',
|
||||||
|
tokens: defaultLightTokens,
|
||||||
|
isDark: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Dark Preset
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultDarkPreset: ThemePreset = {
|
||||||
|
id: 'dark',
|
||||||
|
name: '默认暗色',
|
||||||
|
description: '默认暗色主题',
|
||||||
|
tokens: defaultDarkTokens,
|
||||||
|
isDark: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Built-in Presets Collection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const builtInPresets: ThemePreset[] = [
|
||||||
|
defaultLightPreset,
|
||||||
|
defaultDarkPreset,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Preset ID
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DEFAULT_PRESET_ID = 'light'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Preset Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 获取预设
|
||||||
|
* @param id - 预设 ID
|
||||||
|
* @returns 对应的预设,如果不存在则返回 undefined
|
||||||
|
*/
|
||||||
|
export function getPresetById(id: string): ThemePreset | undefined {
|
||||||
|
return builtInPresets.find((preset) => preset.id === id)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* CSS 安全过滤器 - 用于过滤用户自定义 CSS 中的危险内容
|
||||||
|
* 防范外部资源加载和 XSS 注入
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SanitizeResult {
|
||||||
|
css: string
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤规则:基于正则表达式的危险模式检测
|
||||||
|
* 与匹配的危险模式相关的警告消息
|
||||||
|
*/
|
||||||
|
interface FilterRule {
|
||||||
|
pattern: RegExp
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义所有过滤规则
|
||||||
|
*/
|
||||||
|
const filterRules: FilterRule[] = [
|
||||||
|
{
|
||||||
|
pattern: /@import\s+(?:url\()?['"]?(?:https?:|\/\/)?[^)'"]+['"]?\)?[;]?/gi,
|
||||||
|
message: '移除 @import 语句(禁止加载外部资源)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /url\s*\(\s*(?:https?:|\/\/|data:|javascript:)[^)]*\)/gi,
|
||||||
|
message: '移除 url() 调用(禁止外部请求)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /javascript:/gi,
|
||||||
|
message: '移除 javascript: 协议(XSS 防护)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /expression\s*\(\s*[^)]*\)/gi,
|
||||||
|
message: '移除 expression() 函数(IE 遗留 XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /-moz-binding\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 -moz-binding 属性(Firefox XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /behavior\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 behavior: 属性(IE HTC)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将原始 CSS 按行分割并跟踪行号
|
||||||
|
*/
|
||||||
|
function splitCSSByLines(css: string): string[] {
|
||||||
|
return css.split(/\r?\n/)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 CSS 中查找模式匹配的行号
|
||||||
|
*/
|
||||||
|
function findMatchingLineNumbers(css: string, pattern: RegExp): number[] {
|
||||||
|
const lines = splitCSSByLines(css)
|
||||||
|
const matchingLines: number[] = []
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (pattern.test(line)) {
|
||||||
|
matchingLines.push(index + 1) // 行号从 1 开始
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return matchingLines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤 CSS 中的危险内容
|
||||||
|
* @param rawCSS 原始 CSS 字符串
|
||||||
|
* @returns 包含过滤后的 CSS 和警告列表的对象
|
||||||
|
*/
|
||||||
|
export function sanitizeCSS(rawCSS: string): SanitizeResult {
|
||||||
|
let sanitizedCSS = rawCSS
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
// 应用所有过滤规则
|
||||||
|
filterRules.forEach((rule) => {
|
||||||
|
const lineNumbers = findMatchingLineNumbers(sanitizedCSS, rule.pattern)
|
||||||
|
|
||||||
|
// 对每个匹配的行生成警告
|
||||||
|
lineNumbers.forEach((lineNum) => {
|
||||||
|
warnings.push(`Line ${lineNum}: ${rule.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从 CSS 中移除匹配内容
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(rule.pattern, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理多余的空白行
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(/\n\s*\n/g, '\n').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
css: sanitizedCSS,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速检查 CSS 是否包含危险模式
|
||||||
|
* @param css CSS 字符串
|
||||||
|
* @returns 如果包含危险模式返回 true,否则返回 false
|
||||||
|
*/
|
||||||
|
export function isCSSSafe(css: string): boolean {
|
||||||
|
return !filterRules.some((rule) => rule.pattern.test(css))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
/**
|
||||||
|
* 主题配置的 localStorage 存储管理模块
|
||||||
|
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题存储 key 定义
|
||||||
|
* 统一使用 'maibot-theme-*' 前缀,替代现有的 'ui-theme'、'maibot-ui-theme' 和 'accent-color'
|
||||||
|
*/
|
||||||
|
export const THEME_STORAGE_KEYS = {
|
||||||
|
MODE: 'maibot-theme-mode',
|
||||||
|
PRESET: 'maibot-theme-preset',
|
||||||
|
ACCENT: 'maibot-theme-accent',
|
||||||
|
OVERRIDES: 'maibot-theme-overrides',
|
||||||
|
CUSTOM_CSS: 'maibot-theme-custom-css',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认主题配置
|
||||||
|
*/
|
||||||
|
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
||||||
|
selectedPreset: 'light',
|
||||||
|
accentColor: 'blue',
|
||||||
|
tokenOverrides: {},
|
||||||
|
customCSS: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 加载完整主题配置
|
||||||
|
* 缺失值使用合理默认值
|
||||||
|
*
|
||||||
|
* @returns 加载的主题配置对象
|
||||||
|
*/
|
||||||
|
export function loadThemeConfig(): UserThemeConfig {
|
||||||
|
const preset = localStorage.getItem(THEME_STORAGE_KEYS.PRESET)
|
||||||
|
const accent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||||
|
const overridesStr = localStorage.getItem(THEME_STORAGE_KEYS.OVERRIDES)
|
||||||
|
const customCSS = localStorage.getItem(THEME_STORAGE_KEYS.CUSTOM_CSS)
|
||||||
|
|
||||||
|
// 解析 tokenOverrides JSON
|
||||||
|
let tokenOverrides = {}
|
||||||
|
if (overridesStr) {
|
||||||
|
try {
|
||||||
|
tokenOverrides = JSON.parse(overridesStr)
|
||||||
|
} catch {
|
||||||
|
// JSON 解析失败,使用空对象
|
||||||
|
tokenOverrides = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
||||||
|
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
||||||
|
tokenOverrides,
|
||||||
|
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存完整主题配置到 localStorage
|
||||||
|
*
|
||||||
|
* @param config - 要保存的主题配置
|
||||||
|
*/
|
||||||
|
export function saveThemeConfig(config: UserThemeConfig): void {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部分更新主题配置
|
||||||
|
* 先加载现有配置,合并部分更新,再保存
|
||||||
|
*
|
||||||
|
* @param partial - 部分主题配置更新
|
||||||
|
*/
|
||||||
|
export function saveThemePartial(partial: Partial<UserThemeConfig>): void {
|
||||||
|
const current = loadThemeConfig()
|
||||||
|
const updated: UserThemeConfig = {
|
||||||
|
...current,
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
saveThemeConfig(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出主题配置为美化格式的 JSON 字符串
|
||||||
|
*
|
||||||
|
* @returns 格式化的 JSON 字符串
|
||||||
|
*/
|
||||||
|
export function exportThemeJSON(): string {
|
||||||
|
const config = loadThemeConfig()
|
||||||
|
return JSON.stringify(config, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JSON 字符串导入主题配置
|
||||||
|
* 包含基础的格式和字段校验
|
||||||
|
*
|
||||||
|
* @param json - JSON 字符串
|
||||||
|
* @returns 导入结果,包含成功状态和错误列表
|
||||||
|
*/
|
||||||
|
export function importThemeJSON(
|
||||||
|
json: string,
|
||||||
|
): { success: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// JSON 格式校验
|
||||||
|
let config: unknown
|
||||||
|
try {
|
||||||
|
config = JSON.parse(json)
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [`Invalid JSON format: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本对象类型校验
|
||||||
|
if (typeof config !== 'object' || config === null) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ['Configuration must be a JSON object'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configObj = config as Record<string, unknown>
|
||||||
|
|
||||||
|
// 必要字段存在性校验
|
||||||
|
if (typeof configObj.selectedPreset !== 'string') {
|
||||||
|
errors.push('selectedPreset must be a string')
|
||||||
|
}
|
||||||
|
if (typeof configObj.accentColor !== 'string') {
|
||||||
|
errors.push('accentColor must be a string')
|
||||||
|
}
|
||||||
|
if (typeof configObj.customCSS !== 'string') {
|
||||||
|
errors.push('customCSS must be a string')
|
||||||
|
}
|
||||||
|
if (configObj.tokenOverrides !== undefined && typeof configObj.tokenOverrides !== 'object') {
|
||||||
|
errors.push('tokenOverrides must be an object')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验通过,保存配置
|
||||||
|
const validConfig: UserThemeConfig = {
|
||||||
|
selectedPreset: configObj.selectedPreset as string,
|
||||||
|
accentColor: configObj.accentColor as string,
|
||||||
|
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
|
||||||
|
customCSS: configObj.customCSS as string,
|
||||||
|
}
|
||||||
|
|
||||||
|
saveThemeConfig(validConfig)
|
||||||
|
return { success: true, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置主题配置为默认值
|
||||||
|
* 删除所有 THEME_STORAGE_KEYS 对应的 localStorage 项
|
||||||
|
*/
|
||||||
|
export function resetThemeToDefault(): void {
|
||||||
|
Object.values(THEME_STORAGE_KEYS).forEach((key) => {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移旧的 localStorage key 到新 key
|
||||||
|
* 处理:
|
||||||
|
* - 'ui-theme' 或 'maibot-ui-theme' → 'maibot-theme-mode'
|
||||||
|
* - 'accent-color' → 'maibot-theme-accent'
|
||||||
|
* 迁移完成后删除旧 key,避免重复迁移
|
||||||
|
*/
|
||||||
|
export function migrateOldKeys(): void {
|
||||||
|
// 迁移主题模式
|
||||||
|
// 优先使用 'ui-theme'(因为 ThemeProvider 默认使用它)
|
||||||
|
const uiTheme = localStorage.getItem('ui-theme')
|
||||||
|
const maiTheme = localStorage.getItem('maibot-ui-theme')
|
||||||
|
const newMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE)
|
||||||
|
|
||||||
|
if (!newMode) {
|
||||||
|
if (uiTheme) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, uiTheme)
|
||||||
|
} else if (maiTheme) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, maiTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移强调色
|
||||||
|
const accentColor = localStorage.getItem('accent-color')
|
||||||
|
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||||
|
|
||||||
|
if (accentColor && !newAccent) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧 key
|
||||||
|
localStorage.removeItem('ui-theme')
|
||||||
|
localStorage.removeItem('maibot-ui-theme')
|
||||||
|
localStorage.removeItem('accent-color')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
/**
|
||||||
|
* Design Token Schema 定义
|
||||||
|
* 集中管理所有设计令牌(颜色、排版、间距、阴影、动画等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ColorTokens = {
|
||||||
|
primary: string
|
||||||
|
'primary-foreground': string
|
||||||
|
'primary-gradient': string
|
||||||
|
secondary: string
|
||||||
|
'secondary-foreground': string
|
||||||
|
muted: string
|
||||||
|
'muted-foreground': string
|
||||||
|
accent: string
|
||||||
|
'accent-foreground': string
|
||||||
|
destructive: string
|
||||||
|
'destructive-foreground': string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
card: string
|
||||||
|
'card-foreground': string
|
||||||
|
popover: string
|
||||||
|
'popover-foreground': string
|
||||||
|
border: string
|
||||||
|
input: string
|
||||||
|
ring: string
|
||||||
|
'chart-1': string
|
||||||
|
'chart-2': string
|
||||||
|
'chart-3': string
|
||||||
|
'chart-4': string
|
||||||
|
'chart-5': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typography Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TypographyTokens = {
|
||||||
|
'font-family-base': string
|
||||||
|
'font-family-code': string
|
||||||
|
'font-size-xs': string
|
||||||
|
'font-size-sm': string
|
||||||
|
'font-size-base': string
|
||||||
|
'font-size-lg': string
|
||||||
|
'font-size-xl': string
|
||||||
|
'font-size-2xl': string
|
||||||
|
'font-weight-normal': number
|
||||||
|
'font-weight-medium': number
|
||||||
|
'font-weight-semibold': number
|
||||||
|
'font-weight-bold': number
|
||||||
|
'line-height-tight': number
|
||||||
|
'line-height-normal': number
|
||||||
|
'line-height-relaxed': number
|
||||||
|
'letter-spacing-tight': string
|
||||||
|
'letter-spacing-normal': string
|
||||||
|
'letter-spacing-wide': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visual Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type VisualTokens = {
|
||||||
|
'radius-sm': string
|
||||||
|
'radius-md': string
|
||||||
|
'radius-lg': string
|
||||||
|
'radius-xl': string
|
||||||
|
'radius-full': string
|
||||||
|
'shadow-sm': string
|
||||||
|
'shadow-md': string
|
||||||
|
'shadow-lg': string
|
||||||
|
'shadow-xl': string
|
||||||
|
'blur-sm': string
|
||||||
|
'blur-md': string
|
||||||
|
'blur-lg': string
|
||||||
|
'opacity-disabled': number
|
||||||
|
'opacity-hover': number
|
||||||
|
'opacity-overlay': number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Layout Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LayoutTokens = {
|
||||||
|
'space-unit': string
|
||||||
|
'space-xs': string
|
||||||
|
'space-sm': string
|
||||||
|
'space-md': string
|
||||||
|
'space-lg': string
|
||||||
|
'space-xl': string
|
||||||
|
'space-2xl': string
|
||||||
|
'sidebar-width': string
|
||||||
|
'header-height': string
|
||||||
|
'max-content-width': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Animation Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AnimationTokens = {
|
||||||
|
'anim-duration-fast': string
|
||||||
|
'anim-duration-normal': string
|
||||||
|
'anim-duration-slow': string
|
||||||
|
'anim-easing-default': string
|
||||||
|
'anim-easing-in': string
|
||||||
|
'anim-easing-out': string
|
||||||
|
'anim-easing-in-out': string
|
||||||
|
'transition-colors': string
|
||||||
|
'transition-transform': string
|
||||||
|
'transition-opacity': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregated Theme Tokens
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemeTokens = {
|
||||||
|
color: ColorTokens
|
||||||
|
typography: TypographyTokens
|
||||||
|
visual: VisualTokens
|
||||||
|
layout: LayoutTokens
|
||||||
|
animation: AnimationTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Preset & Config Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemePreset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
tokens: ThemeTokens
|
||||||
|
isDark: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserThemeConfig = {
|
||||||
|
selectedPreset: string
|
||||||
|
accentColor: string
|
||||||
|
tokenOverrides: Partial<ThemeTokens>
|
||||||
|
customCSS: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Light Tokens (from index.css :root)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultLightTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '221.2 83.2% 53.3%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '210 40% 96.1%',
|
||||||
|
'secondary-foreground': '222.2 47.4% 11.2%',
|
||||||
|
muted: '210 40% 96.1%',
|
||||||
|
'muted-foreground': '215.4 16.3% 46.9%',
|
||||||
|
accent: '210 40% 96.1%',
|
||||||
|
'accent-foreground': '222.2 47.4% 11.2%',
|
||||||
|
destructive: '0 84.2% 60.2%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '0 0% 100%',
|
||||||
|
foreground: '222.2 84% 4.9%',
|
||||||
|
card: '0 0% 100%',
|
||||||
|
'card-foreground': '222.2 84% 4.9%',
|
||||||
|
popover: '0 0% 100%',
|
||||||
|
'popover-foreground': '222.2 84% 4.9%',
|
||||||
|
border: '214.3 31.8% 91.4%',
|
||||||
|
input: '214.3 31.8% 91.4%',
|
||||||
|
ring: '221.2 83.2% 53.3%',
|
||||||
|
'chart-1': '221.2 83.2% 53.3%',
|
||||||
|
'chart-2': '160 60% 45%',
|
||||||
|
'chart-3': '30 80% 55%',
|
||||||
|
'chart-4': '280 65% 60%',
|
||||||
|
'chart-5': '340 75% 55%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Dark Tokens (from index.css .dark)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultDarkTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '217.2 91.2% 59.8%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '217.2 32.6% 17.5%',
|
||||||
|
'secondary-foreground': '210 40% 98%',
|
||||||
|
muted: '217.2 32.6% 17.5%',
|
||||||
|
'muted-foreground': '215 20.2% 65.1%',
|
||||||
|
accent: '217.2 32.6% 17.5%',
|
||||||
|
'accent-foreground': '210 40% 98%',
|
||||||
|
destructive: '0 62.8% 30.6%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '222.2 84% 4.9%',
|
||||||
|
foreground: '210 40% 98%',
|
||||||
|
card: '222.2 84% 4.9%',
|
||||||
|
'card-foreground': '210 40% 98%',
|
||||||
|
popover: '222.2 84% 4.9%',
|
||||||
|
'popover-foreground': '210 40% 98%',
|
||||||
|
border: '217.2 32.6% 17.5%',
|
||||||
|
input: '217.2 32.6% 17.5%',
|
||||||
|
ring: '224.3 76.3% 48%',
|
||||||
|
'chart-1': '217.2 91.2% 59.8%',
|
||||||
|
'chart-2': '160 60% 50%',
|
||||||
|
'chart-3': '30 80% 60%',
|
||||||
|
'chart-4': '280 65% 65%',
|
||||||
|
'chart-5': '340 75% 60%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.25)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.4)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Token 类别和 key 转换为 CSS 变量名
|
||||||
|
* @example tokenToCSSVarName('color', 'primary') => '--color-primary'
|
||||||
|
*/
|
||||||
|
export function tokenToCSSVarName(
|
||||||
|
category: keyof ThemeTokens | 'color' | 'typography' | 'visual' | 'layout' | 'animation',
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return `--${category}-${key}`
|
||||||
|
}
|
||||||
|
|
@ -341,7 +341,7 @@ export function AnnualReportPage() {
|
||||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||||
cursor={{ fill: 'transparent' }}
|
cursor={{ fill: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="count" fill="hsl(var(--color-primary))" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
BotInfoSection,
|
BotInfoSection,
|
||||||
PersonalitySection,
|
PersonalitySection,
|
||||||
ChatSection,
|
|
||||||
DreamSection,
|
DreamSection,
|
||||||
LPMMSection,
|
LPMMSection,
|
||||||
LogSection,
|
LogSection,
|
||||||
|
|
@ -625,7 +624,6 @@ function BotConfigPageContent() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
language="toml"
|
language="toml"
|
||||||
theme="dark"
|
|
||||||
height="calc(100vh - 280px)"
|
height="calc(100vh - 280px)"
|
||||||
minHeight="500px"
|
minHeight="500px"
|
||||||
placeholder="TOML 配置内容"
|
placeholder="TOML 配置内容"
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@ function ModelConfigPageContent() {
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
const [providers, setProviders] = useState<string[]>([])
|
const [providers, setProviders] = useState<string[]>([])
|
||||||
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
|
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
|
||||||
const [modelNames, setModelNames] = useState<string[]>([])
|
|
||||||
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
|
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -183,7 +182,6 @@ function ModelConfigPageContent() {
|
||||||
const config = await getModelConfig()
|
const config = await getModelConfig()
|
||||||
const modelList = (config.models as ModelInfo[]) || []
|
const modelList = (config.models as ModelInfo[]) || []
|
||||||
setModels(modelList)
|
setModels(modelList)
|
||||||
setModelNames(modelList.map((m) => m.name))
|
|
||||||
|
|
||||||
const providerList = (config.api_providers as ProviderConfig[]) || []
|
const providerList = (config.api_providers as ProviderConfig[]) || []
|
||||||
setProviders(providerList.map((p) => p.name))
|
setProviders(providerList.map((p) => p.name))
|
||||||
|
|
@ -433,8 +431,6 @@ function ModelConfigPageContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
|
|
||||||
// 如果模型名称发生变化,更新任务配置中对该模型的引用
|
// 如果模型名称发生变化,更新任务配置中对该模型的引用
|
||||||
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
|
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
|
||||||
|
|
@ -492,8 +488,6 @@ function ModelConfigPageContent() {
|
||||||
if (deletingIndex !== null) {
|
if (deletingIndex !== null) {
|
||||||
const newModels = models.filter((_, i) => i !== deletingIndex)
|
const newModels = models.filter((_, i) => i !== deletingIndex)
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
// 重新检查任务配置问题
|
// 重新检查任务配置问题
|
||||||
checkTaskConfigIssues(taskConfig, newModels)
|
checkTaskConfigIssues(taskConfig, newModels)
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -546,8 +540,6 @@ function ModelConfigPageContent() {
|
||||||
const deletedCount = selectedModels.size
|
const deletedCount = selectedModels.size
|
||||||
const newModels = models.filter((_, index) => !selectedModels.has(index))
|
const newModels = models.filter((_, index) => !selectedModels.has(index))
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
// 重新检查任务配置问题
|
// 重新检查任务配置问题
|
||||||
checkTaskConfigIssues(taskConfig, newModels)
|
checkTaskConfigIssues(taskConfig, newModels)
|
||||||
setSelectedModels(new Set())
|
setSelectedModels(new Set())
|
||||||
|
|
@ -558,53 +550,6 @@ function ModelConfigPageContent() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新任务配置
|
|
||||||
const updateTaskConfig = (
|
|
||||||
taskName: keyof ModelTaskConfig,
|
|
||||||
field: keyof TaskConfig,
|
|
||||||
value: string[] | number | string
|
|
||||||
) => {
|
|
||||||
if (!taskConfig) return
|
|
||||||
|
|
||||||
// 检测 embedding 模型列表变化
|
|
||||||
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
|
|
||||||
const previousModels = previousEmbeddingModelsRef.current
|
|
||||||
const newModels = value as string[]
|
|
||||||
|
|
||||||
// 判断是否有变化(添加、删除或替换)
|
|
||||||
const hasChanges =
|
|
||||||
previousModels.length !== newModels.length ||
|
|
||||||
previousModels.some(model => !newModels.includes(model)) ||
|
|
||||||
newModels.some(model => !previousModels.includes(model))
|
|
||||||
|
|
||||||
if (hasChanges && previousModels.length > 0) {
|
|
||||||
// 存储待更新的配置
|
|
||||||
pendingEmbeddingUpdateRef.current = { field, value }
|
|
||||||
// 显示警告对话框
|
|
||||||
setEmbeddingWarningOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正常更新配置
|
|
||||||
const newTaskConfig = {
|
|
||||||
...taskConfig,
|
|
||||||
[taskName]: {
|
|
||||||
...taskConfig[taskName],
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
setTaskConfig(newTaskConfig)
|
|
||||||
|
|
||||||
// 重新检查任务配置问题
|
|
||||||
checkTaskConfigIssues(newTaskConfig, models)
|
|
||||||
|
|
||||||
// 如果是 embedding 模型列表,更新 ref
|
|
||||||
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
|
|
||||||
previousEmbeddingModelsRef.current = [...(value as string[])]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认更新嵌入模型
|
// 确认更新嵌入模型
|
||||||
const handleConfirmEmbeddingChange = () => {
|
const handleConfirmEmbeddingChange = () => {
|
||||||
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return
|
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return
|
||||||
|
|
|
||||||
|
|
@ -403,15 +403,15 @@ function IndexPageContent() {
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
requests: {
|
requests: {
|
||||||
label: '请求数',
|
label: '请求数',
|
||||||
color: 'hsl(var(--chart-1))',
|
color: 'hsl(var(--color-chart-1))',
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
label: '花费(¥)',
|
label: '花费(¥)',
|
||||||
color: 'hsl(var(--chart-2))',
|
color: 'hsl(var(--color-chart-2))',
|
||||||
},
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
label: 'Tokens',
|
label: 'Tokens',
|
||||||
color: 'hsl(var(--chart-3))',
|
color: 'hsl(var(--color-chart-3))',
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
|
@ -738,17 +738,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
|
||||||
<LineChart data={hourly_data}>
|
<LineChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -772,17 +772,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||||
<BarChart data={hourly_data}>
|
<BarChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -800,17 +800,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||||
<BarChart data={hourly_data}>
|
<BarChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -889,7 +889,7 @@ function IndexPageContent() {
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full ml-2 flex-shrink-0"
|
className="w-3 h-3 rounded-full ml-2 flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `hsl(var(--chart-${(index % 5) + 1}))`,
|
backgroundColor: `hsl(var(--color-chart-${(index % 5) + 1}))`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -992,28 +992,28 @@ function IndexPageContent() {
|
||||||
config={{
|
config={{
|
||||||
requests: {
|
requests: {
|
||||||
label: '请求数',
|
label: '请求数',
|
||||||
color: 'hsl(var(--chart-1))',
|
color: 'hsl(var(--color-chart-1))',
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
label: '花费(¥)',
|
label: '花费(¥)',
|
||||||
color: 'hsl(var(--chart-2))',
|
color: 'hsl(var(--color-chart-2))',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
className="h-[400px] sm:h-[500px] w-full aspect-auto"
|
className="h-[400px] sm:h-[500px] w-full aspect-auto"
|
||||||
>
|
>
|
||||||
<BarChart data={daily_data}>
|
<BarChart data={daily_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
}}
|
}}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis yAxisId="left" stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis yAxisId="left" stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
|
|
|
||||||
|
|
@ -611,7 +611,6 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
language="toml"
|
language="toml"
|
||||||
theme="dark"
|
|
||||||
height="calc(100vh - 350px)"
|
height="calc(100vh - 350px)"
|
||||||
minHeight="500px"
|
minHeight="500px"
|
||||||
placeholder="TOML 配置内容"
|
placeholder="TOML 配置内容"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Palette, Info, Shield, Eye, EyeOff, Copy, RefreshCw, Check, CheckCircle2, XCircle, AlertTriangle, Settings, RotateCcw, Database, Download, Upload, Trash2, HardDrive } from 'lucide-react'
|
import { Palette, Info, Shield, Eye, EyeOff, Copy, RefreshCw, Check, CheckCircle2, XCircle, AlertTriangle, Settings, RotateCcw, Database, Download, Upload, Trash2, HardDrive } from 'lucide-react'
|
||||||
import { useTheme } from '@/components/use-theme'
|
import { useTheme } from '@/components/use-theme'
|
||||||
import { useAnimation } from '@/hooks/use-animation'
|
import { useAnimation } from '@/hooks/use-animation'
|
||||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
|
@ -47,6 +47,27 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
import { getComputedTokens } from '@/lib/theme/pipeline'
|
||||||
|
import { hexToHSL } from '@/lib/theme/palette'
|
||||||
|
import { defaultLightTokens } from '@/lib/theme/tokens'
|
||||||
|
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
||||||
|
import type { ThemeTokens } from '@/lib/theme/tokens'
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@/components/ui/accordion'
|
||||||
|
import { CodeEditor } from '@/components/CodeEditor'
|
||||||
|
import { sanitizeCSS } from '@/lib/theme/sanitizer'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||||
|
|
@ -101,146 +122,147 @@ export function SettingsPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用主题色的辅助函数
|
// 辅助函数:将 HSL 字符串转换为 HEX
|
||||||
function applyAccentColor(color: string) {
|
function hslToHex(hsl: string): string {
|
||||||
const root = document.documentElement
|
if (!hsl) return '#000000'
|
||||||
|
|
||||||
// 预设颜色配置
|
// 解析 "221.2 83.2% 53.3%" 格式
|
||||||
const colors = {
|
const parts = hsl.split(' ').filter(Boolean)
|
||||||
// 单色
|
if (parts.length < 3) return '#000000'
|
||||||
blue: {
|
|
||||||
hsl: '221.2 83.2% 53.3%',
|
|
||||||
darkHsl: '217.2 91.2% 59.8%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
hsl: '271 91% 65%',
|
|
||||||
darkHsl: '270 95% 75%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
hsl: '142 71% 45%',
|
|
||||||
darkHsl: '142 76% 36%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
orange: {
|
|
||||||
hsl: '25 95% 53%',
|
|
||||||
darkHsl: '20 90% 48%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
pink: {
|
|
||||||
hsl: '330 81% 60%',
|
|
||||||
darkHsl: '330 85% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
hsl: '0 84% 60%',
|
|
||||||
darkHsl: '0 90% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
|
|
||||||
// 渐变色
|
const h = parseFloat(parts[0])
|
||||||
'gradient-sunset': {
|
const s = parseFloat(parts[1].replace('%', ''))
|
||||||
hsl: '15 95% 60%',
|
const l = parseFloat(parts[2].replace('%', ''))
|
||||||
darkHsl: '15 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
|
const sDecimal = s / 100
|
||||||
},
|
const lDecimal = l / 100
|
||||||
'gradient-ocean': {
|
|
||||||
hsl: '200 90% 55%',
|
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
|
||||||
darkHsl: '200 90% 60%',
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||||
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
|
const m = lDecimal - c / 2
|
||||||
},
|
|
||||||
'gradient-forest': {
|
let r = 0, g = 0, b = 0
|
||||||
hsl: '150 70% 45%',
|
|
||||||
darkHsl: '150 75% 40%',
|
if (h >= 0 && h < 60) { r = c; g = x; b = 0 }
|
||||||
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
|
else if (h >= 60 && h < 120) { r = x; g = c; b = 0 }
|
||||||
},
|
else if (h >= 120 && h < 180) { r = 0; g = c; b = x }
|
||||||
'gradient-aurora': {
|
else if (h >= 180 && h < 240) { r = 0; g = x; b = c }
|
||||||
hsl: '310 85% 65%',
|
else if (h >= 240 && h < 300) { r = x; g = 0; b = c }
|
||||||
darkHsl: '310 90% 70%',
|
else if (h >= 300 && h < 360) { r = c; g = 0; b = x }
|
||||||
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
|
|
||||||
},
|
const toHex = (n: number) => {
|
||||||
'gradient-fire': {
|
const hex = Math.round((n + m) * 255).toString(16)
|
||||||
hsl: '15 95% 55%',
|
return hex.length === 1 ? '0' + hex : hex
|
||||||
darkHsl: '15 95% 60%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-twilight': {
|
|
||||||
hsl: '250 90% 60%',
|
|
||||||
darkHsl: '250 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedColor = colors[color as keyof typeof colors]
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
if (selectedColor) {
|
|
||||||
// 设置主色
|
|
||||||
root.style.setProperty('--primary', selectedColor.hsl)
|
|
||||||
|
|
||||||
// 设置渐变(如果有)
|
|
||||||
if (selectedColor.gradient) {
|
|
||||||
root.style.setProperty('--primary-gradient', selectedColor.gradient)
|
|
||||||
root.classList.add('has-gradient')
|
|
||||||
} else {
|
|
||||||
root.style.removeProperty('--primary-gradient')
|
|
||||||
root.classList.remove('has-gradient')
|
|
||||||
}
|
|
||||||
} else if (color.startsWith('#')) {
|
|
||||||
// 自定义颜色 - 将 HEX 转换为 HSL
|
|
||||||
const hexToHsl = (hex: string) => {
|
|
||||||
// 移除 # 号
|
|
||||||
hex = hex.replace('#', '')
|
|
||||||
|
|
||||||
// 转换为 RGB
|
|
||||||
const r = parseInt(hex.substring(0, 2), 16) / 255
|
|
||||||
const g = parseInt(hex.substring(2, 4), 16) / 255
|
|
||||||
const b = parseInt(hex.substring(4, 6), 16) / 255
|
|
||||||
|
|
||||||
const max = Math.max(r, g, b)
|
|
||||||
const min = Math.min(r, g, b)
|
|
||||||
let h = 0
|
|
||||||
let s = 0
|
|
||||||
const l = (max + min) / 2
|
|
||||||
|
|
||||||
if (max !== min) {
|
|
||||||
const d = max - min
|
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
||||||
|
|
||||||
switch (max) {
|
|
||||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
|
|
||||||
case g: h = ((b - r) / d + 2) / 6; break
|
|
||||||
case b: h = ((r - g) / d + 4) / 6; break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
root.style.setProperty('--primary', hexToHsl(color))
|
|
||||||
root.style.removeProperty('--primary-gradient')
|
|
||||||
root.classList.remove('has-gradient')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 外观设置标签页
|
// 外观设置标签页
|
||||||
function AppearanceTab() {
|
function AppearanceTab() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme()
|
||||||
const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
|
const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
|
||||||
const [accentColor, setAccentColor] = useState(() => {
|
const { toast } = useToast()
|
||||||
return localStorage.getItem('accent-color') || 'blue'
|
|
||||||
|
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
||||||
|
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
||||||
|
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const updateTokenSection = useCallback(
|
||||||
|
<K extends keyof ThemeTokens>(section: K, partial: Partial<ThemeTokens[K]>) => {
|
||||||
|
updateThemeConfig({
|
||||||
|
tokenOverrides: {
|
||||||
|
...themeConfig.tokenOverrides,
|
||||||
|
[section]: {
|
||||||
|
...defaultLightTokens[section],
|
||||||
|
...themeConfig.tokenOverrides?.[section],
|
||||||
|
...partial,
|
||||||
|
} as ThemeTokens[K],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
[themeConfig.tokenOverrides, updateThemeConfig]
|
||||||
|
)
|
||||||
|
|
||||||
// 页面加载时应用保存的主题色
|
const resetTokenSection = useCallback(
|
||||||
useEffect(() => {
|
(section: keyof ThemeTokens) => {
|
||||||
const savedColor = localStorage.getItem('accent-color') || 'blue'
|
const newOverrides: Partial<ThemeTokens> = { ...themeConfig.tokenOverrides }
|
||||||
applyAccentColor(savedColor)
|
delete newOverrides[section]
|
||||||
}, [])
|
updateThemeConfig({ tokenOverrides: newOverrides })
|
||||||
|
},
|
||||||
|
[themeConfig.tokenOverrides, updateThemeConfig]
|
||||||
|
)
|
||||||
|
|
||||||
const handleAccentColorChange = (color: string) => {
|
const handleCSSChange = useCallback((val: string) => {
|
||||||
setAccentColor(color)
|
setLocalCSS(val)
|
||||||
localStorage.setItem('accent-color', color)
|
const result = sanitizeCSS(val)
|
||||||
applyAccentColor(color)
|
setCssWarnings(result.warnings)
|
||||||
|
|
||||||
|
if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current)
|
||||||
|
cssDebounceRef.current = setTimeout(() => {
|
||||||
|
updateThemeConfig({ customCSS: val })
|
||||||
|
}, 500)
|
||||||
|
}, [updateThemeConfig])
|
||||||
|
|
||||||
|
const currentAccentHex = useMemo(() => {
|
||||||
|
if (themeConfig.accentColor) {
|
||||||
|
return hslToHex(themeConfig.accentColor)
|
||||||
}
|
}
|
||||||
|
return '#3b82f6' // 默认蓝色
|
||||||
|
}, [themeConfig.accentColor])
|
||||||
|
|
||||||
|
const handleAccentColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const hex = e.target.value
|
||||||
|
const hsl = hexToHSL(hex)
|
||||||
|
updateThemeConfig({ accentColor: hsl })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetAccent = () => {
|
||||||
|
updateThemeConfig({ accentColor: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const json = exportThemeJSON()
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `maibot-theme-${Date.now()}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const json = ev.target?.result as string
|
||||||
|
const result = importThemeJSON(json)
|
||||||
|
if (result.success) {
|
||||||
|
// 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage)
|
||||||
|
toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' })
|
||||||
|
setTimeout(() => window.location.reload(), 1000)
|
||||||
|
} else {
|
||||||
|
toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
// 重置 input,允许重复选择同一文件
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetTheme = () => {
|
||||||
|
resetTheme()
|
||||||
|
setLocalCSS('')
|
||||||
|
setCssWarnings([])
|
||||||
|
toast({ title: '重置成功', description: '主题已重置为默认值' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewTokens = useMemo(() => {
|
||||||
|
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
|
||||||
|
}, [themeConfig, resolvedTheme])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 sm:space-y-8">
|
<div className="space-y-6 sm:space-y-8">
|
||||||
|
|
@ -272,136 +294,438 @@ function AppearanceTab() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主题色 */}
|
{/* 主题色配置 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">主题色</h3>
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold">主题色</h3>
|
||||||
{/* 单色预设 */}
|
<Button
|
||||||
<div className="space-y-3 sm:space-y-4">
|
variant="outline"
|
||||||
<div>
|
size="sm"
|
||||||
<h4 className="text-xs sm:text-sm font-medium mb-2 sm:mb-3">单色</h4>
|
onClick={handleResetAccent}
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 sm:gap-3">
|
disabled={!themeConfig.accentColor}
|
||||||
<ColorPresetOption
|
className="h-8"
|
||||||
value="blue"
|
>
|
||||||
current={accentColor}
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
onChange={handleAccentColorChange}
|
重置默认
|
||||||
label="蓝色"
|
</Button>
|
||||||
colorClass="bg-blue-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="purple"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="紫色"
|
|
||||||
colorClass="bg-purple-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="green"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="绿色"
|
|
||||||
colorClass="bg-green-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="orange"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="橙色"
|
|
||||||
colorClass="bg-orange-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="pink"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="粉色"
|
|
||||||
colorClass="bg-pink-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="red"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="红色"
|
|
||||||
colorClass="bg-red-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 渐变色预设 */}
|
<div className="space-y-6">
|
||||||
<div>
|
{/* 颜色选择器 */}
|
||||||
<h4 className="text-xs sm:text-sm font-medium mb-2 sm:mb-3">渐变色</h4>
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center p-4 rounded-lg border bg-card">
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 sm:gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ColorPresetOption
|
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
|
||||||
value="gradient-sunset"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="日落"
|
|
||||||
colorClass="bg-gradient-to-r from-orange-500 to-pink-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="gradient-ocean"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="海洋"
|
|
||||||
colorClass="bg-gradient-to-r from-blue-500 to-cyan-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="gradient-forest"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="森林"
|
|
||||||
colorClass="bg-gradient-to-r from-green-500 to-emerald-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="gradient-aurora"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="极光"
|
|
||||||
colorClass="bg-gradient-to-r from-purple-500 to-pink-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="gradient-fire"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="烈焰"
|
|
||||||
colorClass="bg-gradient-to-r from-red-500 to-orange-500"
|
|
||||||
/>
|
|
||||||
<ColorPresetOption
|
|
||||||
value="gradient-twilight"
|
|
||||||
current={accentColor}
|
|
||||||
onChange={handleAccentColorChange}
|
|
||||||
label="暮光"
|
|
||||||
colorClass="bg-gradient-to-r from-indigo-500 to-purple-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自定义颜色选择器 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs sm:text-sm font-medium mb-2 sm:mb-3">自定义颜色</h4>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={accentColor.startsWith('#') ? accentColor : '#3b82f6'}
|
value={currentAccentHex}
|
||||||
onChange={(e) => handleAccentColorChange(e.target.value)}
|
onChange={handleAccentColorChange}
|
||||||
className="h-10 sm:h-12 w-full rounded-lg border-2 border-border cursor-pointer"
|
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
|
||||||
title="选择自定义颜色"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="accent-color-input" className="font-medium">主色调</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">点击色环选择或输入 HEX 值</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 w-full sm:w-auto flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
id="accent-color-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={accentColor}
|
value={currentAccentHex}
|
||||||
onChange={(e) => handleAccentColorChange(e.target.value)}
|
onChange={handleAccentColorChange}
|
||||||
placeholder="#3b82f6"
|
className="font-mono uppercase w-32"
|
||||||
className="font-mono text-sm"
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
|
|
||||||
点击色块选择颜色,或手动输入 HEX 颜色代码
|
{/* 实时色板预览 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">实时色板预览</h4>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-3">
|
||||||
|
<ColorTokenPreview name="primary" value={previewTokens.primary} foreground={previewTokens['primary-foreground']} />
|
||||||
|
<ColorTokenPreview name="secondary" value={previewTokens.secondary} foreground={previewTokens['secondary-foreground']} />
|
||||||
|
<ColorTokenPreview name="muted" value={previewTokens.muted} foreground={previewTokens['muted-foreground']} />
|
||||||
|
<ColorTokenPreview name="accent" value={previewTokens.accent} foreground={previewTokens['accent-foreground']} />
|
||||||
|
<ColorTokenPreview name="destructive" value={previewTokens.destructive} foreground={previewTokens['destructive-foreground']} />
|
||||||
|
<ColorTokenPreview name="background" value={previewTokens.background} foreground={previewTokens.foreground} border />
|
||||||
|
<ColorTokenPreview name="card" value={previewTokens.card} foreground={previewTokens['card-foreground']} border />
|
||||||
|
<ColorTokenPreview name="border" value={previewTokens.border} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 样式微调 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">界面样式微调</h3>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{/* 1. 字体排版 (Typography) */}
|
||||||
|
<AccordionItem value="typography">
|
||||||
|
<AccordionTrigger>字体排版 (Typography)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetTokenSection('typography')}
|
||||||
|
disabled={!themeConfig.tokenOverrides?.typography}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>字体族 (Font Family)</Label>
|
||||||
|
<Select
|
||||||
|
value={(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-serif') ? 'serif' :
|
||||||
|
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-monospace') ? 'mono' :
|
||||||
|
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base'] ? 'sans' : 'system'}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
let fontVal = defaultLightTokens.typography['font-family-base']
|
||||||
|
if (val === 'serif') fontVal = 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif'
|
||||||
|
else if (val === 'mono') fontVal = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
|
else if (val === 'sans') fontVal = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
|
||||||
|
|
||||||
|
updateTokenSection('typography', {
|
||||||
|
'font-family-base': fontVal,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择字体族" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="system">系统默认 (System)</SelectItem>
|
||||||
|
<SelectItem value="sans">无衬线 (Sans-serif)</SelectItem>
|
||||||
|
<SelectItem value="serif">衬线 (Serif)</SelectItem>
|
||||||
|
<SelectItem value="mono">等宽 (Monospace)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>基准字体大小 (Base Size)</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[16]}
|
||||||
|
value={[parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16]}
|
||||||
|
min={12}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
updateTokenSection('typography', {
|
||||||
|
'font-size-base': `${vals[0] / 16}rem`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>行高 (Line Height)</Label>
|
||||||
|
<Select
|
||||||
|
value={String((themeConfig.tokenOverrides?.typography as any)?.['line-height-normal'] || '1.5')}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
updateTokenSection('typography', {
|
||||||
|
'line-height-normal': parseFloat(val),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择行高" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1.2">紧凑 (1.2)</SelectItem>
|
||||||
|
<SelectItem value="1.5">正常 (1.5)</SelectItem>
|
||||||
|
<SelectItem value="1.75">宽松 (1.75)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 2. 视觉效果 (Visual) */}
|
||||||
|
<AccordionItem value="visual">
|
||||||
|
<AccordionTrigger>视觉效果 (Visual)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetTokenSection('visual')}
|
||||||
|
disabled={!themeConfig.tokenOverrides?.visual}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>圆角大小 (Radius)</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[6]}
|
||||||
|
value={[Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)]}
|
||||||
|
min={0}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
updateTokenSection('visual', {
|
||||||
|
'radius-md': `${vals[0] / 16}rem`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>阴影强度 (Shadow)</Label>
|
||||||
|
<Select
|
||||||
|
value={(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === 'none' ? 'none' :
|
||||||
|
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-sm'] ? 'sm' :
|
||||||
|
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-lg'] ? 'lg' :
|
||||||
|
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-xl'] ? 'xl' : 'md'}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
let shadowVal = defaultLightTokens.visual['shadow-md']
|
||||||
|
if (val === 'none') shadowVal = 'none'
|
||||||
|
else if (val === 'sm') shadowVal = defaultLightTokens.visual['shadow-sm']
|
||||||
|
else if (val === 'lg') shadowVal = defaultLightTokens.visual['shadow-lg']
|
||||||
|
else if (val === 'xl') shadowVal = defaultLightTokens.visual['shadow-xl']
|
||||||
|
|
||||||
|
updateTokenSection('visual', {
|
||||||
|
'shadow-md': shadowVal,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择阴影强度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">无阴影 (None)</SelectItem>
|
||||||
|
<SelectItem value="sm">轻微 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">中等 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="lg">强烈 (Large)</SelectItem>
|
||||||
|
<SelectItem value="xl">极强 (Extra Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="blur-switch">模糊效果 (Blur)</Label>
|
||||||
|
<Switch
|
||||||
|
id="blur-switch"
|
||||||
|
checked={(themeConfig.tokenOverrides?.visual as any)?.['blur-md'] !== '0px'}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateTokenSection('visual', {
|
||||||
|
'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 3. 布局 (Layout) */}
|
||||||
|
<AccordionItem value="layout">
|
||||||
|
<AccordionTrigger>布局 (Layout)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetTokenSection('layout')}
|
||||||
|
disabled={!themeConfig.tokenOverrides?.layout}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>侧边栏宽度 (Sidebar Width)</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[16]}
|
||||||
|
value={[parseFloat((themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16')]}
|
||||||
|
min={12}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
updateTokenSection('layout', {
|
||||||
|
'sidebar-width': `${vals[0]}rem`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>内容区最大宽度 (Max Width)</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[1280]}
|
||||||
|
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280').replace('px', ''))]}
|
||||||
|
min={960}
|
||||||
|
max={1600}
|
||||||
|
step={10}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
updateTokenSection('layout', {
|
||||||
|
'max-content-width': `${vals[0]}px`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>基准间距 (Spacing Unit)</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
defaultValue={[0.25]}
|
||||||
|
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25').replace('rem', ''))]}
|
||||||
|
min={0.2}
|
||||||
|
max={0.4}
|
||||||
|
step={0.01}
|
||||||
|
onValueChange={(vals) => {
|
||||||
|
updateTokenSection('layout', {
|
||||||
|
'space-unit': `${vals[0]}rem`,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 4. 动画 (Animation) */}
|
||||||
|
<AccordionItem value="animation">
|
||||||
|
<AccordionTrigger>动画 (Animation)</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetTokenSection('animation')}
|
||||||
|
disabled={!themeConfig.tokenOverrides?.animation}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>动画速度 (Speed)</Label>
|
||||||
|
<Select
|
||||||
|
value={(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '100ms' ? 'fast' :
|
||||||
|
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '500ms' ? 'slow' :
|
||||||
|
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '0ms' ? 'off' : 'normal'}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
let duration = '300ms'
|
||||||
|
if (val === 'fast') duration = '100ms'
|
||||||
|
else if (val === 'slow') duration = '500ms'
|
||||||
|
else if (val === 'off') duration = '0ms'
|
||||||
|
|
||||||
|
// 如果用户选了关闭,我们也应该同步更新 enableAnimations 开关
|
||||||
|
if (val === 'off' && enableAnimations) {
|
||||||
|
setEnableAnimations(false)
|
||||||
|
} else if (val !== 'off' && !enableAnimations) {
|
||||||
|
setEnableAnimations(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTokenSection('animation', {
|
||||||
|
'anim-duration-normal': duration,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择动画速度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fast">快速 (100ms)</SelectItem>
|
||||||
|
<SelectItem value="normal">正常 (300ms)</SelectItem>
|
||||||
|
<SelectItem value="slow">慢速 (500ms)</SelectItem>
|
||||||
|
<SelectItem value="off">关闭 (0ms)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold">自定义 CSS</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
编写自定义 CSS 来进一步个性化界面。危险的 CSS(如 @import、url())将被自动过滤。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setLocalCSS('')
|
||||||
|
updateThemeConfig({ customCSS: '' })
|
||||||
|
setCssWarnings([])
|
||||||
|
}}
|
||||||
|
disabled={!themeConfig.customCSS}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
|
||||||
|
<CodeEditor
|
||||||
|
value={localCSS}
|
||||||
|
language="css"
|
||||||
|
height="250px"
|
||||||
|
placeholder={`/* 在这里输入自定义 CSS */\n\n/* 例如: */\n/* .sidebar { background: #1a1a2e; } */`}
|
||||||
|
onChange={handleCSSChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cssWarnings.length > 0 && (
|
||||||
|
<div className="rounded-md bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-sm font-medium mb-1">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
以下内容已被安全过滤:
|
||||||
|
</div>
|
||||||
|
<ul className="text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-6 list-disc">
|
||||||
|
{cssWarnings.map((w, i) => <li key={i}>{w}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -448,6 +772,90 @@ function AppearanceTab() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 主题导入/导出 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">主题导入/导出</h3>
|
||||||
|
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
{/* 导出按钮 */}
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
导出主题
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 导入按钮 */}
|
||||||
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
导入主题
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 重置按钮 */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
重置为默认
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认重置主题</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
这将重置所有主题设置为默认值,包括颜色、字体、布局和自定义 CSS。此操作不可撤销,确定要继续吗?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleResetTheme}>
|
||||||
|
确认重置
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 隐藏的文件输入 */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleImport}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
导出主题为 JSON 文件便于分享或备份,导入时会自动应用所有配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div
|
||||||
|
className={cn("h-16 rounded-md shadow-sm flex items-center justify-center text-xs font-medium", border && "border border-border")}
|
||||||
|
style={{ backgroundColor: `hsl(${value})`, color: foreground ? `hsl(${foreground})` : undefined }}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground text-center truncate" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1730,36 +2138,3 @@ function ThemeOption({ value, current, onChange, label, description }: ThemeOpti
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColorPresetOptionProps = {
|
|
||||||
value: string
|
|
||||||
current: string
|
|
||||||
onChange: (color: string) => void
|
|
||||||
label: string
|
|
||||||
colorClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorPresetOption({ value, current, onChange, label, colorClass }: ColorPresetOptionProps) {
|
|
||||||
const isSelected = current === value
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(value)}
|
|
||||||
className={cn(
|
|
||||||
'relative rounded-lg border-2 p-2 sm:p-3 text-left transition-all',
|
|
||||||
'hover:border-primary/50 hover:bg-accent/50',
|
|
||||||
isSelected ? 'border-primary bg-accent' : 'border-border'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 选中指示器 */}
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute top-1.5 right-1.5 sm:top-2 sm:right-2 h-1.5 w-1.5 sm:h-2 sm:w-2 rounded-full bg-primary" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1.5 sm:gap-2">
|
|
||||||
<div className={cn('h-8 w-8 sm:h-10 sm:w-10 rounded-full', colorClass)} />
|
|
||||||
<div className="text-[10px] sm:text-xs font-medium text-center">{label}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,29 +16,29 @@
|
||||||
|
|
||||||
/* 拖放区域样式 */
|
/* 拖放区域样式 */
|
||||||
.uppy-Dashboard-AddFiles {
|
.uppy-Dashboard-AddFiles {
|
||||||
border: 2px dashed hsl(var(--border)) !important;
|
border: 2px dashed hsl(var(--color-border)) !important;
|
||||||
border-radius: 0.5rem !important;
|
border-radius: 0.5rem !important;
|
||||||
background: hsl(var(--muted) / 0.3) !important;
|
background: hsl(var(--color-muted) / 0.3) !important;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles:hover {
|
.uppy-Dashboard-AddFiles:hover {
|
||||||
border-color: hsl(var(--primary)) !important;
|
border-color: hsl(var(--color-primary)) !important;
|
||||||
background: hsl(var(--muted) / 0.5) !important;
|
background: hsl(var(--color-muted) / 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles-title {
|
.uppy-Dashboard-AddFiles-title {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles-info {
|
.uppy-Dashboard-AddFiles-info {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式 */
|
/* 按钮样式 */
|
||||||
.uppy-Dashboard-browse {
|
.uppy-Dashboard-browse {
|
||||||
color: hsl(var(--primary)) !important;
|
color: hsl(var(--color-primary)) !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,63 +52,63 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item {
|
.uppy-Dashboard-Item {
|
||||||
border-bottom-color: hsl(var(--border)) !important;
|
border-bottom-color: hsl(var(--color-border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-name {
|
.uppy-Dashboard-Item-name {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-status {
|
.uppy-Dashboard-Item-status {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度条样式 */
|
/* 进度条样式 */
|
||||||
.uppy-StatusBar {
|
.uppy-StatusBar {
|
||||||
background: hsl(var(--muted)) !important;
|
background: hsl(var(--color-muted)) !important;
|
||||||
border-top: 1px solid hsl(var(--border)) !important;
|
border-top: 1px solid hsl(var(--color-border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-progress {
|
.uppy-StatusBar-progress {
|
||||||
background: hsl(var(--primary)) !important;
|
background: hsl(var(--color-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-content {
|
.uppy-StatusBar-content {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-actionBtn--upload {
|
.uppy-StatusBar-actionBtn--upload {
|
||||||
background: hsl(var(--primary)) !important;
|
background: hsl(var(--color-primary)) !important;
|
||||||
color: hsl(var(--primary-foreground)) !important;
|
color: hsl(var(--color-primary-foreground)) !important;
|
||||||
border-radius: 0.375rem !important;
|
border-radius: 0.375rem !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
padding: 0.5rem 1rem !important;
|
padding: 0.5rem 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-actionBtn--upload:hover {
|
.uppy-StatusBar-actionBtn--upload:hover {
|
||||||
background: hsl(var(--primary) / 0.9) !important;
|
background: hsl(var(--color-primary) / 0.9) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note 提示文字样式 */
|
/* Note 提示文字样式 */
|
||||||
.uppy-Dashboard-note {
|
.uppy-Dashboard-note {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
font-size: 0.75rem !important;
|
font-size: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色主题适配 */
|
/* 暗色主题适配 */
|
||||||
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles,
|
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles,
|
||||||
.dark .uppy-Dashboard-AddFiles {
|
.dark .uppy-Dashboard-AddFiles {
|
||||||
background: hsl(var(--muted) / 0.2) !important;
|
background: hsl(var(--color-muted) / 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles-title,
|
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles-title,
|
||||||
.dark .uppy-Dashboard-AddFiles-title {
|
.dark .uppy-Dashboard-AddFiles-title {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-uppy-theme="dark"] .uppy-StatusBar,
|
[data-uppy-theme="dark"] .uppy-StatusBar,
|
||||||
.dark .uppy-StatusBar {
|
.dark .uppy-StatusBar {
|
||||||
background: hsl(var(--muted) / 0.5) !important;
|
background: hsl(var(--color-muted) / 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移除 Uppy 自带的边框和阴影 */
|
/* 移除 Uppy 自带的边框和阴影 */
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
|
|
||||||
/* 删除按钮样式 */
|
/* 删除按钮样式 */
|
||||||
.uppy-Dashboard-Item-action--remove {
|
.uppy-Dashboard-Item-action--remove {
|
||||||
color: hsl(var(--destructive)) !important;
|
color: hsl(var(--color-destructive)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-action--remove:hover {
|
.uppy-Dashboard-Item-action--remove:hover {
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item.is-error .uppy-Dashboard-Item-progress {
|
.uppy-Dashboard-Item.is-error .uppy-Dashboard-Item-progress {
|
||||||
color: hsl(var(--destructive)) !important;
|
color: hsl(var(--color-destructive)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式 */
|
||||||
|
|
@ -150,10 +150,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-files::-webkit-scrollbar-thumb {
|
.uppy-Dashboard-files::-webkit-scrollbar-thumb {
|
||||||
background: hsl(var(--muted-foreground) / 0.3);
|
background: hsl(var(--color-muted-foreground) / 0.3);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-files::-webkit-scrollbar-thumb:hover {
|
.uppy-Dashboard-files::-webkit-scrollbar-thumb:hover {
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
background: hsl(var(--color-muted-foreground) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
global.ResizeObserver = class ResizeObserver {
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
declare module '*.css'
|
||||||
|
|
@ -5,40 +5,61 @@ export default {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--color-border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--color-input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--color-ring))',
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--color-background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--color-foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--color-primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'hsl(var(--color-primary-foreground))',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--color-secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'hsl(var(--color-secondary-foreground))',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--color-muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'hsl(var(--color-muted-foreground))',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--color-accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: 'hsl(var(--color-accent-foreground))',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--color-card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--color-card-foreground))',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--color-popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: 'hsl(var(--color-popover-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--color-destructive))',
|
||||||
|
foreground: 'hsl(var(--color-destructive-foreground))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--visual-radius-lg)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'var(--visual-radius-md)',
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: 'var(--visual-radius-sm)',
|
||||||
|
xl: 'var(--visual-radius-xl)',
|
||||||
|
full: 'var(--visual-radius-full)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: 'var(--typography-font-family-base)',
|
||||||
|
mono: 'var(--typography-font-family-code)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: 'var(--visual-shadow-sm)',
|
||||||
|
md: 'var(--visual-shadow-md)',
|
||||||
|
lg: 'var(--visual-shadow-lg)',
|
||||||
|
xl: 'var(--visual-shadow-xl)',
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
fast: 'var(--animation-anim-duration-fast)',
|
||||||
|
normal: 'var(--animation-anim-duration-normal)',
|
||||||
|
slow: 'var(--animation-anim-duration-slow)',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'slide-in-from-right': {
|
'slide-in-from-right': {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.app.json",
|
"extends": "./tsconfig.app.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/// <reference types="vitest" />
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
@ -6,11 +5,6 @@ import path from 'path'
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: './src/test/setup.ts',
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
port: 7999,
|
port: 7999,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue