feat: 新增组件库

This commit is contained in:
Wxw-Gu
2026-08-18 15:29:13 +08:00
parent f93dc539e4
commit 89d388f661
39 changed files with 2630 additions and 141 deletions
+20
View File
@@ -68,9 +68,24 @@
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@koromix/koffi-win32-x64": "3.1.0", "@koromix/koffi-win32-x64": "3.1.0",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-virtual": "^3.14.6", "@tanstack/react-virtual": "^3.14.6",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ffmpeg-static": "5.3.0", "ffmpeg-static": "5.3.0",
@@ -82,6 +97,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sherpa-onnx-node": "1.13.3", "sherpa-onnx-node": "1.13.3",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"tailwind-merge": "^3.6.0",
"wechat-emojis": "^1.0.2" "wechat-emojis": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
@@ -102,6 +118,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"autoprefixer": "^10.5.4",
"electron": "^43.0.0", "electron": "^43.0.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
@@ -110,10 +127,13 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"postcss": "^8.5.26",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"sass": "^1.102.0", "sass": "^1.102.0",
"tailwindcss": "3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6", "vite": "^7.2.6",
"vitest": "^4.1.10", "vitest": "^4.1.10",
+1012 -7
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+5 -4
View File
@@ -38,6 +38,7 @@ import { enrichQuotedMessages } from './utils/quoted-messages'
import type { SelectableReportTemplateId } from '../../shared/report-templates' import type { SelectableReportTemplateId } from '../../shared/report-templates'
import { switchGeneratedReportTemplate } from './utils/report-template-switch' import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform } from './utils/runtime-environment' import { runtimePlatform } from './utils/runtime-environment'
import { useToast } from './components/ui'
const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380 const SIDEBAR_MAX_WIDTH = 380
@@ -214,6 +215,7 @@ const buildSyntheticGroupMessages = (
void buildSyntheticGroupMessages void buildSyntheticGroupMessages
function App(): React.ReactElement { function App(): React.ReactElement {
const { toast } = useToast()
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false) const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false) const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
@@ -328,9 +330,9 @@ function App(): React.ReactElement {
messagesRef.current = messages messagesRef.current = messages
React.useEffect(() => { React.useEffect(() => {
if (!reportNotice) return if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200) toast({ description: reportNotice, duration: 3200 })
return () => window.clearTimeout(timer) setReportNotice('')
}, [reportNotice]) }, [reportNotice, toast])
React.useEffect(() => { React.useEffect(() => {
const openVoiceRecognitionSettings = (): void => { const openVoiceRecognitionSettings = (): void => {
setSettingsCategory('voice-recognition') setSettingsCategory('voice-recognition')
@@ -2111,7 +2113,6 @@ function App(): React.ReactElement {
appearanceTheme={appearanceSettings.theme} appearanceTheme={appearanceSettings.theme}
compactMode={appearanceSettings.compactMode} compactMode={appearanceSettings.compactMode}
> >
{reportNotice && <div className="app-toast">{reportNotice}</div>}
{showFirstUseWelcome && ( {showFirstUseWelcome && (
<FirstUseWelcome <FirstUseWelcome
onDismiss={dismissFirstUseWelcome} onDismiss={dismissFirstUseWelcome}
@@ -44,6 +44,13 @@ export function AppShell({
compactMode = false, compactMode = false,
children children
}: AppShellProps): React.ReactElement { }: AppShellProps): React.ReactElement {
React.useEffect(() => {
document.documentElement.dataset.theme = appearanceTheme
return () => {
delete document.documentElement.dataset.theme
}
}, [appearanceTheme])
const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage) const activeItem = PRIMARY_NAV_ITEMS.find((item) => item.id === activePage)
return ( return (
@@ -0,0 +1,83 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '../../lib/cn'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogCancel = AlertDialogPrimitive.Cancel
const AlertDialogAction = AlertDialogPrimitive.Action
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AlertDialogPrimitive.Portal>
<div className="fixed inset-0 z-modal flex items-start justify-center overflow-y-auto p-4 sm:items-center">
<AlertDialogPrimitive.Overlay className="fixed inset-0 bg-slate-950/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in" />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'relative z-modal grid w-full max-w-md gap-4 rounded-xl border border-border bg-surface p-6 text-foreground shadow-dialog duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
className
)}
{...props}
>
{children}
</AlertDialogPrimitive.Content>
</div>
</AlertDialogPrimitive.Portal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
)
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-base font-semibold tracking-tight', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-fast ease-tm-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-surface hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground',
outline: 'border border-border bg-surface text-foreground shadow-surface hover:bg-accent',
destructive:
'bg-destructive text-destructive-foreground shadow-surface hover:bg-destructive/90',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
sm: 'h-8 rounded-md px-3 text-xs',
default: 'h-9 px-4 py-2',
lg: 'h-10 rounded-lg px-6',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, type = 'button', ...props }, ref) => (
<button
ref={ref}
type={type}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
)
Button.displayName = 'Button'
// eslint-disable-next-line react-refresh/only-export-components
export { buttonVariants }
@@ -0,0 +1,24 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cn } from '../../lib/cn'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-border bg-surface shadow-surface transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-[11px]">
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+113
View File
@@ -0,0 +1,113 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '../../lib/cn'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const DialogPortal = ({
children,
...props
}: DialogPrimitive.DialogPortalProps): React.ReactElement => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-modal flex items-start justify-center overflow-y-auto p-4 sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
)
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-modal bg-slate-950/45 backdrop-blur-[2px] data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'relative z-modal grid w-full max-w-lg gap-4 rounded-xl border border-border bg-surface p-6 text-foreground shadow-dialog duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
<div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
)
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement => (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-base font-semibold tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle
}
@@ -0,0 +1,169 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '../../lib/cn'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<span className="ml-auto"></span>
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-dropdown min-w-32 overflow-hidden rounded-md border border-border bg-surface p-1 text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & { sideOffset?: number }
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-dropdown min-w-32 overflow-hidden rounded-md border border-border bg-surface p-1 text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
{checked ? '✓' : ''}
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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"></span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = ({
className,
inset,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}): React.ReactElement => (
<DropdownMenuPrimitive.Label
className={cn(
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
)
const DropdownMenuSeparator = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>): React.ReactElement => (
<DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border-subtle', className)}
{...props}
/>
)
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>): React.ReactElement => (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
)
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
}
@@ -0,0 +1,33 @@
import { cn } from '../../lib/cn'
export function EmptyState({
icon,
title,
description,
action,
className
}: {
icon?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
className?: string
}): React.ReactElement {
return (
<div
className={cn(
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface-muted/50 px-6 py-8 text-center',
className
)}
>
<div className="text-muted-foreground">{icon}</div>
<div className="grid gap-1">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{description ? (
<p className="max-w-sm text-xs text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</div>
)
}
@@ -0,0 +1,30 @@
import * as React from 'react'
import { Button, type ButtonProps } from './button'
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'
export interface IconButtonProps extends Omit<ButtonProps, 'size' | 'children'> {
label: string
children: React.ReactNode
tooltip?: string
}
export function IconButton({
label,
tooltip = label,
children,
...props
}: IconButtonProps): React.ReactElement {
const button = (
<Button aria-label={label} size="icon" {...props}>
{children}
</Button>
)
if (!tooltip) return button
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}
+21
View File
@@ -0,0 +1,21 @@
export * from './alert-dialog'
export * from './button'
export * from './checkbox'
export * from './dialog'
export * from './dropdown-menu'
export * from './empty-state'
export * from './icon-button'
export * from './input'
export * from './popover'
export * from './progress'
export * from './radio-group'
export * from './scroll-area'
export * from './select'
export * from './separator'
export * from './skeleton'
export * from './spinner'
export * from './switch'
export * from './tabs'
export * from './textarea'
export * from './toast'
export * from './tooltip'
+19
View File
@@ -0,0 +1,19 @@
/* eslint-disable react/prop-types */
import * as React from 'react'
import { cn } from '../../lib/cn'
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type = 'text', ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-border bg-surface px-3 py-1 text-sm text-foreground shadow-surface transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
))
Input.displayName = 'Input'
@@ -0,0 +1,29 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '../../lib/cn'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverClose = PopoverPrimitive.Close
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 6, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-popover w-72 rounded-lg border border-border bg-surface p-4 text-foreground shadow-popover outline-none duration-fast ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent }
@@ -0,0 +1,22 @@
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '../../lib/cn'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-transform duration-normal ease-tm-standard"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
@@ -0,0 +1,32 @@
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { cn } from '../../lib/cn'
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root ref={ref} className={cn('grid gap-2', className)} {...props} />
))
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-border bg-surface text-primary shadow-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<span className="h-2 w-2 rounded-full bg-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
))
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,43 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '../../lib/cn'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical'
? 'h-full w-2.5 border-l border-l-transparent p-px'
: 'h-2.5 flex-col border-t border-t-transparent p-px',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+101
View File
@@ -0,0 +1,101 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '../../lib/cn'
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-9 w-full items-center justify-between rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground shadow-surface focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<span aria-hidden="true" className="ml-2 text-muted-foreground">
</span>
</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}
position={position}
className={cn(
'relative z-dropdown max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-surface text-foreground shadow-popover data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
position === 'popper' && 'translate-y-1',
className
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{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('px-2 py-1.5 text-xs font-semibold text-muted-foreground', 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"></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('my-1 h-px bg-border-subtle', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator
}
@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '../../lib/cn'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border-subtle',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -0,0 +1,8 @@
import { cn } from '../../lib/cn'
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
}
@@ -0,0 +1,20 @@
import { cn } from '../../lib/cn'
export function Spinner({
className,
label = '加载中'
}: {
className?: string
label?: string
}): React.ReactElement {
return (
<span
role="status"
aria-label={label}
className={cn(
'inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent',
className
)}
/>
)
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '../../lib/cn'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary',
className
)}
{...props}
>
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '../../lib/cn'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-foreground data-[state=active]:shadow-surface',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
/* eslint-disable react/prop-types */
import * as React from 'react'
import { cn } from '../../lib/cn'
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex min-h-20 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground shadow-surface transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
))
Textarea.displayName = 'Textarea'
+157
View File
@@ -0,0 +1,157 @@
import * as React from 'react'
import * as ToastPrimitive from '@radix-ui/react-toast'
import { cn } from '../../lib/cn'
type ToastVariant = 'default' | 'success' | 'warning' | 'destructive'
type ToastItem = {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
variant?: ToastVariant
duration?: number
}
type ToastContextValue = {
toast: (input: Omit<ToastItem, 'id'>) => string
dismiss: (id?: string) => void
}
const ToastContext = React.createContext<ToastContextValue | null>(null)
const variantClassNames: Record<ToastVariant, string> = {
default: 'border-border bg-surface text-foreground',
success: 'border-success/40 bg-surface text-foreground',
warning: 'border-warning/40 bg-surface text-foreground',
destructive: 'border-destructive/45 bg-surface text-foreground'
}
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & { variant?: ToastVariant }
>(({ className, variant = 'default', ...props }, ref) => (
<ToastPrimitive.Root
ref={ref}
className={cn(
'group pointer-events-auto relative flex w-full items-start justify-between gap-3 overflow-hidden rounded-lg border p-4 shadow-floating duration-normal ease-tm-emphasized data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full data-[state=open]:animate-in data-[state=open]:slide-in-from-top-full',
variantClassNames[variant],
className
)}
{...props}
/>
))
Toast.displayName = ToastPrimitive.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitive.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Close
ref={ref}
className={cn(
'rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100',
className
)}
{...props}
>
×
</ToastPrimitive.Close>
))
ToastClose.displayName = ToastPrimitive.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
))
ToastTitle.displayName = ToastPrimitive.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Description
ref={ref}
className={cn('text-xs text-muted-foreground', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitive.Description.displayName
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitive.Viewport
ref={ref}
className={cn(
'fixed right-0 top-0 z-toast flex max-h-screen w-full flex-col gap-2 p-4 sm:bottom-0 sm:top-auto sm:max-w-sm',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitive.Viewport.displayName
export function ToastProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [items, setItems] = React.useState<ToastItem[]>([])
const dismiss = React.useCallback((id?: string) => {
setItems((current) => (id ? current.filter((item) => item.id !== id) : []))
}, [])
const toast = React.useCallback((input: Omit<ToastItem, 'id'>): string => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
setItems((current) => [...current.slice(-2), { ...input, id }])
return id
}, [])
return (
<ToastContext.Provider value={{ toast, dismiss }}>
<ToastPrimitive.Provider swipeDirection="right">
{children}
{items.map((item) => (
<Toast
key={item.id}
variant={item.variant}
duration={item.duration}
onOpenChange={(open) => !open && dismiss(item.id)}
>
<div className="grid gap-1">
{item.title ? <ToastTitle>{item.title}</ToastTitle> : null}
{item.description ? <ToastDescription>{item.description}</ToastDescription> : null}
</div>
{item.action}
<ToastClose aria-label="关闭通知" />
</Toast>
))}
<ToastViewport />
</ToastPrimitive.Provider>
</ToastContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useToast(): ToastContextValue {
const context = React.useContext(ToastContext)
if (!context) throw new Error('useToast must be used within ToastProvider')
return context
}
export { Toast, ToastAction, ToastClose, ToastTitle, ToastDescription, ToastViewport }
@@ -0,0 +1,27 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '../../lib/cn'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 5, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-popover rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-floating duration-fast ease-tm-standard data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { AccountSummary } from '../../../components/account/AccountSummary' import { AccountSummary } from '../../../components/account/AccountSummary'
import { Button, Input } from '../../../components/ui'
import { SETTINGS_NAVIGATION } from '../model/settingsNavigation' import { SETTINGS_NAVIGATION } from '../model/settingsNavigation'
import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types' import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types'
@@ -34,7 +35,8 @@ export function SettingsSidebar({
<p></p> <p></p>
<label className="settings-search"> <label className="settings-search">
<span></span> <span></span>
<input <Input
className="settings-search-input"
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索设置" placeholder="搜索设置"
@@ -46,14 +48,16 @@ export function SettingsSidebar({
<section key={group.label}> <section key={group.label}>
<h2>{group.label}</h2> <h2>{group.label}</h2>
{group.items.map((item) => ( {group.items.map((item) => (
<button <Button
variant="ghost"
size="sm"
type="button" type="button"
key={item.id} key={item.id}
className={selectedId === item.id ? 'active' : ''} className={selectedId === item.id ? 'active' : ''}
onClick={() => onSelect(item.id)} onClick={() => onSelect(item.id)}
> >
{item.label} {item.label}
</button> </Button>
))} ))}
</section> </section>
))} ))}
@@ -1,4 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../../../components/ui'
export function DatabaseKeyDangerZone({ export function DatabaseKeyDangerZone({
disabled, disabled,
@@ -22,9 +33,9 @@ export function DatabaseKeyDangerZone({
<strong></strong> <strong></strong>
<small></small> <small></small>
</span> </span>
<button type="button" onClick={() => setConfirmingReturn(true)}> <Button type="button" variant="outline" onClick={() => setConfirmingReturn(true)}>
</button> </Button>
</div> </div>
</section> </section>
<section className="database-key-danger"> <section className="database-key-danger">
@@ -34,91 +45,60 @@ export function DatabaseKeyDangerZone({
<strong></strong> <strong></strong>
<small></small> <small></small>
</span> </span>
<button type="button" onClick={() => setConfirming(true)} disabled={disabled}> <Button
type="button"
variant="destructive"
onClick={() => setConfirming(true)}
disabled={disabled}
>
</button> </Button>
</div> </div>
<div> <div>
<span> <span>
<strong></strong> <strong></strong>
<small></small> <small></small>
</span> </span>
<button type="button" onClick={onReplace} disabled={disabled}> <Button type="button" variant="outline" onClick={onReplace} disabled={disabled}>
</button> </Button>
</div> </div>
</section> </section>
{confirming && ( <AlertDialog open={confirming} onOpenChange={setConfirming}>
<div <AlertDialogContent>
className="database-key-confirm-backdrop" <AlertDialogHeader>
role="presentation" <AlertDialogTitle></AlertDialogTitle>
onMouseDown={() => setConfirming(false)} <AlertDialogDescription>
>
<div
className="database-key-confirm"
role="dialog"
aria-modal="true"
aria-labelledby="database-key-confirm-title"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 id="database-key-confirm-title"></h2>
<p>
TraceMemo TraceMemo
</p> </AlertDialogDescription>
<div> </AlertDialogHeader>
<button type="button" onClick={() => setConfirming(false)}> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
</button> <AlertDialogAction
<button className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
type="button" onClick={onClear}
className="danger"
onClick={() => {
setConfirming(false)
onClear()
}}
> >
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
)} <AlertDialog open={confirmingReturn} onOpenChange={setConfirmingReturn}>
{confirmingReturn && ( <AlertDialogContent>
<div <AlertDialogHeader>
className="database-key-confirm-backdrop" <AlertDialogTitle></AlertDialogTitle>
role="presentation" <AlertDialogDescription>
onMouseDown={() => setConfirmingReturn(false)}
>
<div
className="database-key-confirm"
role="dialog"
aria-modal="true"
aria-labelledby="database-key-return-confirm-title"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 id="database-key-return-confirm-title"></h2>
<p>
TraceMemo TraceMemo
</p> </AlertDialogDescription>
<div> </AlertDialogHeader>
<button type="button" onClick={() => setConfirmingReturn(false)}> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
</button> <AlertDialogAction onClick={onReturnToLogin}></AlertDialogAction>
<button </AlertDialogFooter>
type="button" </AlertDialogContent>
onClick={() => { </AlertDialog>
setConfirmingReturn(false)
onReturnToLogin()
}}
>
</button>
</div>
</div>
</div>
)}
</> </>
) )
} }
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, IconButton, Input } from '../../../components/ui'
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement { function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
return visible ? ( return visible ? (
@@ -35,7 +36,7 @@ export function DatabaseKeyEditor({
<section id="database-key-editor" className="settings-card database-key-editor"> <section id="database-key-editor" className="settings-card database-key-editor">
<label htmlFor="wechat-db-key">WeChat DB Key</label> <label htmlFor="wechat-db-key">WeChat DB Key</label>
<div className="database-key-input-row"> <div className="database-key-input-row">
<input <Input
id="wechat-db-key" id="wechat-db-key"
type={visible ? 'text' : 'password'} type={visible ? 'text' : 'password'}
value={value} value={value}
@@ -45,48 +46,59 @@ export function DatabaseKeyEditor({
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
<button <IconButton
variant="ghost"
label={visible ? '隐藏密钥' : '显示密钥'}
tooltip={visible ? '隐藏密钥' : '显示密钥'}
type="button" type="button"
onClick={() => setVisible((current) => !current)} onClick={() => setVisible((current) => !current)}
title={visible ? '隐藏密钥' : '显示密钥'}
disabled={disabled} disabled={disabled}
> >
<EyeIcon visible={visible} /> <EyeIcon visible={visible} />
</button> </IconButton>
<button type="button" onClick={onPaste} title="从剪贴板粘贴" disabled={disabled}> <IconButton
type="button"
variant="ghost"
label="从剪贴板粘贴"
onClick={onPaste}
disabled={disabled}
>
<svg viewBox="0 0 24 24" aria-hidden> <svg viewBox="0 0 24 24" aria-hidden>
<path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" /> <path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" />
</svg> </svg>
</button> </IconButton>
<button <IconButton
variant="ghost"
label="清空当前输入"
type="button" type="button"
onClick={() => onChange('')} onClick={() => onChange('')}
title="清空当前输入"
disabled={disabled || !value} disabled={disabled || !value}
> >
<svg viewBox="0 0 24 24" aria-hidden> <svg viewBox="0 0 24 24" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" /> <path d="m6 6 12 12M18 6 6 18" />
</svg> </svg>
</button> </IconButton>
</div> </div>
<p></p> <p></p>
<div className="database-key-actions"> <div className="database-key-actions">
<button <Button
variant="secondary"
type="button" type="button"
className="database-key-secondary" className="database-key-secondary"
onClick={onValidate} onClick={onValidate}
disabled={disabled || !value} disabled={disabled || !value}
> >
</button> </Button>
<button <Button
variant="default"
type="button" type="button"
className="database-key-primary" className="database-key-primary"
onClick={onSave} onClick={onSave}
disabled={disabled || !canSave} disabled={disabled || !canSave}
> >
</button> </Button>
</div> </div>
</section> </section>
) )
@@ -1,4 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../../../components/ui'
export function DangerZone({ export function DangerZone({
disabled, disabled,
@@ -17,31 +28,30 @@ export function DangerZone({
<strong></strong> <strong></strong>
<small></small> <small></small>
</span> </span>
<button disabled={disabled} onClick={() => setConfirming(true)}> <Button variant="destructive" disabled={disabled} onClick={() => setConfirming(true)}>
</button> </Button>
</div> </div>
</section> </section>
{confirming ? ( <AlertDialog open={confirming} onOpenChange={setConfirming}>
<div className="database-key-confirm-backdrop" role="presentation"> <AlertDialogContent>
<div className="database-key-confirm" role="dialog" aria-modal="true"> <AlertDialogHeader>
<h2></h2> <AlertDialogTitle></AlertDialogTitle>
<p></p> <AlertDialogDescription>
<div>
<button onClick={() => setConfirming(false)}></button> </AlertDialogDescription>
<button </AlertDialogHeader>
className="danger" <AlertDialogFooter>
onClick={() => { <AlertDialogCancel></AlertDialogCancel>
setConfirming(false) <AlertDialogAction
onClear() className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
}} onClick={onClear}
> >
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
) : null}
</> </>
) )
} }
@@ -1,4 +1,5 @@
import type { ImageDecryptionState } from './types' import type { ImageDecryptionState } from './types'
import { Input } from '../../../components/ui'
export function ImageKeyConfiguration({ export function ImageKeyConfiguration({
state, state,
@@ -14,7 +15,7 @@ export function ImageKeyConfiguration({
<div className="image-key-grid"> <div className="image-key-grid">
<label> <label>
<span>XOR Key</span> <span>XOR Key</span>
<input <Input
value={state.xorKey} value={state.xorKey}
disabled={disabled} disabled={disabled}
onChange={(event) => onEdit('xorKey', event.target.value)} onChange={(event) => onEdit('xorKey', event.target.value)}
@@ -22,7 +23,7 @@ export function ImageKeyConfiguration({
</label> </label>
<label> <label>
<span>AES Key</span> <span>AES Key</span>
<input <Input
type="password" type="password"
value={state.aesKey} value={state.aesKey}
disabled={disabled} disabled={disabled}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { RadioGroup, RadioGroupItem, Switch } from '../../../components/ui'
export type AppearanceTheme = 'system' | 'light' | 'dark' export type AppearanceTheme = 'system' | 'light' | 'dark'
@@ -20,7 +21,10 @@ export function AppearancePage({
setTheme(result.settings.appearanceTheme) setTheme(result.settings.appearanceTheme)
setCompactMode(result.settings.compactMode) setCompactMode(result.settings.compactMode)
setShowStartupProgress(result.settings.showStartupProgress) setShowStartupProgress(result.settings.showStartupProgress)
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode }) onAppearanceChange({
theme: result.settings.appearanceTheme,
compactMode: result.settings.compactMode
})
}) })
return () => { return () => {
active = false active = false
@@ -36,7 +40,10 @@ export function AppearancePage({
setTheme(result.settings.appearanceTheme) setTheme(result.settings.appearanceTheme)
setCompactMode(result.settings.compactMode) setCompactMode(result.settings.compactMode)
setShowStartupProgress(result.settings.showStartupProgress) setShowStartupProgress(result.settings.showStartupProgress)
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode }) onAppearanceChange({
theme: result.settings.appearanceTheme,
compactMode: result.settings.compactMode
})
onNotice('外观设置已保存') onNotice('外观设置已保存')
} }
@@ -52,29 +59,63 @@ export function AppearancePage({
<div className="settings-page-content"> <div className="settings-page-content">
<h2 className="settings-section-heading"></h2> <h2 className="settings-section-heading"></h2>
<section className="settings-card settings-option-card"> <section className="settings-card settings-option-card">
<div className="settings-choice-grid"> <RadioGroup
{([ className="settings-choice-grid"
value={theme}
onValueChange={(value) => {
if (value === 'system' || value === 'light' || value === 'dark')
void save({ appearanceTheme: value })
}}
>
{(
[
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'], ['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
['light', '浅色', '保持当前清爽的浅色工作区'], ['light', '浅色', '保持当前清爽的浅色工作区'],
['dark', '深色', '降低夜间浏览时的亮度'] ['dark', '深色', '降低夜间浏览时的亮度']
] as const).map(([value, label, hint]) => ( ] as const
<label className={`settings-choice ${theme === value ? 'active' : ''}`} key={value}> ).map(([value, label, hint]) => (
<input type="radio" name="appearance-theme" checked={theme === value} onChange={() => void save({ appearanceTheme: value })} /> <label
<span><b>{label}</b><small>{hint}</small></span> className={`settings-choice ${theme === value ? 'active' : ''}`}
key={value}
htmlFor={`appearance-theme-${value}`}
>
<RadioGroupItem
id={`appearance-theme-${value}`}
value={value}
className="mt-0.5"
/>
<span>
<b>{label}</b>
<small>{hint}</small>
</span>
</label> </label>
))} ))}
</div> </RadioGroup>
</section> </section>
<h2 className="settings-section-heading"></h2> <h2 className="settings-section-heading"></h2>
<section className="settings-card settings-toggle-list"> <section className="settings-card settings-toggle-list">
<label className="settings-toggle-row"> <label className="settings-toggle-row">
<span><b></b><small></small></span> <span>
<input type="checkbox" checked={compactMode} onChange={(event) => void save({ compactMode: event.target.checked })} /> <b></b>
<small></small>
</span>
<Switch
checked={compactMode}
onCheckedChange={(checked) => void save({ compactMode: checked })}
aria-label="紧凑布局"
/>
</label> </label>
<label className="settings-toggle-row"> <label className="settings-toggle-row">
<span><b></b><small></small></span> <span>
<input type="checkbox" checked={showStartupProgress} onChange={(event) => void save({ showStartupProgress: event.target.checked })} /> <b></b>
<small></small>
</span>
<Switch
checked={showStartupProgress}
onCheckedChange={(checked) => void save({ showStartupProgress: checked })}
aria-label="显示启动进度"
/>
</label> </label>
</section> </section>
</div> </div>
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+6
View File
@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { ToastProvider, TooltipProvider } from './components/ui'
import './styles/tailwind.css'
import './styles/index.scss' import './styles/index.scss'
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
@@ -36,6 +38,10 @@ window.addEventListener('unhandledrejection', (event) => {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<TooltipProvider>
<ToastProvider>
<App /> <App />
</ToastProvider>
</TooltipProvider>
</React.StrictMode> </React.StrictMode>
) )
+114
View File
@@ -0,0 +1,114 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--tm-background: 150 18% 96%;
--tm-foreground: 150 12% 15%;
--tm-surface: 0 0% 100%;
--tm-surface-muted: 150 14% 94%;
--tm-surface-elevated: 0 0% 100%;
--tm-border: 150 13% 87%;
--tm-border-subtle: 150 10% 92%;
--tm-primary: 161 54% 31%;
--tm-primary-foreground: 0 0% 100%;
--tm-secondary: 150 13% 91%;
--tm-secondary-foreground: 150 12% 20%;
--tm-muted: 150 11% 92%;
--tm-muted-foreground: 150 8% 44%;
--tm-accent: 157 39% 89%;
--tm-accent-foreground: 161 54% 25%;
--tm-success: 155 51% 37%;
--tm-warning: 34 57% 49%;
--tm-destructive: 0 51% 54%;
--tm-destructive-foreground: 0 0% 100%;
--tm-focus-ring: 161 54% 38%;
--tm-radius-sm: 0.375rem;
--tm-radius-md: 0.5rem;
--tm-radius-lg: 0.75rem;
--tm-radius-xl: 1rem;
--tm-shadow-surface: 0 1px 2px rgb(15 23 42 / 0.04);
--tm-shadow-floating: 0 8px 24px rgb(15 23 42 / 0.1);
--tm-shadow-popover: 0 12px 32px rgb(15 23 42 / 0.14);
--tm-shadow-dialog: 0 24px 80px rgb(15 23 42 / 0.2);
--tm-motion-fast: 120ms;
--tm-motion-normal: 180ms;
--tm-motion-slow: 260ms;
--tm-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--tm-ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--tm-z-base: 0;
--tm-z-dropdown: 40;
--tm-z-popover: 50;
--tm-z-toast: 70;
--tm-z-modal: 60;
}
[data-theme='dark'] {
--tm-background: 150 15% 10%;
--tm-foreground: 145 25% 94%;
--tm-surface: 150 13% 15%;
--tm-surface-muted: 150 12% 18%;
--tm-surface-elevated: 150 12% 19%;
--tm-border: 150 12% 28%;
--tm-border-subtle: 150 10% 22%;
--tm-primary: 157 48% 52%;
--tm-primary-foreground: 150 15% 10%;
--tm-secondary: 150 12% 24%;
--tm-secondary-foreground: 145 25% 94%;
--tm-muted: 150 12% 24%;
--tm-muted-foreground: 150 10% 66%;
--tm-accent: 157 31% 25%;
--tm-accent-foreground: 157 48% 82%;
--tm-success: 155 51% 55%;
--tm-warning: 34 75% 63%;
--tm-destructive: 0 65% 66%;
--tm-destructive-foreground: 150 15% 10%;
--tm-focus-ring: 157 48% 62%;
--tm-shadow-surface: 0 1px 2px rgb(0 0 0 / 0.2);
--tm-shadow-floating: 0 8px 24px rgb(0 0 0 / 0.28);
--tm-shadow-popover: 0 12px 32px rgb(0 0 0 / 0.38);
--tm-shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.48);
}
@media (prefers-color-scheme: dark) {
[data-theme='system'] {
--tm-background: 150 15% 10%;
--tm-foreground: 145 25% 94%;
--tm-surface: 150 13% 15%;
--tm-surface-muted: 150 12% 18%;
--tm-surface-elevated: 150 12% 19%;
--tm-border: 150 12% 28%;
--tm-border-subtle: 150 10% 22%;
--tm-primary: 157 48% 52%;
--tm-primary-foreground: 150 15% 10%;
--tm-secondary: 150 12% 24%;
--tm-secondary-foreground: 145 25% 94%;
--tm-muted: 150 12% 24%;
--tm-muted-foreground: 150 10% 66%;
--tm-accent: 157 31% 25%;
--tm-accent-foreground: 157 48% 82%;
--tm-success: 155 51% 55%;
--tm-warning: 34 75% 63%;
--tm-destructive: 0 65% 66%;
--tm-destructive-foreground: 150 15% 10%;
--tm-focus-ring: 157 48% 62%;
}
}
:where(button, input, textarea, select):focus-visible {
outline: 2px solid hsl(var(--tm-focus-ring) / 0.75);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', '[data-theme="dark"]'],
content: ['./src/renderer/src/**/*.{js,ts,jsx,tsx}'],
corePlugins: {
// Keep the existing SCSS application stable while new UI code adopts Tailwind incrementally.
preflight: false
},
theme: {
extend: {
colors: {
background: 'hsl(var(--tm-background) / <alpha-value>)',
foreground: 'hsl(var(--tm-foreground) / <alpha-value>)',
surface: 'hsl(var(--tm-surface) / <alpha-value>)',
'surface-muted': 'hsl(var(--tm-surface-muted) / <alpha-value>)',
'surface-elevated': 'hsl(var(--tm-surface-elevated) / <alpha-value>)',
border: 'hsl(var(--tm-border) / <alpha-value>)',
'border-subtle': 'hsl(var(--tm-border-subtle) / <alpha-value>)',
primary: 'hsl(var(--tm-primary) / <alpha-value>)',
'primary-foreground': 'hsl(var(--tm-primary-foreground) / <alpha-value>)',
secondary: 'hsl(var(--tm-secondary) / <alpha-value>)',
'secondary-foreground': 'hsl(var(--tm-secondary-foreground) / <alpha-value>)',
muted: 'hsl(var(--tm-muted) / <alpha-value>)',
'muted-foreground': 'hsl(var(--tm-muted-foreground) / <alpha-value>)',
accent: 'hsl(var(--tm-accent) / <alpha-value>)',
'accent-foreground': 'hsl(var(--tm-accent-foreground) / <alpha-value>)',
success: 'hsl(var(--tm-success) / <alpha-value>)',
warning: 'hsl(var(--tm-warning) / <alpha-value>)',
destructive: 'hsl(var(--tm-destructive) / <alpha-value>)',
'destructive-foreground': 'hsl(var(--tm-destructive-foreground) / <alpha-value>)',
ring: 'hsl(var(--tm-focus-ring) / <alpha-value>)'
},
borderRadius: {
sm: 'var(--tm-radius-sm)',
md: 'var(--tm-radius-md)',
lg: 'var(--tm-radius-lg)',
xl: 'var(--tm-radius-xl)'
},
boxShadow: {
surface: 'var(--tm-shadow-surface)',
floating: 'var(--tm-shadow-floating)',
popover: 'var(--tm-shadow-popover)',
dialog: 'var(--tm-shadow-dialog)'
},
transitionDuration: {
fast: 'var(--tm-motion-fast)',
normal: 'var(--tm-motion-normal)',
slow: 'var(--tm-motion-slow)'
},
transitionTimingFunction: {
'tm-standard': 'var(--tm-ease-standard)',
'tm-emphasized': 'var(--tm-ease-emphasized)'
},
zIndex: {
base: 'var(--tm-z-base)',
dropdown: 'var(--tm-z-dropdown)',
popover: 'var(--tm-z-popover)',
toast: 'var(--tm-z-toast)',
modal: 'var(--tm-z-modal)'
}
}
},
plugins: [require('tailwindcss-animate')]
}
@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AppearancePage } from '../../src/renderer/src/features/settings/pages/AppearancePage'
describe('AppearancePage UI pilot', () => {
it('uses the shared radio and switch controls without changing settings semantics', async () => {
const user = userEvent.setup()
const onNotice = vi.fn()
const onAppearanceChange = vi.fn()
const settings = {
appearanceTheme: 'system' as const,
compactMode: false,
showStartupProgress: true
}
const api = {
getSettings: vi.fn(async () => ({ settings })),
setSettings: vi.fn(async (patch: Partial<typeof settings>) => ({
settings: Object.assign(settings, patch)
}))
}
Object.defineProperty(window, 'api', { configurable: true, value: api })
render(<AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />)
expect(await screen.findByRole('heading', { name: '外观与行为' })).toBeVisible()
await user.click(screen.getByRole('radio', { name: /深色/ }))
await user.click(screen.getByRole('switch', { name: '紧凑布局' }))
expect(api.setSettings).toHaveBeenCalledWith({ appearanceTheme: 'dark' })
expect(api.setSettings).toHaveBeenCalledWith({ compactMode: true })
expect(onNotice).toHaveBeenCalledWith('外观设置已保存')
})
})
@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import {
Button,
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
Input,
Popover,
PopoverContent,
PopoverTrigger,
ToastProvider,
TooltipProvider,
useToast
} from '../../src/renderer/src/components/ui'
function ToastDemo(): React.ReactElement {
const { toast } = useToast()
return (
<Button onClick={() => toast({ title: '已保存', description: '设置已更新' })}></Button>
)
}
describe('TraceMemo UI infrastructure', () => {
it('restores focus after a dialog is closed with Escape', async () => {
const user = userEvent.setup()
render(
<Dialog>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogTitle></DialogTitle>
<Input aria-label="测试输入" />
</DialogContent>
</Dialog>
)
const trigger = screen.getByRole('button', { name: '打开对话框' })
await user.click(trigger)
expect(screen.getByRole('dialog', { name: '测试对话框' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '测试对话框' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('closes a popover when the user clicks outside', async () => {
const user = userEvent.setup()
render(
<Popover>
<PopoverTrigger asChild>
<Button></Button>
</PopoverTrigger>
<PopoverContent>
<p></p>
</PopoverContent>
</Popover>
)
await user.click(screen.getByRole('button', { name: '打开菜单' }))
expect(screen.getByText('菜单内容')).toBeVisible()
await user.click(document.body)
expect(screen.queryByText('菜单内容')).not.toBeInTheDocument()
})
it('renders toast notifications through the shared provider', async () => {
const user = userEvent.setup()
render(
<TooltipProvider>
<ToastProvider>
<ToastDemo />
</ToastProvider>
</TooltipProvider>
)
await user.click(screen.getByRole('button', { name: '显示通知' }))
expect(screen.getByText('已保存')).toBeVisible()
expect(screen.getByText('设置已更新')).toBeVisible()
})
})