27 changed files with 1689 additions and 266 deletions
File diff suppressed because it is too large
@ -0,0 +1,56 @@ |
|||
import React from 'react'; |
|||
|
|||
interface PaginationBarProps { |
|||
page: number; |
|||
pageSize: number; |
|||
total: number; |
|||
onPageChange: (page: number) => void; |
|||
onPageSizeChange: (size: number) => void; |
|||
showSummary?: boolean; |
|||
pageSizeOptions?: number[]; |
|||
} |
|||
|
|||
export default function PaginationBar({ |
|||
page, |
|||
pageSize, |
|||
total, |
|||
onPageChange, |
|||
onPageSizeChange, |
|||
showSummary = true, |
|||
pageSizeOptions = [10, 20, 50], |
|||
}: PaginationBarProps) { |
|||
const totalPages = Math.ceil(total / pageSize); |
|||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1; |
|||
const end = Math.min(page * pageSize, total); |
|||
|
|||
return ( |
|||
<div className="flex justify-end items-center gap-1 text-xs h-8 mt-2 border-t pt-2"> |
|||
{showSummary && ( |
|||
<div className="text-muted-foreground"> |
|||
第 {start}-{end} 条/共 {total} 条 |
|||
</div> |
|||
)} |
|||
<button |
|||
className="border rounded px-1 py-0.5 text-xs disabled:opacity-50" |
|||
disabled={page === 1} |
|||
onClick={() => onPageChange(page - 1)} |
|||
>上一页</button> |
|||
<span className="text-xs mx-1">{page} / {totalPages}</span> |
|||
<button |
|||
className="border rounded px-1 py-0.5 text-xs disabled:opacity-50" |
|||
disabled={page === totalPages || totalPages === 0} |
|||
onClick={() => onPageChange(page + 1)} |
|||
>下一页</button> |
|||
<select |
|||
className="border rounded px-1 py-0.5 text-xs bg-background text-foreground ml-2" |
|||
value={pageSize} |
|||
onChange={e => onPageSizeChange(Number(e.target.value))} |
|||
style={{ minWidth: 60 }} |
|||
> |
|||
{pageSizeOptions.map(opt => ( |
|||
<option key={opt} value={opt}>{opt} 条/页</option> |
|||
))} |
|||
</select> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,208 @@ |
|||
import React, { useState, useRef, useEffect } from 'react'; |
|||
import { AiOutlineReload, AiOutlineSetting, AiOutlineTable } from 'react-icons/ai'; |
|||
|
|||
interface ColumnConfig { |
|||
key: string; |
|||
title: string; |
|||
visible: boolean; |
|||
fixed?: boolean; |
|||
} |
|||
|
|||
interface TableToolbarProps { |
|||
onRefresh?: () => void; |
|||
onDensityChange?: (density: DensityType) => void; |
|||
onColumnsChange?: (columns: ColumnConfig[]) => void; |
|||
onColumnsReset?: () => void; |
|||
onColumnsConfigOpen?: () => void; |
|||
columns?: ColumnConfig[]; |
|||
density?: DensityType; |
|||
} |
|||
|
|||
export type DensityType = 'relaxed' | 'default' | 'compact'; |
|||
|
|||
const densityOptions = [ |
|||
{ value: 'relaxed', label: '宽松' }, |
|||
{ value: 'default', label: '中等' }, |
|||
{ value: 'compact', label: '紧凑' }, |
|||
]; |
|||
|
|||
function Tooltip({ text, children }: { text: string; children: React.ReactNode }) { |
|||
const [show, setShow] = useState(false); |
|||
return ( |
|||
<div className="relative flex items-center"> |
|||
<div |
|||
onMouseEnter={() => setShow(true)} |
|||
onMouseLeave={() => setShow(false)} |
|||
className="flex" |
|||
> |
|||
{children} |
|||
</div> |
|||
{show && ( |
|||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 flex flex-col items-center"> |
|||
<div className="bg-black text-white text-xs rounded px-2 py-1 shadow-lg whitespace-nowrap font-bold relative"> |
|||
{text} |
|||
<span className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-black"></span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default function TableToolbar({ onRefresh, onDensityChange, onColumnsChange, onColumnsReset, columns = [], density = 'default' }: TableToolbarProps) { |
|||
const [densityMenuOpen, setDensityMenuOpen] = useState(false); |
|||
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false); |
|||
const densityBtnRef = useRef<HTMLDivElement>(null); |
|||
const columnsBtnRef = useRef<HTMLDivElement>(null); |
|||
const [localColumns, setLocalColumns] = useState<ColumnConfig[]>(columns); |
|||
const dragItem = useRef<number | null>(null); |
|||
const dragOverItem = useRef<number | null>(null); |
|||
|
|||
// 同步外部columns变化
|
|||
useEffect(() => { |
|||
setLocalColumns(columns); |
|||
}, [columns]); |
|||
|
|||
// 点击外部关闭菜单
|
|||
useEffect(() => { |
|||
function handleClick(e: MouseEvent) { |
|||
if (densityBtnRef.current && !densityBtnRef.current.contains(e.target as Node)) { |
|||
setDensityMenuOpen(false); |
|||
} |
|||
if (columnsBtnRef.current && !columnsBtnRef.current.contains(e.target as Node)) { |
|||
setColumnsMenuOpen(false); |
|||
} |
|||
} |
|||
if (densityMenuOpen || columnsMenuOpen) document.addEventListener('mousedown', handleClick); |
|||
return () => document.removeEventListener('mousedown', handleClick); |
|||
}, [densityMenuOpen, columnsMenuOpen]); |
|||
|
|||
// 切换列显示
|
|||
const handleToggleColumn = (key: string) => { |
|||
const updated = localColumns.map(col => col.key === key ? { ...col, visible: !col.visible } : col); |
|||
setLocalColumns(updated); |
|||
onColumnsChange?.(updated); |
|||
}; |
|||
|
|||
// 重置
|
|||
const handleReset = () => { |
|||
onColumnsReset?.(); |
|||
setColumnsMenuOpen(false); |
|||
}; |
|||
|
|||
// 拖拽排序
|
|||
const handleDragStart = (index: number) => { |
|||
dragItem.current = index; |
|||
}; |
|||
const handleDragEnter = (index: number) => { |
|||
dragOverItem.current = index; |
|||
}; |
|||
const handleDragEnd = () => { |
|||
if (dragItem.current === null || dragOverItem.current === null || dragItem.current === dragOverItem.current) { |
|||
dragItem.current = null; |
|||
dragOverItem.current = null; |
|||
return; |
|||
} |
|||
const updated = [...localColumns]; |
|||
const [removed] = updated.splice(dragItem.current, 1); |
|||
updated.splice(dragOverItem.current, 0, removed); |
|||
setLocalColumns(updated); |
|||
onColumnsChange?.(updated); |
|||
dragItem.current = null; |
|||
dragOverItem.current = null; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="flex items-center gap-3"> |
|||
<Tooltip text="刷新"> |
|||
<button |
|||
type="button" |
|||
className="p-2 rounded-full hover:bg-accent transition-colors" |
|||
onClick={onRefresh} |
|||
> |
|||
<AiOutlineReload className="w-5 h-5" /> |
|||
</button> |
|||
</Tooltip> |
|||
{/* 密度设置按钮及菜单 */} |
|||
<div className="relative" ref={densityBtnRef}> |
|||
<Tooltip text="密度"> |
|||
<button |
|||
type="button" |
|||
aria-label="密度" |
|||
className={ |
|||
`p-2 rounded-full border-b-2 transition-colors border-transparent hover:bg-accent !bg-transparent focus:!bg-transparent active:!bg-transparent` |
|||
+ (densityMenuOpen ? ' text-primary border-primary' : '') |
|||
} |
|||
style={{ |
|||
background: 'transparent', |
|||
boxShadow: 'none', |
|||
}} |
|||
onClick={() => setDensityMenuOpen(v => !v)} |
|||
> |
|||
<span className="sr-only">密度</span> |
|||
<AiOutlineSetting className="w-5 h-5" /> |
|||
</button> |
|||
</Tooltip> |
|||
{densityMenuOpen && ( |
|||
<div className="absolute right-0 mt-2 w-20 rounded-xl shadow-2xl bg-background border z-50 animate-fade-in"> |
|||
{densityOptions.map(opt => ( |
|||
<div |
|||
key={opt.value} |
|||
className={`px-4 py-2 cursor-pointer text-center text-sm select-none transition-colors ${density === opt.value ? 'bg-primary/10 text-primary font-bold' : 'hover:bg-accent'}`} |
|||
onClick={() => { onDensityChange?.(opt.value as DensityType); setDensityMenuOpen(false); }} |
|||
> |
|||
{opt.label} |
|||
</div> |
|||
))} |
|||
</div> |
|||
)} |
|||
</div> |
|||
{/* 列设置按钮及菜单 */} |
|||
<div className="relative" ref={columnsBtnRef}> |
|||
<Tooltip text="列显示设置"> |
|||
<button |
|||
type="button" |
|||
className="p-2 rounded-full hover:bg-accent transition-colors" |
|||
onClick={() => setColumnsMenuOpen(v => !v)} |
|||
> |
|||
<AiOutlineTable className="w-5 h-5" /> |
|||
</button> |
|||
</Tooltip> |
|||
{columnsMenuOpen && ( |
|||
<div className="absolute right-0 mt-2 w-56 rounded-xl shadow-2xl bg-background border z-50 animate-fade-in p-3"> |
|||
<div className="flex items-center justify-between mb-2"> |
|||
<div className="flex items-center gap-2 font-bold text-primary"> |
|||
<input type="checkbox" checked readOnly className="accent-primary" /> |
|||
<span>列展示</span> |
|||
</div> |
|||
<button className="text-primary font-bold text-sm hover:underline" onClick={handleReset}>重置</button> |
|||
</div> |
|||
<div className="space-y-2"> |
|||
{localColumns.map((col, idx) => ( |
|||
<div |
|||
key={col.key} |
|||
className="flex items-center gap-2 pl-2" |
|||
draggable |
|||
onDragStart={() => handleDragStart(idx)} |
|||
onDragEnter={() => handleDragEnter(idx)} |
|||
onDragEnd={handleDragEnd} |
|||
onDragOver={e => e.preventDefault()} |
|||
style={{ cursor: 'move' }} |
|||
> |
|||
<span className="cursor-move select-none text-gray-400">⋮⋮</span> |
|||
<input |
|||
type="checkbox" |
|||
checked={col.visible} |
|||
onChange={() => handleToggleColumn(col.key)} |
|||
className="accent-primary" |
|||
/> |
|||
<span>{col.title}</span> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,20 @@ |
|||
import { Moon, Sun } from 'lucide-react'; |
|||
import { useTheme } from '@/contexts/ThemeContext'; |
|||
import { Button } from './button'; |
|||
|
|||
export function ThemeToggle() { |
|||
const { theme, toggleTheme } = useTheme(); |
|||
|
|||
return ( |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={toggleTheme} |
|||
className="relative rounded-full p-2 hover:bg-accent" |
|||
> |
|||
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> |
|||
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> |
|||
<span className="sr-only">切换主题</span> |
|||
</Button> |
|||
); |
|||
} |
@ -1,6 +1,7 @@ |
|||
export * from './button'; |
|||
export * from './input'; |
|||
export * from './form'; |
|||
export * from './label'; |
|||
export * from './table'; |
|||
export * from './dialog'; |
|||
export { Button } from './button'; |
|||
export { Input } from './input'; |
|||
export { Form } from './form'; |
|||
export { Label } from './label'; |
|||
export { Table } from './table'; |
|||
export { Dialog } from './dialog'; |
|||
export { ThemeToggle } from './theme-toggle'; |
@ -0,0 +1,115 @@ |
|||
import * as React from 'react'; |
|||
import * as SelectPrimitive from '@radix-ui/react-select'; |
|||
import { Check, ChevronDown } from 'lucide-react'; |
|||
import { cn } from '@/lib/utils'; |
|||
|
|||
const Select = SelectPrimitive.Root; |
|||
const SelectGroup = SelectPrimitive.Group; |
|||
const SelectValue = SelectPrimitive.Value; |
|||
|
|||
const SelectTrigger = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Trigger>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<SelectPrimitive.Trigger |
|||
ref={ref} |
|||
className={cn( |
|||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
{children} |
|||
<SelectPrimitive.Icon asChild> |
|||
<ChevronDown className="h-4 w-4 opacity-50" /> |
|||
</SelectPrimitive.Icon> |
|||
</SelectPrimitive.Trigger> |
|||
)); |
|||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; |
|||
|
|||
const SelectContent = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Content>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> |
|||
>(({ className, children, position = 'popper', ...props }, ref) => ( |
|||
<SelectPrimitive.Portal> |
|||
<SelectPrimitive.Content |
|||
ref={ref} |
|||
className={cn( |
|||
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', |
|||
position === 'popper' && |
|||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', |
|||
className |
|||
)} |
|||
position={position} |
|||
{...props} |
|||
> |
|||
<SelectPrimitive.Viewport |
|||
className={cn( |
|||
'p-1', |
|||
position === 'popper' && |
|||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]' |
|||
)} |
|||
> |
|||
{children} |
|||
</SelectPrimitive.Viewport> |
|||
</SelectPrimitive.Content> |
|||
</SelectPrimitive.Portal> |
|||
)); |
|||
SelectContent.displayName = SelectPrimitive.Content.displayName; |
|||
|
|||
const SelectLabel = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Label>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.Label |
|||
ref={ref} |
|||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
SelectLabel.displayName = SelectPrimitive.Label.displayName; |
|||
|
|||
const SelectItem = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Item>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> |
|||
>(({ className, children, ...props }, ref) => ( |
|||
<SelectPrimitive.Item |
|||
ref={ref} |
|||
className={cn( |
|||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', |
|||
className |
|||
)} |
|||
{...props} |
|||
> |
|||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> |
|||
<SelectPrimitive.ItemIndicator> |
|||
<Check className="h-4 w-4" /> |
|||
</SelectPrimitive.ItemIndicator> |
|||
</span> |
|||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
|||
</SelectPrimitive.Item> |
|||
)); |
|||
SelectItem.displayName = SelectPrimitive.Item.displayName; |
|||
|
|||
const SelectSeparator = React.forwardRef< |
|||
React.ElementRef<typeof SelectPrimitive.Separator>, |
|||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> |
|||
>(({ className, ...props }, ref) => ( |
|||
<SelectPrimitive.Separator |
|||
ref={ref} |
|||
className={cn('-mx-1 my-1 h-px bg-muted', className)} |
|||
{...props} |
|||
/> |
|||
)); |
|||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName; |
|||
|
|||
export { |
|||
Select, |
|||
SelectGroup, |
|||
SelectValue, |
|||
SelectTrigger, |
|||
SelectContent, |
|||
SelectLabel, |
|||
SelectItem, |
|||
SelectSeparator, |
|||
}; |
@ -0,0 +1,20 @@ |
|||
import { Moon, Sun } from 'lucide-react'; |
|||
import { Button } from './button'; |
|||
import { useTheme } from '@/contexts/ThemeContext'; |
|||
|
|||
export function ThemeToggle() { |
|||
const { toggleTheme } = useTheme(); |
|||
|
|||
return ( |
|||
<Button |
|||
variant="ghost" |
|||
size="icon" |
|||
onClick={toggleTheme} |
|||
className="h-9 w-9" |
|||
> |
|||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> |
|||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> |
|||
<span className="sr-only">切换主题</span> |
|||
</Button> |
|||
); |
|||
} |
@ -0,0 +1,35 @@ |
|||
import { createContext, useContext } from 'react'; |
|||
import { useRecoilState } from 'recoil'; |
|||
import { settingsState, AppSettings } from '@/stores/settingsStore'; |
|||
|
|||
interface SettingsContextType { |
|||
settings: AppSettings; |
|||
updateSettings: (settings: Partial<AppSettings>) => void; |
|||
} |
|||
|
|||
const SettingsContext = createContext<SettingsContextType | undefined>(undefined); |
|||
|
|||
export function SettingsContextProvider({ children }: { children: React.ReactNode }) { |
|||
const [settings, setSettings] = useRecoilState(settingsState); |
|||
|
|||
const updateSettings = (newSettings: Partial<AppSettings>) => { |
|||
setSettings(prev => ({ |
|||
...prev, |
|||
...newSettings, |
|||
})); |
|||
}; |
|||
|
|||
return ( |
|||
<SettingsContext.Provider value={{ settings, updateSettings }}> |
|||
{children} |
|||
</SettingsContext.Provider> |
|||
); |
|||
} |
|||
|
|||
export function useSettings() { |
|||
const context = useContext(SettingsContext); |
|||
if (context === undefined) { |
|||
throw new Error('useSettings must be used within a SettingsContextProvider'); |
|||
} |
|||
return context; |
|||
} |
@ -0,0 +1,47 @@ |
|||
import { createContext, useContext, useEffect, useState } from 'react'; |
|||
import { useRecoilState } from 'recoil'; |
|||
import { themeState } from '@/stores/themeStore'; |
|||
|
|||
interface ThemeContextType { |
|||
theme: 'light' | 'dark'; |
|||
setTheme: (theme: 'light' | 'dark') => void; |
|||
toggleTheme: () => void; |
|||
} |
|||
|
|||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); |
|||
|
|||
export function ThemeContextProvider({ children }: { children: React.ReactNode }) { |
|||
const [state, setState] = useRecoilState(themeState); |
|||
const [mounted, setMounted] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
setMounted(true); |
|||
}, []); |
|||
|
|||
const setTheme = (theme: 'light' | 'dark') => { |
|||
setState({ theme }); |
|||
}; |
|||
|
|||
const toggleTheme = () => { |
|||
setTheme(state.theme === 'light' ? 'dark' : 'light'); |
|||
}; |
|||
|
|||
// 防止水合不匹配
|
|||
if (!mounted) { |
|||
return null; |
|||
} |
|||
|
|||
return ( |
|||
<ThemeContext.Provider value={{ theme: state.theme, setTheme, toggleTheme }}> |
|||
{children} |
|||
</ThemeContext.Provider> |
|||
); |
|||
} |
|||
|
|||
export function useTheme() { |
|||
const context = useContext(ThemeContext); |
|||
if (context === undefined) { |
|||
throw new Error('useTheme must be used within a ThemeContextProvider'); |
|||
} |
|||
return context; |
|||
} |
@ -0,0 +1,34 @@ |
|||
import { ReactNode, useEffect } from 'react'; |
|||
import { useRecoilValue } from 'recoil'; |
|||
import { themeState } from '@/stores/themeStore'; |
|||
|
|||
interface ThemeProviderProps { |
|||
children: ReactNode; |
|||
} |
|||
|
|||
export function ThemeProvider({ children }: ThemeProviderProps) { |
|||
const { theme } = useRecoilValue(themeState); |
|||
|
|||
useEffect(() => { |
|||
// 应用主题
|
|||
document.documentElement.classList.remove('light', 'dark'); |
|||
document.documentElement.classList.add(theme); |
|||
|
|||
// 保存主题到本地存储
|
|||
localStorage.setItem('theme', theme); |
|||
}, [theme]); |
|||
|
|||
// 初始化主题
|
|||
useEffect(() => { |
|||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null; |
|||
if (savedTheme) { |
|||
document.documentElement.classList.add(savedTheme); |
|||
} else { |
|||
// 检查系统主题
|
|||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
|||
document.documentElement.classList.add(prefersDark ? 'dark' : 'light'); |
|||
} |
|||
}, []); |
|||
|
|||
return <>{children}</>; |
|||
} |
@ -1,19 +0,0 @@ |
|||
import { ReactNode, useEffect } from 'react'; |
|||
import { useRecoilValue } from 'recoil'; |
|||
import { appSettingsState } from '@/states/appState'; |
|||
|
|||
interface ThemeProviderProps { |
|||
children: ReactNode; |
|||
} |
|||
|
|||
export function ThemeProvider({ children }: ThemeProviderProps) { |
|||
const settings = useRecoilValue(appSettingsState); |
|||
|
|||
useEffect(() => { |
|||
// 应用主题
|
|||
document.documentElement.classList.remove('light', 'dark'); |
|||
document.documentElement.classList.add(settings.theme); |
|||
}, [settings.theme]); |
|||
|
|||
return <>{children}</>; |
|||
} |
@ -1,14 +1,18 @@ |
|||
import { atom, selector } from 'recoil'; |
|||
import { User } from '@/types/auth'; |
|||
|
|||
export interface AppSettings { |
|||
theme: 'light' | 'dark'; |
|||
language: 'zh-CN' | 'en-US'; |
|||
} |
|||
|
|||
// 应用设置状态
|
|||
export const appSettingsState = atom({ |
|||
key: 'appSettingsState', |
|||
export const appSettingsState = atom<AppSettings>({ |
|||
key: 'appSettings', |
|||
default: { |
|||
theme: 'light', |
|||
language: 'zh-CN', |
|||
sidebarCollapsed: false, |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
// 用户信息状态(与 AuthProvider 同步)
|
@ -0,0 +1,3 @@ |
|||
export * from './themeStore'; |
|||
export * from './settingsStore'; |
|||
export * from './userStore'; |
@ -0,0 +1,18 @@ |
|||
import { atom } from 'recoil'; |
|||
|
|||
export interface AppSettings { |
|||
language: 'zh-CN' | 'en-US'; |
|||
notifications: boolean; |
|||
soundEnabled: boolean; |
|||
autoRefresh: boolean; |
|||
} |
|||
|
|||
export const settingsState = atom<AppSettings>({ |
|||
key: 'settingsState', |
|||
default: { |
|||
language: 'zh-CN', |
|||
notifications: true, |
|||
soundEnabled: true, |
|||
autoRefresh: true, |
|||
}, |
|||
}); |
@ -0,0 +1,12 @@ |
|||
import { atom } from 'recoil'; |
|||
|
|||
export interface ThemeState { |
|||
theme: 'light' | 'dark'; |
|||
} |
|||
|
|||
export const themeState = atom<ThemeState>({ |
|||
key: 'themeState', |
|||
default: { |
|||
theme: 'light', |
|||
}, |
|||
}); |
@ -0,0 +1,15 @@ |
|||
import { atom } from 'recoil'; |
|||
|
|||
export interface User { |
|||
id: string; |
|||
username: string; |
|||
email: string; |
|||
avatar?: string; |
|||
roles: string[]; |
|||
permissions: string[]; |
|||
} |
|||
|
|||
export const userState = atom<User | null>({ |
|||
key: 'userState', |
|||
default: null, |
|||
}); |
Loading…
Reference in new issue