mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 新增组件库
This commit is contained in:
@@ -38,6 +38,7 @@ import { enrichQuotedMessages } from './utils/quoted-messages'
|
||||
import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
import { runtimePlatform } from './utils/runtime-environment'
|
||||
import { useToast } from './components/ui'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -214,6 +215,7 @@ const buildSyntheticGroupMessages = (
|
||||
void buildSyntheticGroupMessages
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const { toast } = useToast()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||
const [isDatabaseConnecting, setIsDatabaseConnecting] = useState(false)
|
||||
@@ -328,9 +330,9 @@ function App(): React.ReactElement {
|
||||
messagesRef.current = messages
|
||||
React.useEffect(() => {
|
||||
if (!reportNotice) return
|
||||
const timer = window.setTimeout(() => setReportNotice(''), 3200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [reportNotice])
|
||||
toast({ description: reportNotice, duration: 3200 })
|
||||
setReportNotice('')
|
||||
}, [reportNotice, toast])
|
||||
React.useEffect(() => {
|
||||
const openVoiceRecognitionSettings = (): void => {
|
||||
setSettingsCategory('voice-recognition')
|
||||
@@ -2111,7 +2113,6 @@ function App(): React.ReactElement {
|
||||
appearanceTheme={appearanceSettings.theme}
|
||||
compactMode={appearanceSettings.compactMode}
|
||||
>
|
||||
{reportNotice && <div className="app-toast">{reportNotice}</div>}
|
||||
{showFirstUseWelcome && (
|
||||
<FirstUseWelcome
|
||||
onDismiss={dismissFirstUseWelcome}
|
||||
|
||||
@@ -44,6 +44,13 @@ export function AppShell({
|
||||
compactMode = false,
|
||||
children
|
||||
}: 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)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -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 { AccountSummary } from '../../../components/account/AccountSummary'
|
||||
import { Button, Input } from '../../../components/ui'
|
||||
import { SETTINGS_NAVIGATION } from '../model/settingsNavigation'
|
||||
import type { SettingsCategoryId, SettingsSelfInfo } from '../model/types'
|
||||
|
||||
@@ -34,7 +35,8 @@ export function SettingsSidebar({
|
||||
<p>管理账号、数据连接和本地能力</p>
|
||||
<label className="settings-search">
|
||||
<span>⌕</span>
|
||||
<input
|
||||
<Input
|
||||
className="settings-search-input"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索设置"
|
||||
@@ -46,14 +48,16 @@ export function SettingsSidebar({
|
||||
<section key={group.label}>
|
||||
<h2>{group.label}</h2>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={selectedId === item.id ? 'active' : ''}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button
|
||||
} from '../../../components/ui'
|
||||
|
||||
export function DatabaseKeyDangerZone({
|
||||
disabled,
|
||||
@@ -22,9 +33,9 @@ export function DatabaseKeyDangerZone({
|
||||
<strong>返回登录界面</strong>
|
||||
<small>断开当前数据库连接,回到密钥输入界面。不会删除已保存密钥或微信数据。</small>
|
||||
</span>
|
||||
<button type="button" onClick={() => setConfirmingReturn(true)}>
|
||||
<Button type="button" variant="outline" onClick={() => setConfirmingReturn(true)}>
|
||||
返回登录
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="database-key-danger">
|
||||
@@ -34,91 +45,60 @@ export function DatabaseKeyDangerZone({
|
||||
<strong>清除已保存密钥</strong>
|
||||
<small>从系统安全存储中删除密钥,不会删除微信数据库文件。</small>
|
||||
</span>
|
||||
<button type="button" onClick={() => setConfirming(true)} disabled={disabled}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
清除密钥
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<strong>替换当前密钥</strong>
|
||||
<small>回到编辑区输入并验证新的数据库密钥。</small>
|
||||
</span>
|
||||
<button type="button" onClick={onReplace} disabled={disabled}>
|
||||
<Button type="button" variant="outline" onClick={onReplace} disabled={disabled}>
|
||||
更换密钥
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{confirming && (
|
||||
<div
|
||||
className="database-key-confirm-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={() => setConfirming(false)}
|
||||
>
|
||||
<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>
|
||||
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认清除数据库密钥?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
清除后 TraceMemo
|
||||
将暂时无法读取聊天记录,需要重新输入或获取密钥。该操作不会删除微信原始数据。
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" onClick={() => setConfirming(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
onClear()
|
||||
}}
|
||||
>
|
||||
清除密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmingReturn && (
|
||||
<div
|
||||
className="database-key-confirm-backdrop"
|
||||
role="presentation"
|
||||
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>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onClear}
|
||||
>
|
||||
清除密钥
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog open={confirmingReturn} onOpenChange={setConfirmingReturn}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>返回登录界面?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
TraceMemo
|
||||
将断开当前数据库连接并回到密钥输入界面。已保存的数据库密钥和微信原始数据不会被删除。
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" onClick={() => setConfirmingReturn(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmingReturn(false)
|
||||
onReturnToLogin()
|
||||
}}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onReturnToLogin}>返回登录</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, IconButton, Input } from '../../../components/ui'
|
||||
|
||||
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||
return visible ? (
|
||||
@@ -35,7 +36,7 @@ export function DatabaseKeyEditor({
|
||||
<section id="database-key-editor" className="settings-card database-key-editor">
|
||||
<label htmlFor="wechat-db-key">WeChat DB Key</label>
|
||||
<div className="database-key-input-row">
|
||||
<input
|
||||
<Input
|
||||
id="wechat-db-key"
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value}
|
||||
@@ -45,48 +46,59 @@ export function DatabaseKeyEditor({
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label={visible ? '隐藏密钥' : '显示密钥'}
|
||||
tooltip={visible ? '隐藏密钥' : '显示密钥'}
|
||||
type="button"
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
title={visible ? '隐藏密钥' : '显示密钥'}
|
||||
disabled={disabled}
|
||||
>
|
||||
<EyeIcon visible={visible} />
|
||||
</button>
|
||||
<button type="button" onClick={onPaste} title="从剪贴板粘贴" disabled={disabled}>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
label="从剪贴板粘贴"
|
||||
onClick={onPaste}
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M9 5h6M9 3h6v4H9zM7 5H5v16h14V5h-2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
label="清空当前输入"
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
title="清空当前输入"
|
||||
disabled={disabled || !value}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
<p>保存前会验证密钥格式、数据库可读性和当前账号身份。</p>
|
||||
<div className="database-key-actions">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
className="database-key-secondary"
|
||||
onClick={onValidate}
|
||||
disabled={disabled || !value}
|
||||
>
|
||||
验证密钥
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
type="button"
|
||||
className="database-key-primary"
|
||||
onClick={onSave}
|
||||
disabled={disabled || !canSave}
|
||||
>
|
||||
保存密钥
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button
|
||||
} from '../../../components/ui'
|
||||
|
||||
export function DangerZone({
|
||||
disabled,
|
||||
@@ -17,31 +28,30 @@ export function DangerZone({
|
||||
<strong>清除图片密钥</strong>
|
||||
<small>聊天记录和微信原始图片不会被删除。</small>
|
||||
</span>
|
||||
<button disabled={disabled} onClick={() => setConfirming(true)}>
|
||||
<Button variant="destructive" disabled={disabled} onClick={() => setConfirming(true)}>
|
||||
清除图片密钥
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{confirming ? (
|
||||
<div className="database-key-confirm-backdrop" role="presentation">
|
||||
<div className="database-key-confirm" role="dialog" aria-modal="true">
|
||||
<h2>确认清除图片解密配置?</h2>
|
||||
<p>清除后聊天记录仍然存在,但图片需要重新配置后才能解析。</p>
|
||||
<div>
|
||||
<button onClick={() => setConfirming(false)}>取消</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
onClear()
|
||||
}}
|
||||
>
|
||||
清除图片密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认清除图片解密配置?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
清除后聊天记录仍然存在,但图片需要重新配置后才能解析。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onClear}
|
||||
>
|
||||
清除图片密钥
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
import { Input } from '../../../components/ui'
|
||||
|
||||
export function ImageKeyConfiguration({
|
||||
state,
|
||||
@@ -14,7 +15,7 @@ export function ImageKeyConfiguration({
|
||||
<div className="image-key-grid">
|
||||
<label>
|
||||
<span>XOR Key</span>
|
||||
<input
|
||||
<Input
|
||||
value={state.xorKey}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onEdit('xorKey', event.target.value)}
|
||||
@@ -22,7 +23,7 @@ export function ImageKeyConfiguration({
|
||||
</label>
|
||||
<label>
|
||||
<span>AES Key</span>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
value={state.aesKey}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RadioGroup, RadioGroupItem, Switch } from '../../../components/ui'
|
||||
|
||||
export type AppearanceTheme = 'system' | 'light' | 'dark'
|
||||
|
||||
@@ -20,7 +21,10 @@ export function AppearancePage({
|
||||
setTheme(result.settings.appearanceTheme)
|
||||
setCompactMode(result.settings.compactMode)
|
||||
setShowStartupProgress(result.settings.showStartupProgress)
|
||||
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
||||
onAppearanceChange({
|
||||
theme: result.settings.appearanceTheme,
|
||||
compactMode: result.settings.compactMode
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
@@ -36,7 +40,10 @@ export function AppearancePage({
|
||||
setTheme(result.settings.appearanceTheme)
|
||||
setCompactMode(result.settings.compactMode)
|
||||
setShowStartupProgress(result.settings.showStartupProgress)
|
||||
onAppearanceChange({ theme: result.settings.appearanceTheme, compactMode: result.settings.compactMode })
|
||||
onAppearanceChange({
|
||||
theme: result.settings.appearanceTheme,
|
||||
compactMode: result.settings.compactMode
|
||||
})
|
||||
onNotice('外观设置已保存')
|
||||
}
|
||||
|
||||
@@ -52,29 +59,63 @@ export function AppearancePage({
|
||||
<div className="settings-page-content">
|
||||
<h2 className="settings-section-heading">显示主题</h2>
|
||||
<section className="settings-card settings-option-card">
|
||||
<div className="settings-choice-grid">
|
||||
{([
|
||||
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
|
||||
['light', '浅色', '保持当前清爽的浅色工作区'],
|
||||
['dark', '深色', '降低夜间浏览时的亮度']
|
||||
] as const).map(([value, label, hint]) => (
|
||||
<label className={`settings-choice ${theme === value ? 'active' : ''}`} key={value}>
|
||||
<input type="radio" name="appearance-theme" checked={theme === value} onChange={() => void save({ appearanceTheme: value })} />
|
||||
<span><b>{label}</b><small>{hint}</small></span>
|
||||
<RadioGroup
|
||||
className="settings-choice-grid"
|
||||
value={theme}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'system' || value === 'light' || value === 'dark')
|
||||
void save({ appearanceTheme: value })
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
['system', '跟随系统', '根据 macOS 或 Windows 外观自动切换'],
|
||||
['light', '浅色', '保持当前清爽的浅色工作区'],
|
||||
['dark', '深色', '降低夜间浏览时的亮度']
|
||||
] as const
|
||||
).map(([value, label, hint]) => (
|
||||
<label
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">工作区行为</h2>
|
||||
<section className="settings-card settings-toggle-list">
|
||||
<label className="settings-toggle-row">
|
||||
<span><b>紧凑布局</b><small>减少导航栏和列表的留白,适合较小窗口。</small></span>
|
||||
<input type="checkbox" checked={compactMode} onChange={(event) => void save({ compactMode: event.target.checked })} />
|
||||
<span>
|
||||
<b>紧凑布局</b>
|
||||
<small>减少导航栏和列表的留白,适合较小窗口。</small>
|
||||
</span>
|
||||
<Switch
|
||||
checked={compactMode}
|
||||
onCheckedChange={(checked) => void save({ compactMode: checked })}
|
||||
aria-label="紧凑布局"
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-toggle-row">
|
||||
<span><b>显示启动进度</b><small>启动或自动连接数据库时显示详细进度。</small></span>
|
||||
<input type="checkbox" checked={showStartupProgress} onChange={(event) => void save({ showStartupProgress: event.target.checked })} />
|
||||
<span>
|
||||
<b>显示启动进度</b>
|
||||
<small>启动或自动连接数据库时显示详细进度。</small>
|
||||
</span>
|
||||
<Switch
|
||||
checked={showStartupProgress}
|
||||
onCheckedChange={(checked) => void save({ showStartupProgress: checked })}
|
||||
aria-label="显示启动进度"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ToastProvider, TooltipProvider } from './components/ui'
|
||||
import './styles/tailwind.css'
|
||||
import './styles/index.scss'
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
@@ -36,6 +38,10 @@ window.addEventListener('unhandledrejection', (event) => {
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user