Merge branch 'r-dev' of github.com:Mai-with-u/MaiBot into r-dev

pull/1496/head
UnCLAS-Prommer 2026-02-19 19:03:25 +08:00
commit 6bcd7cbebb
No known key found for this signature in database
30 changed files with 11927 additions and 628 deletions

View File

@ -11,6 +11,17 @@
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root" class="notranslate"></div>

9791
dashboard/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
@ -77,6 +78,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@ -1,16 +1,20 @@
import { useEffect, useState } from 'react'
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 { python } from '@codemirror/lang-python'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import { StreamLanguage } from '@codemirror/language'
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 {
value: string
onChange?: (value: string) => void
language?: Language
readOnly?: boolean
@ -27,6 +31,7 @@ const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), jsonParseLinter()],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}
@ -39,10 +44,11 @@ export function CodeEditor({
minHeight,
maxHeight,
placeholder,
theme = 'dark',
theme,
className = '',
}: CodeEditorProps) {
const [mounted, setMounted] = useState(false)
const { resolvedTheme } = useTheme()
useEffect(() => {
setMounted(true)
@ -81,6 +87,9 @@ export function CodeEditor({
extensions.push(EditorView.editable.of(false))
}
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
const effectiveTheme = theme ?? resolvedTheme
return (
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
<CodeMirror
@ -88,7 +97,7 @@ export function CodeEditor({
height={height}
minHeight={minHeight}
maxHeight={maxHeight}
theme={theme === 'dark' ? oneDark : undefined}
theme={effectiveTheme === 'dark' ? oneDark : undefined}
extensions={extensions}
onChange={onChange}
placeholder={placeholder}

View File

@ -1,5 +1,6 @@
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 { DynamicConfigForm } from '../DynamicConfigForm'

View File

@ -1,5 +1,6 @@
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 { DynamicField } from '../DynamicField'

View File

@ -1,6 +1,16 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
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'
@ -13,126 +23,74 @@ type ThemeProviderProps = {
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'ui-theme',
...props
storageKey: _storageKey,
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [themeMode, setThemeMode] = useState<Theme>(() => {
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(() => {
const root = window.document.documentElement
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')
}
}
}
migrateOldKeys()
}, [])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
if (themeMode === 'system') {
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 (
<ThemeProviderContext.Provider {...props} value={value}>
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)

View File

@ -7,10 +7,10 @@ import { useTour } from './use-tour'
const joyrideStyles = {
options: {
zIndex: 10000,
primaryColor: 'hsl(var(--primary))',
textColor: 'hsl(var(--foreground))',
backgroundColor: 'hsl(var(--background))',
arrowColor: 'hsl(var(--background))',
primaryColor: 'hsl(var(--color-primary))',
textColor: 'hsl(var(--color-foreground))',
backgroundColor: 'hsl(var(--color-background))',
arrowColor: 'hsl(var(--color-background))',
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
tooltip: {
@ -30,23 +30,23 @@ const joyrideStyles = {
padding: '0.5rem 0',
},
buttonNext: {
backgroundColor: 'hsl(var(--primary))',
color: 'hsl(var(--primary-foreground))',
backgroundColor: 'hsl(var(--color-primary))',
color: 'hsl(var(--color-primary-foreground))',
borderRadius: 'calc(var(--radius) - 2px)',
fontSize: '0.875rem',
padding: '0.5rem 1rem',
},
buttonBack: {
color: 'hsl(var(--muted-foreground))',
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
marginRight: '0.5rem',
},
buttonSkip: {
color: 'hsl(var(--muted-foreground))',
color: 'hsl(var(--color-muted-foreground))',
fontSize: '0.875rem',
},
buttonClose: {
color: 'hsl(var(--muted-foreground))',
color: 'hsl(var(--color-muted-foreground))',
},
spotlight: {
borderRadius: 'var(--radius)',

View File

@ -354,7 +354,7 @@ export function WavesBackground() {
left: 0,
width: '0.5rem',
height: '0.5rem',
background: 'hsl(var(--primary) / 0.3)',
background: 'hsl(var(--color-primary) / 0.3)',
borderRadius: '50%',
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
willChange: 'transform',
@ -372,7 +372,7 @@ export function WavesBackground() {
<style>{`
path {
fill: none;
stroke: hsl(var(--primary) / 0.20);
stroke: hsl(var(--color-primary) / 0.20);
stroke-width: 1px;
}
`}</style>

View File

@ -13,60 +13,183 @@
@layer base {
:root {
--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%;
--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%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--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%;
/* Color Tokens */
--color-primary: 221.2 83.2% 53.3%;
--color-primary-foreground: 210 40% 98%;
--color-primary-gradient: none;
--color-secondary: 210 40% 96.1%;
--color-secondary-foreground: 222.2 47.4% 11.2%;
--color-muted: 210 40% 96.1%;
--color-muted-foreground: 215.4 16.3% 46.9%;
--color-accent: 210 40% 96.1%;
--color-accent-foreground: 222.2 47.4% 11.2%;
--color-destructive: 0 84.2% 60.2%;
--color-destructive-foreground: 210 40% 98%;
--color-background: 0 0% 100%;
--color-foreground: 222.2 84% 4.9%;
--color-card: 0 0% 100%;
--color-card-foreground: 222.2 84% 4.9%;
--color-popover: 0 0% 100%;
--color-popover-foreground: 222.2 84% 4.9%;
--color-border: 214.3 31.8% 91.4%;
--color-input: 214.3 31.8% 91.4%;
--color-ring: 221.2 83.2% 53.3%;
--color-chart-1: 221.2 83.2% 53.3%;
--color-chart-2: 160 60% 45%;
--color-chart-3: 30 80% 55%;
--color-chart-4: 280 65% 60%;
--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 {
--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%;
--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%;
--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%;
/* Color Tokens */
--color-primary: 217.2 91.2% 59.8%;
--color-primary-foreground: 210 40% 98%;
--color-primary-gradient: none;
--color-secondary: 217.2 32.6% 17.5%;
--color-secondary-foreground: 210 40% 98%;
--color-muted: 217.2 32.6% 17.5%;
--color-muted-foreground: 215 20.2% 65.1%;
--color-accent: 217.2 32.6% 17.5%;
--color-accent-foreground: 210 40% 98%;
--color-destructive: 0 62.8% 30.6%;
--color-destructive-foreground: 210 40% 98%;
--color-background: 222.2 84% 4.9%;
--color-foreground: 210 40% 98%;
--color-card: 222.2 84% 4.9%;
--color-card-foreground: 210 40% 98%;
--color-popover: 222.2 84% 4.9%;
--color-popover-foreground: 210 40% 98%;
--color-border: 217.2 32.6% 17.5%;
--color-input: 217.2 32.6% 17.5%;
--color-ring: 224.3 76.3% 48%;
--color-chart-1: 217.2 91.2% 59.8%;
--color-chart-2: 160 60% 50%;
--color-chart-3: 30 80% 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 {
/* 渐变色背景工具类 */
.bg-primary-gradient {
background: var(--primary-gradient, hsl(var(--primary)));
background: var(--color-primary-gradient, hsl(var(--color-primary)));
}
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
.text-primary-gradient {
color: hsl(var(--primary));
color: hsl(var(--color-primary));
}
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
.has-gradient .text-primary-gradient {
background: var(--primary-gradient);
background: var(--color-primary-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
/* 渐变色边框工具类 */
.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;
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
scrollbar-color: hsl(var(--color-border)) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
@ -187,14 +305,14 @@
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--border));
background: hsl(var(--color-border));
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.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;
}

View File

@ -6,7 +6,9 @@
// 所有设置的 key 定义
export const STORAGE_KEYS = {
// 外观设置
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.MODE */
THEME: 'maibot-ui-theme',
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.ACCENT */
ACCENT_COLOR: 'accent-color',
ENABLE_ANIMATIONS: 'maibot-animations',
ENABLE_WAVES_BACKGROUND: 'maibot-waves-background',

View File

@ -1,15 +1,30 @@
import { createContext } from 'react'
import type { UserThemeConfig } from './theme/tokens'
type Theme = 'dark' | 'light' | 'system'
export type ThemeProviderState = {
theme: Theme
resolvedTheme: 'dark' | 'light'
setTheme: (theme: Theme) => void
themeConfig: UserThemeConfig
updateThemeConfig: (partial: Partial<UserThemeConfig>) => void
resetTheme: () => void
}
const initialState: ThemeProviderState = {
theme: 'system',
resolvedTheme: 'light',
setTheme: () => null,
themeConfig: {
selectedPreset: 'light',
accentColor: '',
tokenOverrides: {},
customCSS: '',
},
updateThemeConfig: () => null,
resetTheme: () => null,
}
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -341,7 +341,7 @@ export function AnnualReportPage() {
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
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>
</ResponsiveContainer>
</CardContent>

View File

@ -2,7 +2,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
BotInfoSection,
PersonalitySection,
ChatSection,
DreamSection,
LPMMSection,
LogSection,
@ -625,7 +624,6 @@ function BotConfigPageContent() {
}
}}
language="toml"
theme="dark"
height="calc(100vh - 280px)"
minHeight="500px"
placeholder="TOML 配置内容"

View File

@ -79,7 +79,6 @@ function ModelConfigPageContent() {
const [models, setModels] = useState<ModelInfo[]>([])
const [providers, setProviders] = useState<string[]>([])
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
const [modelNames, setModelNames] = useState<string[]>([])
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -183,7 +182,6 @@ function ModelConfigPageContent() {
const config = await getModelConfig()
const modelList = (config.models as ModelInfo[]) || []
setModels(modelList)
setModelNames(modelList.map((m) => m.name))
const providerList = (config.api_providers as ProviderConfig[]) || []
setProviders(providerList.map((p) => p.name))
@ -433,8 +431,6 @@ function ModelConfigPageContent() {
}
setModels(newModels)
// 立即更新模型名称列表
setModelNames(newModels.map((m) => m.name))
// 如果模型名称发生变化,更新任务配置中对该模型的引用
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
@ -492,8 +488,6 @@ function ModelConfigPageContent() {
if (deletingIndex !== null) {
const newModels = models.filter((_, i) => i !== deletingIndex)
setModels(newModels)
// 立即更新模型名称列表
setModelNames(newModels.map((m) => m.name))
// 重新检查任务配置问题
checkTaskConfigIssues(taskConfig, newModels)
toast({
@ -546,8 +540,6 @@ function ModelConfigPageContent() {
const deletedCount = selectedModels.size
const newModels = models.filter((_, index) => !selectedModels.has(index))
setModels(newModels)
// 立即更新模型名称列表
setModelNames(newModels.map((m) => m.name))
// 重新检查任务配置问题
checkTaskConfigIssues(taskConfig, newModels)
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 = () => {
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return

View File

@ -403,15 +403,15 @@ function IndexPageContent() {
const chartConfig = {
requests: {
label: '请求数',
color: 'hsl(var(--chart-1))',
color: 'hsl(var(--color-chart-1))',
},
cost: {
label: '花费(¥)',
color: 'hsl(var(--chart-2))',
color: 'hsl(var(--color-chart-2))',
},
tokens: {
label: 'Tokens',
color: 'hsl(var(--chart-3))',
color: 'hsl(var(--color-chart-3))',
},
} satisfies ChartConfig
@ -738,17 +738,17 @@ function IndexPageContent() {
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
<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
dataKey="timestamp"
tickFormatter={(value) => formatDateTime(value)}
angle={-45}
textAnchor="end"
height={60}
stroke="hsl(var(--muted-foreground))"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--color-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
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
/>
@ -772,17 +772,17 @@ function IndexPageContent() {
<CardContent>
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
<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
dataKey="timestamp"
tickFormatter={(value) => formatDateTime(value)}
angle={-45}
textAnchor="end"
height={60}
stroke="hsl(var(--muted-foreground))"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--color-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
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
/>
@ -800,17 +800,17 @@ function IndexPageContent() {
<CardContent>
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
<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
dataKey="timestamp"
tickFormatter={(value) => formatDateTime(value)}
angle={-45}
textAnchor="end"
height={60}
stroke="hsl(var(--muted-foreground))"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--color-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
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
/>
@ -889,7 +889,7 @@ function IndexPageContent() {
<div
className="w-3 h-3 rounded-full ml-2 flex-shrink-0"
style={{
backgroundColor: `hsl(var(--chart-${(index % 5) + 1}))`,
backgroundColor: `hsl(var(--color-chart-${(index % 5) + 1}))`,
}}
/>
</div>
@ -992,28 +992,28 @@ function IndexPageContent() {
config={{
requests: {
label: '请求数',
color: 'hsl(var(--chart-1))',
color: 'hsl(var(--color-chart-1))',
},
cost: {
label: '花费(¥)',
color: 'hsl(var(--chart-2))',
color: 'hsl(var(--color-chart-2))',
},
}}
className="h-[400px] sm:h-[500px] w-full aspect-auto"
>
<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
dataKey="timestamp"
tickFormatter={(value) => {
const date = new Date(value)
return `${date.getMonth() + 1}/${date.getDate()}`
}}
stroke="hsl(var(--muted-foreground))"
tick={{ fill: 'hsl(var(--muted-foreground))' }}
stroke="hsl(var(--color-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="right" orientation="right" 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(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
<ChartTooltip
content={
<ChartTooltipContent

View File

@ -602,20 +602,19 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
</AlertDescription>
</Alert>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
if (hasTomlError) {
setHasTomlError(false)
}
}}
language="toml"
theme="dark"
height="calc(100vh - 350px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
if (hasTomlError) {
setHasTomlError(false)
}
}}
language="toml"
height="calc(100vh - 350px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@ -16,29 +16,29 @@
/* 拖放区域样式 */
.uppy-Dashboard-AddFiles {
border: 2px dashed hsl(var(--border)) !important;
border: 2px dashed hsl(var(--color-border)) !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;
}
.uppy-Dashboard-AddFiles:hover {
border-color: hsl(var(--primary)) !important;
background: hsl(var(--muted) / 0.5) !important;
border-color: hsl(var(--color-primary)) !important;
background: hsl(var(--color-muted) / 0.5) !important;
}
.uppy-Dashboard-AddFiles-title {
color: hsl(var(--foreground)) !important;
color: hsl(var(--color-foreground)) !important;
font-weight: 500 !important;
}
.uppy-Dashboard-AddFiles-info {
color: hsl(var(--muted-foreground)) !important;
color: hsl(var(--color-muted-foreground)) !important;
}
/* 按钮样式 */
.uppy-Dashboard-browse {
color: hsl(var(--primary)) !important;
color: hsl(var(--color-primary)) !important;
font-weight: 500 !important;
}
@ -52,63 +52,63 @@
}
.uppy-Dashboard-Item {
border-bottom-color: hsl(var(--border)) !important;
border-bottom-color: hsl(var(--color-border)) !important;
}
.uppy-Dashboard-Item-name {
color: hsl(var(--foreground)) !important;
color: hsl(var(--color-foreground)) !important;
}
.uppy-Dashboard-Item-status {
color: hsl(var(--muted-foreground)) !important;
color: hsl(var(--color-muted-foreground)) !important;
}
/* 进度条样式 */
.uppy-StatusBar {
background: hsl(var(--muted)) !important;
border-top: 1px solid hsl(var(--border)) !important;
background: hsl(var(--color-muted)) !important;
border-top: 1px solid hsl(var(--color-border)) !important;
}
.uppy-StatusBar-progress {
background: hsl(var(--primary)) !important;
background: hsl(var(--color-primary)) !important;
}
.uppy-StatusBar-content {
color: hsl(var(--foreground)) !important;
color: hsl(var(--color-foreground)) !important;
}
.uppy-StatusBar-actionBtn--upload {
background: hsl(var(--primary)) !important;
color: hsl(var(--primary-foreground)) !important;
background: hsl(var(--color-primary)) !important;
color: hsl(var(--color-primary-foreground)) !important;
border-radius: 0.375rem !important;
font-weight: 500 !important;
padding: 0.5rem 1rem !important;
}
.uppy-StatusBar-actionBtn--upload:hover {
background: hsl(var(--primary) / 0.9) !important;
background: hsl(var(--color-primary) / 0.9) !important;
}
/* Note 提示文字样式 */
.uppy-Dashboard-note {
color: hsl(var(--muted-foreground)) !important;
color: hsl(var(--color-muted-foreground)) !important;
font-size: 0.75rem !important;
}
/* 暗色主题适配 */
[data-uppy-theme="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,
.dark .uppy-Dashboard-AddFiles-title {
color: hsl(var(--foreground)) !important;
color: hsl(var(--color-foreground)) !important;
}
[data-uppy-theme="dark"] .uppy-StatusBar,
.dark .uppy-StatusBar {
background: hsl(var(--muted) / 0.5) !important;
background: hsl(var(--color-muted) / 0.5) !important;
}
/* 移除 Uppy 自带的边框和阴影 */
@ -124,7 +124,7 @@
/* 删除按钮样式 */
.uppy-Dashboard-Item-action--remove {
color: hsl(var(--destructive)) !important;
color: hsl(var(--color-destructive)) !important;
}
.uppy-Dashboard-Item-action--remove:hover {
@ -137,7 +137,7 @@
}
.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 {
background: hsl(var(--muted-foreground) / 0.3);
background: hsl(var(--color-muted-foreground) / 0.3);
border-radius: 3px;
}
.uppy-Dashboard-files::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background: hsl(var(--color-muted-foreground) / 0.5);
}

View File

@ -1,6 +1,6 @@
import '@testing-library/jest-dom/vitest'
global.ResizeObserver = class ResizeObserver {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}

View File

@ -0,0 +1 @@
declare module '*.css'

View File

@ -5,40 +5,61 @@ export default {
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
border: 'hsl(var(--color-border))',
input: 'hsl(var(--color-input))',
ring: 'hsl(var(--color-ring))',
background: 'hsl(var(--color-background))',
foreground: 'hsl(var(--color-foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
DEFAULT: 'hsl(var(--color-primary))',
foreground: 'hsl(var(--color-primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
DEFAULT: 'hsl(var(--color-secondary))',
foreground: 'hsl(var(--color-secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
DEFAULT: 'hsl(var(--color-muted))',
foreground: 'hsl(var(--color-muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
DEFAULT: 'hsl(var(--color-accent))',
foreground: 'hsl(var(--color-accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
DEFAULT: 'hsl(var(--color-card))',
foreground: 'hsl(var(--color-card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
DEFAULT: 'hsl(var(--color-popover))',
foreground: 'hsl(var(--color-popover-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--color-destructive))',
foreground: 'hsl(var(--color-destructive-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
lg: 'var(--visual-radius-lg)',
md: 'var(--visual-radius-md)',
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: {
'slide-in-from-right': {

View File

@ -1,7 +1,7 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

View File

@ -1,4 +1,3 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
@ -6,11 +5,6 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
server: {
port: 7999,
proxy: {