refactor(T11): split settings.tsx into settings/ directory

This commit is contained in:
DrSmoothl
2026-03-01 19:03:09 +08:00
parent dc7e037582
commit bddc6087cd
9 changed files with 2275 additions and 2227 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { APP_NAME, APP_VERSION } from '@/lib/version'
import { cn } from '@/lib/utils'
import { LibraryItem } from './LibraryItem'
export function AboutTab() {
return (
<div className="space-y-4 sm:space-y-6">
{/* GitHub 开源地址 */}
<div className="rounded-lg border-2 border-primary/30 bg-gradient-to-r from-primary/5 to-primary/10 p-4 sm:p-6">
<div className="flex items-start gap-3 sm:gap-4">
<div className="flex-shrink-0 rounded-lg bg-primary/10 p-2 sm:p-3">
<svg
className="h-6 w-6 sm:h-8 sm:w-8 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg sm:text-xl font-bold text-foreground mb-2">
</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-3">
GitHub Star
</p>
<a
href="https://github.com/Mai-with-u/MaiBot-Dashboard"
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-lg",
"bg-primary text-primary-foreground font-medium text-sm",
"hover:bg-primary/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
>
<svg
className="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
GitHub
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
</div>
{/* 应用信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> {APP_NAME}</h3>
<div className="space-y-2 text-xs sm:text-sm text-muted-foreground">
<p>: {APP_VERSION}</p>
<p>MaiBot Web </p>
</div>
</div>
{/* 作者信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">MaiBot </p>
<p className="text-xs sm:text-sm text-muted-foreground">Mai-with-u</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">WebUI</p>
<p className="text-xs sm:text-sm text-muted-foreground">Mai-with-u <a href="https://github.com/DrSmoothl" target="_blank" rel="noopener noreferrer" className="text-primary underline">@MotricSeven</a></p>
</div>
</div>
</div>
{/* 技术栈 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs sm:text-sm text-muted-foreground">
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>React 19.2.0</li>
<li>TypeScript 5.7.2</li>
<li>Vite 6.0.7</li>
<li>TanStack Router 1.94.2</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground">UI </p>
<ul className="space-y-0.5 list-disc list-inside">
<li>shadcn/ui</li>
<li>Radix UI</li>
<li>Tailwind CSS 3.4.17</li>
<li>Lucide Icons</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Python 3.12+</li>
<li>FastAPI</li>
<li>Uvicorn</li>
<li>WebSocket</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Bun / npm</li>
<li>ESLint 9.17.0</li>
<li>PostCSS</li>
</ul>
</div>
</div>
</div>
{/* 开源感谢 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-3">
使
</p>
<ScrollArea className="h-[300px] sm:h-[400px]">
<div className="space-y-4 pr-4">
{/* UI 框架 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">UI </p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React" description="用户界面构建库" license="MIT" />
<LibraryItem name="shadcn/ui" description="优雅的 React 组件库" license="MIT" />
<LibraryItem name="Radix UI" description="无样式的可访问组件库" license="MIT" />
<LibraryItem name="Tailwind CSS" description="实用优先的 CSS 框架" license="MIT" />
<LibraryItem name="Lucide React" description="精美的图标库" license="ISC" />
</div>
</div>
{/* 路由与状态 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TanStack Router" description="类型安全的路由库" license="MIT" />
<LibraryItem name="Zustand" description="轻量级状态管理" license="MIT" />
</div>
</div>
{/* 表单与验证 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React Hook Form" description="高性能表单库" license="MIT" />
<LibraryItem name="Zod" description="TypeScript 优先的 schema 验证" license="MIT" />
</div>
</div>
{/* 工具库 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="clsx" description="条件 className 构建工具" license="MIT" />
<LibraryItem name="tailwind-merge" description="Tailwind 类名合并工具" license="MIT" />
<LibraryItem name="class-variance-authority" description="组件变体管理" license="Apache-2.0" />
<LibraryItem name="date-fns" description="现代化日期处理库" license="MIT" />
</div>
</div>
{/* 动画 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="Framer Motion" description="React 动画库" license="MIT" />
<LibraryItem name="vaul" description="抽屉组件动画" license="MIT" />
</div>
</div>
{/* 后端相关 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="FastAPI" description="现代化 Python Web 框架" license="MIT" />
<LibraryItem name="Uvicorn" description="ASGI 服务器" license="BSD-3-Clause" />
<LibraryItem name="Pydantic" description="数据验证库" license="MIT" />
<LibraryItem name="python-multipart" description="文件上传支持" license="Apache-2.0" />
</div>
</div>
{/* 开发工具 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TypeScript" description="JavaScript 的超集" license="Apache-2.0" />
<LibraryItem name="Vite" description="下一代前端构建工具" license="MIT" />
<LibraryItem name="ESLint" description="JavaScript 代码检查工具" license="MIT" />
<LibraryItem name="PostCSS" description="CSS 转换工具" license="MIT" />
</div>
</div>
</div>
</ScrollArea>
</div>
{/* 许可证 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3">
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="flex-shrink-0 mt-0.5">
<div className="rounded-md bg-primary/10 px-2 py-1">
<span className="text-xs sm:text-sm font-bold text-primary">GPLv3</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-semibold text-foreground mb-1">
MaiBot WebUI
</p>
<p className="text-xs sm:text-sm text-muted-foreground">
GNU General Public License v3.0
使
</p>
</div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
MITApache-2.0BSD
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,840 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
import { useAnimation } from '@/hooks/use-animation'
import { useTheme } from '@/components/use-theme'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { getComputedTokens } from '@/lib/theme/pipeline'
import { hexToHSL } from '@/lib/theme/palette'
import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { CodeEditor } from '@/components/CodeEditor'
import { BackgroundEffectsControls } from '@/components/background-effects-controls'
import { BackgroundUploader } from '@/components/background-uploader'
import { ComponentCSSEditor } from '@/components/component-css-editor'
import { sanitizeCSS } from '@/lib/theme/sanitizer'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { ThemeOption } from './ThemeOption'
import { hslToHex } from './types'
export function AppearanceTab() {
const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme()
const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { toast } = useToast()
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
const [cssWarnings, setCssWarnings] = useState<string[]>([])
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const bgDebounceRef = 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(
(section: keyof ThemeTokens) => {
const newOverrides: Partial<ThemeTokens> = { ...themeConfig.tokenOverrides }
delete newOverrides[section]
updateThemeConfig({ tokenOverrides: newOverrides })
},
[themeConfig.tokenOverrides, updateThemeConfig]
)
const handleCSSChange = useCallback((val: string) => {
setLocalCSS(val)
const result = sanitizeCSS(val)
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])
const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {}
const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
const current = bgConfig[layerId] ?? defaultBackgroundConfig
const newMap: BackgroundConfigMap = {
...bgConfig,
[layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
}
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
}
const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => {
const current = bgConfig[layerId] ?? defaultBackgroundConfig
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } }
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
}
const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => {
const current = bgConfig[layerId] ?? defaultBackgroundConfig
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } }
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
}
const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => {
const current = bgConfig[layerId] ?? defaultBackgroundConfig
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } }
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
}
return (
<div className="space-y-6 sm:space-y-8">
{/* 主题模式 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<ThemeOption
value="light"
current={theme}
onChange={setTheme}
label="浅色"
description="始终使用浅色主题"
/>
<ThemeOption
value="dark"
current={theme}
onChange={setTheme}
label="深色"
description="始终使用深色主题"
/>
<ThemeOption
value="system"
current={theme}
onChange={setTheme}
label="跟随系统"
description="根据系统设置自动切换"
/>
</div>
</div>
{/* 主题色配置 */}
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-semibold"></h3>
<Button
variant="outline"
size="sm"
onClick={handleResetAccent}
disabled={!themeConfig.accentColor}
className="h-8"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-6">
{/* 颜色选择器 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center p-4 rounded-lg border bg-card">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
<input
type="color"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
/>
</div>
<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
id="accent-color-input"
type="text"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="font-mono uppercase w-32"
maxLength={7}
/>
</div>
</div>
{/* 实时色板预览 */}
<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>
{/* 5. 背景设置 (Backgrounds) */}
<AccordionItem value="backgrounds">
<AccordionTrigger> (Backgrounds)</AccordionTrigger>
<AccordionContent>
<div className="pt-2">
<Tabs defaultValue="page">
<TabsList className="w-full grid grid-cols-5">
<TabsTrigger value="page"></TabsTrigger>
<TabsTrigger value="sidebar"></TabsTrigger>
<TabsTrigger value="header">Header</TabsTrigger>
<TabsTrigger value="card">Card</TabsTrigger>
<TabsTrigger value="dialog">Dialog</TabsTrigger>
</TabsList>
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
{layerId !== 'page' && (
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium"></Label>
<p className="text-xs text-muted-foreground">使</p>
</div>
<Switch
checked={bgConfig[layerId]?.inherit ?? false}
onCheckedChange={(v) => handleBgInheritChange(layerId, v)}
/>
</div>
)}
<BackgroundUploader
assetId={bgConfig[layerId]?.assetId}
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
/>
<BackgroundEffectsControls
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
/>
<ComponentCSSEditor
componentId={layerId}
value={bgConfig[layerId]?.customCSS ?? ''}
onChange={(css) => handleBgCSSChange(layerId, css)}
/>
</TabsContent>
))}
</Tabs>
</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 @importurl()
</p>
</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>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-2 sm:space-y-3">
{/* 全局动画开关 */}
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="animations" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Switch
id="animations"
checked={enableAnimations}
onCheckedChange={setEnableAnimations}
/>
</div>
</div>
{/* 波浪背景开关 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="waves-background" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<Switch
id="waves-background"
checked={enableWavesBackground}
onCheckedChange={setEnableWavesBackground}
/>
</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>
)
}

View File

@@ -0,0 +1,15 @@
import { type LibraryItemProps } from './types'
export function LibraryItem({ name, description, license }: LibraryItemProps) {
return (
<div className="flex items-start justify-between gap-2 rounded-lg border bg-muted/30 p-2.5 sm:p-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">{name}</p>
<p className="text-muted-foreground text-xs mt-0.5">{description}</p>
</div>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary flex-shrink-0">
{license}
</span>
</div>
)
}

View File

@@ -0,0 +1,513 @@
import { AlertTriangle, Database, Download, HardDrive, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { useToast } from '@/hooks/use-toast'
import { clearLocalCache, DEFAULT_SETTINGS, exportSettings, formatBytes, getSetting, getStorageUsage, importSettings, resetAllSettings, setSetting } from '@/lib/settings-manager'
import { logWebSocket } from '@/lib/log-websocket'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
// 其他设置标签页
export function OtherTab() {
const navigate = useNavigate()
const { toast } = useToast()
const [isResetting, setIsResetting] = useState(false)
const [shouldThrowError, setShouldThrowError] = useState(false)
// 性能与存储设置状态
const [logCacheSize, setLogCacheSize] = useState(() => getSetting('logCacheSize'))
const [wsReconnectInterval, setWsReconnectInterval] = useState(() => getSetting('wsReconnectInterval'))
const [wsMaxReconnectAttempts, setWsMaxReconnectAttempts] = useState(() => getSetting('wsMaxReconnectAttempts'))
const [dataSyncInterval, setDataSyncInterval] = useState(() => getSetting('dataSyncInterval'))
const [storageUsage, setStorageUsage] = useState(() => getStorageUsage())
// 导入/导出状态
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// 手动触发 React 错误
if (shouldThrowError) {
throw new Error('这是一个手动触发的测试错误,用于验证错误边界组件是否正常工作。')
}
// 刷新存储使用情况
const refreshStorageUsage = () => {
setStorageUsage(getStorageUsage())
}
// 处理日志缓存大小变更
const handleLogCacheSizeChange = (value: number[]) => {
const size = value[0]
setLogCacheSize(size)
setSetting('logCacheSize', size)
}
// 处理 WebSocket 重连间隔变更
const handleWsReconnectIntervalChange = (value: number[]) => {
const interval = value[0]
setWsReconnectInterval(interval)
setSetting('wsReconnectInterval', interval)
}
// 处理 WebSocket 最大重连次数变更
const handleWsMaxReconnectAttemptsChange = (value: number[]) => {
const attempts = value[0]
setWsMaxReconnectAttempts(attempts)
setSetting('wsMaxReconnectAttempts', attempts)
}
// 处理数据同步间隔变更
const handleDataSyncIntervalChange = (value: number[]) => {
const interval = value[0]
setDataSyncInterval(interval)
setSetting('dataSyncInterval', interval)
}
// 清除日志缓存
const handleClearLogCache = () => {
logWebSocket.clearLogs()
toast({
title: '日志已清除',
description: '日志缓存已清空',
})
}
// 清除本地缓存
const handleClearLocalCache = () => {
const result = clearLocalCache()
refreshStorageUsage()
toast({
title: '缓存已清除',
description: `已清除 ${result.clearedKeys.length} 项缓存数据`,
})
}
// 导出设置
const handleExportSettings = () => {
setIsExporting(true)
try {
const settings = exportSettings()
const dataStr = JSON.stringify(settings, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `maibot-webui-settings-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({
title: '导出成功',
description: '设置已导出为 JSON 文件',
})
} catch (error) {
console.error('导出设置失败:', error)
toast({
title: '导出失败',
description: '无法导出设置',
variant: 'destructive',
})
} finally {
setIsExporting(false)
}
}
// 导入设置
const handleImportSettings = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsImporting(true)
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const settings = JSON.parse(content)
const result = importSettings(settings)
if (result.success) {
// 刷新页面状态
setLogCacheSize(getSetting('logCacheSize'))
setWsReconnectInterval(getSetting('wsReconnectInterval'))
setWsMaxReconnectAttempts(getSetting('wsMaxReconnectAttempts'))
setDataSyncInterval(getSetting('dataSyncInterval'))
refreshStorageUsage()
toast({
title: '导入成功',
description: `成功导入 ${result.imported.length} 项设置${result.skipped.length > 0 ? `,跳过 ${result.skipped.length}` : ''}`,
})
// 提示用户刷新页面以应用所有更改
if (result.imported.includes('theme') || result.imported.includes('accentColor')) {
toast({
title: '提示',
description: '部分设置需要刷新页面才能完全生效',
})
}
} else {
toast({
title: '导入失败',
description: '没有有效的设置项可导入',
variant: 'destructive',
})
}
} catch (error) {
console.error('导入设置失败:', error)
toast({
title: '导入失败',
description: '文件格式无效',
variant: 'destructive',
})
} finally {
setIsImporting(false)
// 清空 input允许重复选择同一文件
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
reader.readAsText(file)
}
// 重置所有设置
const handleResetAllSettings = () => {
resetAllSettings()
// 刷新页面状态
setLogCacheSize(DEFAULT_SETTINGS.logCacheSize)
setWsReconnectInterval(DEFAULT_SETTINGS.wsReconnectInterval)
setWsMaxReconnectAttempts(DEFAULT_SETTINGS.wsMaxReconnectAttempts)
setDataSyncInterval(DEFAULT_SETTINGS.dataSyncInterval)
refreshStorageUsage()
toast({
title: '已重置',
description: '所有设置已恢复为默认值,刷新页面以应用更改',
})
}
const handleResetSetup = async () => {
setIsResetting(true)
try {
// 调用后端API重置首次配置状态
const response = await fetchWithAuth('/api/webui/setup/reset', {
method: 'POST',
})
const data = await response.json()
if (response.ok && data.success) {
toast({
title: '重置成功',
description: '即将进入初次配置向导',
})
// 延迟跳转到配置向导
setTimeout(() => {
navigate({ to: '/setup' })
}, 1000)
} else {
toast({
title: '重置失败',
description: data.message || '无法重置配置状态',
variant: 'destructive',
})
}
} catch (error) {
console.error('重置配置状态错误:', error)
toast({
title: '重置失败',
description: '连接服务器失败',
variant: 'destructive',
})
} finally {
setIsResetting(false)
}
}
return (
<div className="space-y-4 sm:space-y-6">
{/* 性能与存储 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Database className="h-5 w-5" />
</h3>
<div className="space-y-4 sm:space-y-5">
{/* 存储使用情况 */}
<div className="rounded-lg bg-muted/50 p-3 sm:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium flex items-center gap-2">
<HardDrive className="h-4 w-4" />
使
</span>
<Button variant="ghost" size="sm" onClick={refreshStorageUsage} className="h-7 px-2">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="text-2xl font-bold text-primary">{formatBytes(storageUsage.used)}</div>
<p className="text-xs text-muted-foreground mt-1">{storageUsage.items} </p>
</div>
{/* 日志缓存大小 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{logCacheSize} </span>
</div>
<Slider
value={[logCacheSize]}
onValueChange={handleLogCacheSizeChange}
min={100}
max={5000}
step={100}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 数据刷新间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{dataSyncInterval} </span>
</div>
<Slider
value={[dataSyncInterval]}
onValueChange={handleDataSyncIntervalChange}
min={10}
max={120}
step={5}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* WebSocket 重连间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsReconnectInterval / 1000} </span>
</div>
<Slider
value={[wsReconnectInterval]}
onValueChange={handleWsReconnectIntervalChange}
min={1000}
max={10000}
step={500}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
WebSocket
</p>
</div>
{/* WebSocket 最大重连次数 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsMaxReconnectAttempts} </span>
</div>
<Slider
value={[wsMaxReconnectAttempts]}
onValueChange={handleWsMaxReconnectAttemptsChange}
min={3}
max={30}
step={1}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 清理按钮 */}
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm" onClick={handleClearLogCache} className="gap-2">
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearLocalCache}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
{/* 导入/导出设置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
/
</h3>
<div className="space-y-4">
<p className="text-xs sm:text-sm text-muted-foreground">
便
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={handleExportSettings}
disabled={isExporting}
className="gap-2"
>
<Download className="h-4 w-4" />
{isExporting ? '导出中...' : '导出设置'}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportSettings}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
className="gap-2"
>
<Upload className="h-4 w-4" />
{isImporting ? '导入中...' : '导入设置'}
</Button>
</div>
{/* 重置所有设置 */}
<div className="pt-2 border-t">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive">
<RotateCcw className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetAllSettings}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
{/* 配置向导 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={isResetting} className="gap-2">
<RotateCcw className={cn('h-4 w-4', isResetting && 'animate-spin')} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetSetup}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 开发者工具 */}
<div className="rounded-lg border border-dashed border-yellow-500/50 bg-yellow-500/5 p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
使
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<AlertTriangle className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
React
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => setShouldThrowError(true)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,486 @@
import {
AlertTriangle,
Check,
CheckCircle2,
Copy,
Eye,
EyeOff,
RefreshCw,
XCircle,
} from 'lucide-react'
import { useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useToast } from '@/hooks/use-toast'
import { validateToken } from '@/lib/token-validator'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
export function SecurityTab() {
const navigate = useNavigate()
const [currentToken, setCurrentToken] = useState('')
const [newToken, setNewToken] = useState('')
const [showCurrentToken, setShowCurrentToken] = useState(false)
const [showNewToken, setShowNewToken] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const [isRegenerating, setIsRegenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [showTokenDialog, setShowTokenDialog] = useState(false)
const [generatedToken, setGeneratedToken] = useState('')
const [tokenCopied, setTokenCopied] = useState(false)
const { toast } = useToast()
// 实时验证新 Token
const tokenValidation = useMemo(() => validateToken(newToken), [newToken])
// 复制 token 到剪贴板
const copyToClipboard = async (text: string) => {
if (!currentToken) {
toast({
title: '无法复制',
description: 'Token 存储在安全 Cookie 中,请重新生成以获取新 Token',
variant: 'destructive',
})
return
}
try {
await navigator.clipboard.writeText(text)
setCopied(true)
toast({
title: '复制成功',
description: 'Token 已复制到剪贴板',
})
setTimeout(() => setCopied(false), 2000)
} catch {
toast({
title: '复制失败',
description: '请手动复制 Token',
variant: 'destructive',
})
}
}
// 更新 token
const handleUpdateToken = async () => {
if (!newToken.trim()) {
toast({
title: '输入错误',
description: '请输入新的 Token',
variant: 'destructive',
})
return
}
// 验证 Token 格式
if (!tokenValidation.isValid) {
const failedRules = tokenValidation.rules
.filter((rule) => !rule.passed)
.map((rule) => rule.label)
.join(', ')
toast({
title: '格式错误',
description: `Token 不符合要求: ${failedRules}`,
variant: 'destructive',
})
return
}
setIsUpdating(true)
try {
const response = await fetch('/api/webui/auth/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 使用 Cookie 认证
body: JSON.stringify({ new_token: newToken.trim() }),
})
const data = await response.json()
if (response.ok && data.success) {
// 清空输入框
setNewToken('')
// 更新当前显示的 Token
setCurrentToken(newToken.trim())
toast({
title: '更新成功',
description: 'Access Token 已更新,即将跳转到登录页',
})
// 延迟跳转到登录页
setTimeout(() => {
navigate({ to: '/auth' })
}, 1500)
} else {
toast({
title: '更新失败',
description: data.message || '无法更新 Token',
variant: 'destructive',
})
}
} catch (err) {
console.error('更新 Token 错误:', err)
toast({
title: '更新失败',
description: '连接服务器失败',
variant: 'destructive',
})
} finally {
setIsUpdating(false)
}
}
// 重新生成 token (实际执行函数)
const executeRegenerateToken = async () => {
setIsRegenerating(true)
try {
const response = await fetch('/api/webui/auth/regenerate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 使用 Cookie 认证
})
const data = await response.json()
if (response.ok && data.success) {
// 更新当前显示的 Token
setCurrentToken(data.token)
// 显示弹窗展示新 Token
setGeneratedToken(data.token)
setShowTokenDialog(true)
setTokenCopied(false)
toast({
title: '生成成功',
description: '新的 Access Token 已生成,请及时保存',
})
} else {
toast({
title: '生成失败',
description: data.message || '无法生成新 Token',
variant: 'destructive',
})
}
} catch (err) {
console.error('生成 Token 错误:', err)
toast({
title: '生成失败',
description: '连接服务器失败',
variant: 'destructive',
})
} finally {
setIsRegenerating(false)
}
}
// 复制生成的 Token
const copyGeneratedToken = async () => {
try {
await navigator.clipboard.writeText(generatedToken)
setTokenCopied(true)
toast({
title: '复制成功',
description: 'Token 已复制到剪贴板',
})
} catch {
toast({
title: '复制失败',
description: '请手动复制 Token',
variant: 'destructive',
})
}
}
// 关闭弹窗
const handleCloseDialog = () => {
setShowTokenDialog(false)
// 延迟清空 token避免用户看到内容消失
setTimeout(() => {
setGeneratedToken('')
setTokenCopied(false)
}, 300)
// 跳转到登录页
setTimeout(() => {
navigate({ to: '/auth' })
}, 500)
}
// 处理对话框状态变化包括点击外部、ESC 等关闭方式)
const handleDialogOpenChange = (open: boolean) => {
if (!open) {
handleCloseDialog()
}
}
return (
<div className="space-y-4 sm:space-y-6">
{/* Token 生成成功弹窗 */}
<Dialog open={showTokenDialog} onOpenChange={handleDialogOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Access Token
</DialogTitle>
<DialogDescription>
Token
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Token 显示区域 */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<Label className="text-xs text-muted-foreground mb-2 block">
Token (64)
</Label>
<div className="font-mono text-sm break-all select-all bg-background p-3 rounded border">
{generatedToken}
</div>
</div>
{/* 警告提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token </li>
<li></li>
<li></li>
<li>使 Token </li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={copyGeneratedToken}
className="gap-2"
>
{tokenCopied ? (
<>
<Check className="h-4 w-4 text-green-500" />
</>
) : (
<>
<Copy className="h-4 w-4" />
Token
</>
)}
</Button>
<Button onClick={handleCloseDialog}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 当前 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="current-token" className="text-sm">访</Label>
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Input
id="current-token"
type={showCurrentToken ? 'text' : 'password'}
value={currentToken || '••••••••••••••••••••••••••••••••'}
readOnly
className="pr-10 font-mono text-sm"
placeholder="Token 存储在安全 Cookie 中"
/>
<button
onClick={() => {
if (currentToken) {
setShowCurrentToken(!showCurrentToken)
} else {
toast({
title: '无法查看',
description: 'Token 存储在安全 Cookie 中,如需新 Token 请点击"重新生成"',
})
}
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showCurrentToken ? '隐藏' : '显示'}
>
{showCurrentToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(currentToken)}
title="复制到剪贴板"
className="flex-shrink-0"
disabled={!currentToken}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={isRegenerating}
className="gap-2 flex-1 sm:flex-none"
>
<RefreshCw className={cn('h-4 w-4', isRegenerating && 'animate-spin')} />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> Token</AlertDialogTitle>
<AlertDialogDescription>
64 使 Token
使 Token
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={executeRegenerateToken}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Access Token
</p>
</div>
</div>
</div>
{/* 更新 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="new-token" className="text-sm">访</Label>
<div className="relative">
<Input
id="new-token"
type={showNewToken ? 'text' : 'password'}
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
className="pr-10 font-mono text-sm"
placeholder="输入自定义 Token"
/>
<button
onClick={() => setShowNewToken(!showNewToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showNewToken ? '隐藏' : '显示'}
>
{showNewToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
{/* Token 验证规则显示 */}
{newToken && (
<div className="mt-3 space-y-2 p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium text-foreground">Token :</p>
<div className="space-y-1.5">
{tokenValidation.rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 text-sm">
{rule.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
rule.passed ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}>
{rule.label}
</span>
</div>
))}
</div>
{tokenValidation.isValid && (
<div className="mt-2 pt-2 border-t border-border">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="h-4 w-4" />
<span className="font-medium">Token 使</span>
</div>
</div>
)}
</div>
)}
</div>
<Button
onClick={handleUpdateToken}
disabled={isUpdating || !tokenValidation.isValid || !newToken}
className="w-full sm:w-auto"
>
{isUpdating ? '更新中...' : '更新自定义 Token'}
</Button>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3 sm:p-4">
<h4 className="text-sm sm:text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2"></h4>
<ul className="text-xs sm:text-sm text-yellow-800 dark:text-yellow-300 space-y-1 list-disc list-inside">
<li> Token 64 </li>
<li> Token 使</li>
<li> Token Token </li>
<li> Token</li>
<li>怀 Token </li>
<li>使 Token </li>
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { cn } from '@/lib/utils'
import { type ThemeOptionProps } from './types'
export function ThemeOption({ value, current, onChange, label, description }: ThemeOptionProps) {
const isSelected = current === value
return (
<button
onClick={() => onChange(value)}
className={cn(
'relative rounded-lg border-2 p-3 sm:p-4 text-left transition-all',
'hover:border-primary/50 hover:bg-accent/50',
isSelected ? 'border-primary bg-accent' : 'border-border'
)}
>
{isSelected && (
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 h-2 w-2 rounded-full bg-primary" />
)}
<div className="space-y-1">
<div className="text-sm sm:text-base font-medium">{label}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{description}</div>
</div>
<div className="mt-2 sm:mt-3 flex gap-1">
{value === 'light' && (
<>
<div className="h-2 w-2 rounded-full bg-slate-200" />
<div className="h-2 w-2 rounded-full bg-slate-300" />
<div className="h-2 w-2 rounded-full bg-slate-400" />
</>
)}
{value === 'dark' && (
<>
<div className="h-2 w-2 rounded-full bg-slate-700" />
<div className="h-2 w-2 rounded-full bg-slate-800" />
<div className="h-2 w-2 rounded-full bg-slate-900" />
</>
)}
{value === 'system' && (
<>
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-200 to-slate-700" />
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-300 to-slate-800" />
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-400 to-slate-900" />
</>
)}
</div>
</button>
)
}

View File

@@ -0,0 +1,63 @@
import { Info, Palette, Settings, Shield } from 'lucide-react'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { AboutTab } from './AboutTab'
import { AppearanceTab } from './AppearanceTab'
import { OtherTab } from './OtherTab'
import { SecurityTab } from './SecurityTab'
export function SettingsPage() {
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"></p>
</div>
</div>
{/* 标签页 */}
<Tabs defaultValue="appearance" className="w-full">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 gap-0.5 sm:gap-1 h-auto p-1">
<TabsTrigger value="appearance" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Palette className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Shield className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="other" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="about" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-240px)] sm:h-[calc(100vh-280px)] mt-4 sm:mt-6">
<TabsContent value="appearance" className="mt-0">
<AppearanceTab />
</TabsContent>
<TabsContent value="security" className="mt-0">
<SecurityTab />
</TabsContent>
<TabsContent value="other" className="mt-0">
<OtherTab />
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutTab />
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,51 @@
function hslToHex(hsl: string): string {
if (!hsl) return '#000000'
// 解析 "221.2 83.2% 53.3%" 格式
const parts = hsl.split(' ').filter(Boolean)
if (parts.length < 3) return '#000000'
const h = parseFloat(parts[0])
const s = parseFloat(parts[1].replace('%', ''))
const l = parseFloat(parts[2].replace('%', ''))
const sDecimal = s / 100
const lDecimal = l / 100
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = lDecimal - c / 2
let r = 0, g = 0, b = 0
if (h >= 0 && h < 60) { r = c; g = x; b = 0 }
else if (h >= 60 && h < 120) { r = x; g = c; b = 0 }
else if (h >= 120 && h < 180) { r = 0; g = c; b = x }
else if (h >= 180 && h < 240) { r = 0; g = x; b = c }
else if (h >= 240 && h < 300) { r = x; g = 0; b = c }
else if (h >= 300 && h < 360) { r = c; g = 0; b = x }
const toHex = (n: number) => {
const hex = Math.round((n + m) * 255).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
type LibraryItemProps = {
name: string
description: string
license: string
}
type ThemeOptionProps = {
value: 'light' | 'dark' | 'system'
current: 'light' | 'dark' | 'system'
onChange: (theme: 'light' | 'dark' | 'system') => void
label: string
description: string
}
export { hslToHex }
export type { LibraryItemProps, ThemeOptionProps }