mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
publish: 1.0.0 snapshot (bad2ec1)
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type AlertType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface AlertProps {
|
||||
type?: AlertType
|
||||
title?: string
|
||||
message: ReactNode
|
||||
closable?: boolean
|
||||
onClose?: () => void
|
||||
showIcon?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertCircle,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
success: 'bg-[var(--color-success)]/15 border-[var(--color-success)]/30 text-[var(--color-success)]',
|
||||
error: 'bg-[var(--color-error)]/15 border-[var(--color-error)]/30 text-[var(--color-error)]',
|
||||
warning: 'bg-[var(--color-warning)]/15 border-[var(--color-warning)]/30 text-[var(--color-warning)]',
|
||||
info: 'bg-[var(--color-accent)]/15 border-[var(--color-accent)]/30 text-[var(--color-accent)]',
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
type = 'info',
|
||||
title,
|
||||
message,
|
||||
closable = false,
|
||||
onClose,
|
||||
showIcon = true,
|
||||
className,
|
||||
}: AlertProps) {
|
||||
const Icon = icons[type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-3 p-4 rounded-lg border',
|
||||
styles[type],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{showIcon && <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h4 className="font-semibold mb-1">{title}</h4>
|
||||
)}
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{closable && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
|
||||
type BadgeSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode
|
||||
variant?: BadgeVariant
|
||||
size?: BadgeSize
|
||||
dot?: boolean
|
||||
dotClassName?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-[var(--color-bg-muted)] text-[var(--color-text-secondary)]',
|
||||
success: 'bg-[var(--color-success)]/15 text-[var(--color-success)]',
|
||||
error: 'bg-[var(--color-error)]/15 text-[var(--color-error)]',
|
||||
warning: 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]',
|
||||
info: 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-2.5 py-1 text-sm',
|
||||
}
|
||||
|
||||
const dotStyles = {
|
||||
default: 'bg-[var(--color-text-muted)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
error: 'bg-[var(--color-error)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
info: 'bg-[var(--color-accent)]',
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
dot = false,
|
||||
dotClassName = 'w-1.5 h-1.5',
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 rounded-full font-medium',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span className={clsx('rounded-full', dotClassName, dotStyles[variant])} />
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-[var(--color-accent)] text-[var(--color-text-inverse)] hover:opacity-90 focus-visible:ring-[var(--color-accent)]',
|
||||
secondary: 'bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)] border border-[var(--color-border-default)] hover:bg-[var(--color-bg-muted)] hover:border-[var(--color-border-strong)] focus-visible:ring-[var(--color-border-strong)]',
|
||||
danger: 'bg-[var(--color-error)] text-white hover:opacity-90 focus-visible:ring-[var(--color-error)]',
|
||||
ghost: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-8 px-3 text-xs gap-1.5',
|
||||
md: 'h-9 px-4 text-sm gap-2',
|
||||
lg: 'h-10 px-5 text-sm gap-2',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
(disabled || loading) && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
actions?: ReactNode
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className,
|
||||
padding = 'md',
|
||||
actions,
|
||||
hover = false
|
||||
}: CardProps) {
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-bg-surface)] rounded-xl overflow-hidden',
|
||||
'border border-[var(--color-border-default)]',
|
||||
'transition-all duration-200',
|
||||
hover && 'hover:shadow-[var(--shadow-md)] hover:border-[var(--color-border-strong)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border-muted)]">
|
||||
<div>
|
||||
{title && (
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className={paddings[padding]}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom'
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
placement?: DrawerPlacement
|
||||
width?: string
|
||||
height?: string
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
const placementStyles = {
|
||||
left: 'left-0 top-0 bottom-0 animate-slide-in-left',
|
||||
right: 'right-0 top-0 bottom-0 animate-slide-in-right',
|
||||
top: 'top-0 left-0 right-0 animate-slide-in-top',
|
||||
bottom: 'bottom-0 left-0 right-0 animate-slide-in-bottom',
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
placement = 'right',
|
||||
width = '400px',
|
||||
height = '300px',
|
||||
closable = true,
|
||||
}: DrawerProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isHorizontal = placement === 'left' || placement === 'right'
|
||||
const size = isHorizontal ? { width } : { height }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={closable ? onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bg-[var(--color-bg-elevated)] shadow-2xl flex flex-col',
|
||||
placementStyles[placement]
|
||||
)}
|
||||
style={size}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
{(title || closable) && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)] flex-shrink-0">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{closable && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] transition-colors ml-auto"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border)] flex-shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ReactNode, useState, useRef, useEffect } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface DropdownItem {
|
||||
key: string
|
||||
label: ReactNode
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
items: DropdownItem[]
|
||||
onSelect?: (key: string) => void
|
||||
children?: ReactNode
|
||||
trigger?: ReactNode
|
||||
placement?: 'bottom-left' | 'bottom-right'
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
items,
|
||||
onSelect,
|
||||
children,
|
||||
trigger,
|
||||
placement = 'bottom-left',
|
||||
}: DropdownProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setVisible(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleSelect = (item: DropdownItem) => {
|
||||
if (item.disabled || item.divider) return
|
||||
onSelect?.(item.key)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const placementStyles = {
|
||||
'bottom-left': 'left-0',
|
||||
'bottom-right': 'right-0',
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<div onClick={() => setVisible(!visible)} className="cursor-pointer">
|
||||
{trigger || children || (
|
||||
<button className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-muted)] transition-colors">
|
||||
<span className="text-sm">操作</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-full mt-2 z-50 min-w-[160px] bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 animate-scale-in',
|
||||
placementStyles[placement]
|
||||
)}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
item.divider ? (
|
||||
<div key={index} className="h-px bg-[var(--color-border)] my-1" />
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => handleSelect(item)}
|
||||
disabled={item.disabled}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors',
|
||||
item.disabled
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: item.danger
|
||||
? 'text-[var(--color-error)] hover:bg-[var(--color-error)]/15'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]'
|
||||
)}
|
||||
>
|
||||
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ReactNode, InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface FormItemProps {
|
||||
label?: string
|
||||
required?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormItem({ label, required, hint, error, children, className }: FormItemProps) {
|
||||
return (
|
||||
<div className={clsx('space-y-1.5', className)}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
{label}
|
||||
{required && <span className="text-[var(--color-error)] ml-0.5">*</span>}
|
||||
{hint && <span className="text-xs font-normal text-[var(--color-text-muted)] ml-1">({hint})</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{error && <p className="text-xs text-[var(--color-error)]">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export function Input({ error, className, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={clsx(
|
||||
'block h-9 px-3 text-sm',
|
||||
'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]',
|
||||
'border border-[var(--color-border-default)] rounded-lg',
|
||||
'placeholder:text-[var(--color-text-muted)]',
|
||||
'focus:outline-none focus:border-[var(--color-border-strong)] focus:ring-1 focus:ring-[var(--color-border-strong)]',
|
||||
'disabled:bg-[var(--color-bg-muted)] disabled:text-[var(--color-text-muted)] disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150',
|
||||
error && 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]',
|
||||
// 默认宽度自适应,可通过 className 覆盖
|
||||
!className?.includes('w-') && 'w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
error?: boolean
|
||||
options: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export function Select({ error, options, className, ...props }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={clsx(
|
||||
'block h-9 px-3 text-sm',
|
||||
'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]',
|
||||
'border border-[var(--color-border-default)] rounded-lg',
|
||||
'focus:outline-none focus:border-[var(--color-border-strong)] focus:ring-1 focus:ring-[var(--color-border-strong)]',
|
||||
'disabled:bg-[var(--color-bg-muted)] disabled:text-[var(--color-text-muted)] disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150',
|
||||
'cursor-pointer',
|
||||
error && 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]',
|
||||
// 默认宽度自适应,可通过 className 覆盖
|
||||
!className?.includes('w-') && 'w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export function Textarea({ error, className, ...props }: TextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={clsx(
|
||||
'block w-full px-3 py-2 text-sm',
|
||||
'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]',
|
||||
'border border-[var(--color-border-default)] rounded-lg',
|
||||
'placeholder:text-[var(--color-text-muted)]',
|
||||
'focus:outline-none focus:border-[var(--color-border-strong)] focus:ring-1 focus:ring-[var(--color-border-strong)]',
|
||||
'disabled:bg-[var(--color-bg-muted)] disabled:text-[var(--color-text-muted)] disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150 resize-none',
|
||||
error && 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, disabled }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-150',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2',
|
||||
checked ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-border-strong)]',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-150',
|
||||
checked ? 'translate-x-4' : 'translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
type LoadingSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
interface LoadingProps {
|
||||
size?: LoadingSize
|
||||
text?: string
|
||||
fullscreen?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-2',
|
||||
lg: 'w-8 h-8 border-[3px]',
|
||||
}
|
||||
|
||||
export function Loading({
|
||||
size = 'md',
|
||||
text,
|
||||
fullscreen = false,
|
||||
className
|
||||
}: LoadingProps) {
|
||||
const spinner = (
|
||||
<div className={clsx('flex flex-col items-center gap-3', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'border-[var(--color-border-default)] border-t-[var(--color-accent)] rounded-full animate-spin',
|
||||
sizeStyles[size]
|
||||
)}
|
||||
/>
|
||||
{text && (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">{text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-bg-base)]/80 backdrop-blur-sm">
|
||||
{spinner}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return spinner
|
||||
}
|
||||
|
||||
// 骨架屏组件
|
||||
interface SkeletonProps {
|
||||
width?: string
|
||||
height?: string
|
||||
circle?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
width = '100%',
|
||||
height = '20px',
|
||||
circle = false,
|
||||
className
|
||||
}: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-bg-muted)] animate-pulse',
|
||||
circle ? 'rounded-full' : 'rounded',
|
||||
className
|
||||
)}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
width?: string
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
width = '500px',
|
||||
closable = true,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={closable ? onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<div
|
||||
className="relative bg-[var(--color-bg-elevated)] rounded-xl shadow-2xl animate-scale-in max-h-[90vh] w-full flex flex-col"
|
||||
style={{ width, maxWidth: '90vw' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
{(title || closable) && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)] flex-shrink-0">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{closable && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] transition-colors ml-auto"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border)] flex-shrink-0">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 确认对话框
|
||||
interface ConfirmModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
content: ReactNode
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = '确认',
|
||||
content,
|
||||
confirmText = '确定',
|
||||
cancelText = '取消',
|
||||
danger = false,
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
width="400px"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? 'danger' : 'primary'}
|
||||
onClick={() => {
|
||||
onConfirm()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="text-[var(--color-text-secondary)]">{content}</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
current: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onChange: (page: number) => void
|
||||
onPageSizeChange?: (size: number) => void
|
||||
pageSizeOptions?: number[]
|
||||
showTotal?: boolean
|
||||
showPageSize?: boolean
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
current,
|
||||
total,
|
||||
pageSize,
|
||||
onChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
showTotal = true,
|
||||
showPageSize = true,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
// 生成页码数组
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 5
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i)
|
||||
} else {
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) pages.push('...')
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(totalPages - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
|
||||
if (current < totalPages - 2) pages.push('...')
|
||||
|
||||
pages.push(totalPages)
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
if (total === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3 px-4 border-t border-[var(--color-border)]">
|
||||
{/* 左侧:总数和每页条数 */}
|
||||
<div className="flex items-center gap-4 text-sm text-[var(--color-text-muted)]">
|
||||
{showTotal && (
|
||||
<span>共 <span className="font-medium text-[var(--color-text-secondary)]">{total}</span> 条</span>
|
||||
)}
|
||||
{showPageSize && onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="px-2 py-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/50"
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:分页按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 首页 */}
|
||||
<button
|
||||
onClick={() => onChange(1)}
|
||||
disabled={current === 1}
|
||||
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="首页"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 上一页 */}
|
||||
<button
|
||||
onClick={() => onChange(current - 1)}
|
||||
disabled={current === 1}
|
||||
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 页码 */}
|
||||
{getPageNumbers().map((page, index) => (
|
||||
typeof page === 'number' ? (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onChange(page)}
|
||||
className={`min-w-[32px] h-8 px-2 rounded-md text-sm font-medium transition-colors ${
|
||||
page === current
|
||||
? 'bg-[var(--color-accent)] text-white'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
) : (
|
||||
<span key={index} className="px-1 text-[var(--color-text-muted)]">...</span>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* 下一页 */}
|
||||
<button
|
||||
onClick={() => onChange(current + 1)}
|
||||
disabled={current === totalPages}
|
||||
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 末页 */}
|
||||
<button
|
||||
onClick={() => onChange(totalPages)}
|
||||
disabled={current === totalPages}
|
||||
className="p-1.5 rounded-md text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
title="末页"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ReactNode, useState, useRef, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
type PopoverTrigger = 'click' | 'hover'
|
||||
|
||||
interface PopoverProps {
|
||||
content: ReactNode
|
||||
children: ReactNode
|
||||
placement?: PopoverPlacement
|
||||
trigger?: PopoverTrigger
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Popover({
|
||||
content,
|
||||
children,
|
||||
placement = 'bottom',
|
||||
trigger = 'click',
|
||||
className,
|
||||
}: PopoverProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger === 'click' && visible) {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setVisible(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [visible, trigger])
|
||||
|
||||
const handleTrigger = () => {
|
||||
if (trigger === 'click') {
|
||||
setVisible(!visible)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (trigger === 'hover') {
|
||||
setVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === 'hover') {
|
||||
setVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const placementStyles = {
|
||||
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative inline-block"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div onClick={handleTrigger}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{visible && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute z-50 animate-scale-in',
|
||||
placementStyles[placement],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-lg shadow-lg p-3 min-w-[120px]">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
type ProgressStatus = 'normal' | 'success' | 'error' | 'warning'
|
||||
|
||||
interface ProgressProps {
|
||||
percent: number
|
||||
status?: ProgressStatus
|
||||
showInfo?: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
normal: 'bg-[var(--color-accent)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
error: 'bg-[var(--color-error)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'h-1',
|
||||
md: 'h-2',
|
||||
lg: 'h-3',
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
percent,
|
||||
status = 'normal',
|
||||
showInfo = true,
|
||||
size = 'md',
|
||||
className,
|
||||
}: ProgressProps) {
|
||||
const validPercent = Math.min(100, Math.max(0, percent))
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center gap-3', className)}>
|
||||
<div className={clsx('flex-1 bg-[var(--color-bg-muted)] rounded-full overflow-hidden', sizeStyles[size])}>
|
||||
<div
|
||||
className={clsx('h-full transition-all duration-300 rounded-full', statusColors[status])}
|
||||
style={{ width: `${validPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showInfo && (
|
||||
<span className="text-sm text-[var(--color-text-muted)] min-w-[3ch] text-right">
|
||||
{validPercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 圆形进度条
|
||||
interface CircleProgressProps {
|
||||
percent: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
status?: ProgressStatus
|
||||
showInfo?: boolean
|
||||
}
|
||||
|
||||
export function CircleProgress({
|
||||
percent,
|
||||
size = 120,
|
||||
strokeWidth = 8,
|
||||
status = 'normal',
|
||||
showInfo = true,
|
||||
}: CircleProgressProps) {
|
||||
const validPercent = Math.min(100, Math.max(0, percent))
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (validPercent / 100) * circumference
|
||||
|
||||
const colors = {
|
||||
normal: 'var(--color-accent)',
|
||||
success: 'var(--color-success)',
|
||||
error: 'var(--color-error)',
|
||||
warning: 'var(--color-warning)',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* 背景圆 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--color-bg-muted)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* 进度圆 */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors[status]}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{showInfo && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{validPercent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
trend?: {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon, trend }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-bg-surface)] rounded-xl overflow-hidden',
|
||||
'border border-[var(--color-border-default)]',
|
||||
'transition-all duration-200',
|
||||
'hover:border-[var(--color-border-strong)]',
|
||||
'group'
|
||||
)}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-[var(--color-text-muted)] font-medium tracking-wide uppercase">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-2 tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className={clsx(
|
||||
'text-xs font-medium',
|
||||
trend.value >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{trend.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="w-11 h-11 rounded-xl bg-[var(--color-bg-muted)] flex items-center justify-center text-[var(--color-text-secondary)] transition-colors group-hover:bg-[var(--color-accent-muted)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ArrowUp, ArrowDown } from 'lucide-react'
|
||||
|
||||
export type SortOrder = 'asc' | 'desc' | undefined
|
||||
|
||||
export interface SorterResult {
|
||||
column: string
|
||||
order: SortOrder
|
||||
}
|
||||
|
||||
export interface TableColumn<T> {
|
||||
key: string
|
||||
title: ReactNode
|
||||
width?: string | number
|
||||
align?: 'left' | 'center' | 'right'
|
||||
render?: (value: any, record: T, index: number) => ReactNode
|
||||
sortable?: boolean // 是否可排序
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: TableColumn<T>[]
|
||||
data: T[]
|
||||
rowKey: string | ((record: T) => string)
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
onRowClick?: (record: T) => void
|
||||
className?: string
|
||||
maxHeight?: string // 表格最大高度,默认 'calc(100vh - 320px)'
|
||||
stickyHeader?: boolean // 是否固定表头,默认 true
|
||||
onSort?: (sorterResult: SorterResult) => void // 排序变化回调
|
||||
sortColumn?: string // 当前排序的列
|
||||
sortOrder?: SortOrder // 当前排序方式
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
loading = false,
|
||||
emptyText = '暂无数据',
|
||||
onRowClick,
|
||||
className,
|
||||
maxHeight = 'calc(100vh - 320px)',
|
||||
stickyHeader = true,
|
||||
onSort,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
}: TableProps<T>) {
|
||||
const getRowKey = (record: T, index: number): string => {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(record)
|
||||
}
|
||||
return record[rowKey] ?? index.toString()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16" style={{ maxHeight }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-[var(--color-border-default)] border-t-[var(--color-accent)] rounded-full animate-spin" />
|
||||
<span className="text-sm text-[var(--color-text-muted)]">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSortClick = (column: TableColumn<T>) => {
|
||||
if (!column.sortable || !onSort) return;
|
||||
|
||||
let newOrder: SortOrder;
|
||||
if (sortColumn !== column.key) {
|
||||
newOrder = 'asc';
|
||||
} else {
|
||||
newOrder = sortOrder === 'asc' ? 'desc' : sortOrder === 'desc' ? undefined : 'asc';
|
||||
}
|
||||
|
||||
onSort({ column: column.key, order: newOrder });
|
||||
};
|
||||
|
||||
// 渲染排序图标
|
||||
const renderSortIcon = (column: TableColumn<T>) => {
|
||||
if (!column.sortable) return null;
|
||||
|
||||
if (sortColumn === column.key) {
|
||||
if (sortOrder === 'asc') {
|
||||
return <ArrowUp className="w-3.5 h-3.5 ml-1 text-[var(--color-accent)]" />;
|
||||
}
|
||||
if (sortOrder === 'desc') {
|
||||
return <ArrowDown className="w-3.5 h-3.5 ml-1 text-[var(--color-accent)]" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-[var(--color-text-muted)] ml-1 opacity-40 group-hover:opacity-70">
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('overflow-auto', className)}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="min-w-full">
|
||||
<thead className={clsx(stickyHeader && 'sticky top-0 z-10')}>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
'px-4 py-3 text-xs font-semibold text-[var(--color-text-muted)] uppercase tracking-wider bg-[var(--color-bg-muted)]',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right',
|
||||
!col.align && 'text-left',
|
||||
col.sortable && 'cursor-pointer group hover:text-[var(--color-text-primary)]'
|
||||
)}
|
||||
style={{ width: col.width }}
|
||||
onClick={() => col.sortable && handleSortClick(col)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{col.title}
|
||||
{renderSortIcon(col)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--color-border-muted)] bg-[var(--color-bg-surface)]">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-16 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--color-bg-muted)] flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-[var(--color-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--color-text-muted)]">{emptyText}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
className={clsx(
|
||||
'hover:bg-[var(--color-bg-muted)]/50 transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(record)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={clsx(
|
||||
'px-4 py-3.5 text-sm text-[var(--color-text-secondary)]',
|
||||
col.align === 'center' && 'text-center',
|
||||
col.align === 'right' && 'text-right'
|
||||
)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(record[col.key], record, index)
|
||||
: record[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export interface TabItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
items: TabItem[]
|
||||
activeKey?: string
|
||||
defaultActiveKey?: string
|
||||
onChange?: (key: string) => void
|
||||
children?: (activeKey: string) => ReactNode
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
items,
|
||||
activeKey: controlledActiveKey,
|
||||
defaultActiveKey,
|
||||
onChange,
|
||||
children,
|
||||
}: TabsProps) {
|
||||
const [internalActiveKey, setInternalActiveKey] = useState(
|
||||
defaultActiveKey || items[0]?.key || ''
|
||||
)
|
||||
|
||||
const activeKey = controlledActiveKey ?? internalActiveKey
|
||||
|
||||
const handleTabClick = (key: string, disabled?: boolean) => {
|
||||
if (disabled) return
|
||||
|
||||
if (controlledActiveKey === undefined) {
|
||||
setInternalActiveKey(key)
|
||||
}
|
||||
onChange?.(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab 导航 */}
|
||||
<div className="border-b border-[var(--color-border)]">
|
||||
<div className="flex gap-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => handleTabClick(item.key, item.disabled)}
|
||||
disabled={item.disabled}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors relative',
|
||||
'border-b-2 -mb-px',
|
||||
activeKey === item.key
|
||||
? 'text-[var(--color-accent)] border-[var(--color-accent)]'
|
||||
: 'text-[var(--color-text-muted)] border-transparent hover:text-[var(--color-text-secondary)]',
|
||||
item.disabled && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
{children && (
|
||||
<div className="animate-fade-in">
|
||||
{children(activeKey)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useTheme, themeConfigs, ThemeType } from '../theme'
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const themePreview: Record<ThemeType, { bg: string; sidebar: string; accent: string }> = {
|
||||
dark: { bg: '#0c0c0e', sidebar: '#18181b', accent: '#fafafa' },
|
||||
light: { bg: '#f8fafc', sidebar: '#ffffff', accent: '#1e293b' },
|
||||
cream: { bg: '#faf7f2', sidebar: '#fffdf8', accent: '#8b7355' },
|
||||
mint: { bg: '#f6f9f8', sidebar: '#fbfdfc', accent: '#3d5a4c' },
|
||||
ocean: { bg: '#f5f8fa', sidebar: '#fafcfd', accent: '#3a5068' },
|
||||
}
|
||||
|
||||
export function ThemeSwitcher({ className }: ThemeSwitcherProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-4', className)}>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{themeConfigs.map((config) => {
|
||||
const isActive = theme === config.id
|
||||
const preview = themePreview[config.id]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={config.id}
|
||||
onClick={() => setTheme(config.id)}
|
||||
className={clsx(
|
||||
'group relative flex flex-col items-center gap-2.5 p-3 rounded-xl border-2 transition-all duration-200',
|
||||
isActive
|
||||
? 'border-[var(--color-accent)] bg-[var(--color-accent-muted)]'
|
||||
: 'border-[var(--color-border-default)] hover:border-[var(--color-border-strong)] bg-[var(--color-bg-surface)]'
|
||||
)}
|
||||
title={config.description}
|
||||
>
|
||||
{/* 主题预览 - 模拟界面布局 */}
|
||||
<div
|
||||
className="w-full aspect-[4/3] rounded-lg overflow-hidden border border-black/10"
|
||||
style={{ backgroundColor: preview.bg }}
|
||||
>
|
||||
{/* 侧边栏 */}
|
||||
<div
|
||||
className="w-1/4 h-full float-left"
|
||||
style={{ backgroundColor: preview.sidebar }}
|
||||
>
|
||||
<div
|
||||
className="w-2/3 h-1 mt-2 mx-auto rounded-full"
|
||||
style={{ backgroundColor: preview.accent }}
|
||||
/>
|
||||
<div className="mt-2 mx-1 space-y-1">
|
||||
<div className="h-0.5 rounded-full bg-black/10" />
|
||||
<div className="h-0.5 rounded-full bg-black/10" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 内容区 */}
|
||||
<div className="p-1">
|
||||
<div className="h-1 w-1/2 rounded-full bg-black/10 mb-1" />
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<div className="h-2 rounded-sm" style={{ backgroundColor: preview.sidebar }} />
|
||||
<div className="h-2 rounded-sm" style={{ backgroundColor: preview.sidebar }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主题名称 */}
|
||||
<span className={clsx(
|
||||
'text-xs font-medium transition-colors',
|
||||
isActive ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
|
||||
)}>
|
||||
{config.name.replace('主题', '')}
|
||||
</span>
|
||||
|
||||
{/* 选中标记 */}
|
||||
{isActive && (
|
||||
<div className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-[var(--color-accent)] flex items-center justify-center shadow-sm">
|
||||
<Check className="w-3 h-3 text-[var(--color-text-inverse)]" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前主题描述 */}
|
||||
<p className="text-xs text-[var(--color-text-muted)] text-center">
|
||||
{themeConfigs.find(c => c.id === theme)?.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[]
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (toast) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { ...toast, id }],
|
||||
}))
|
||||
|
||||
// 自动移除
|
||||
const duration = toast.duration ?? 3000
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
}, duration)
|
||||
}
|
||||
},
|
||||
removeToast: (id) =>
|
||||
set((state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Toast 工具函数
|
||||
export const toast = {
|
||||
success: (message: string, duration?: number) =>
|
||||
useToastStore.getState().addToast({ type: 'success', message, duration }),
|
||||
error: (message: string, duration?: number) =>
|
||||
useToastStore.getState().addToast({ type: 'error', message, duration }),
|
||||
warning: (message: string, duration?: number) =>
|
||||
useToastStore.getState().addToast({ type: 'warning', message, duration }),
|
||||
info: (message: string, duration?: number) =>
|
||||
useToastStore.getState().addToast({ type: 'info', message, duration }),
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertCircle,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
const styles = {
|
||||
success: 'bg-[var(--color-bg-surface)] text-[var(--color-success)] border-[var(--color-success)]/30 shadow-lg shadow-[var(--color-success)]/5',
|
||||
error: 'bg-[var(--color-bg-surface)] text-[var(--color-error)] border-[var(--color-error)]/30 shadow-lg shadow-[var(--color-error)]/5',
|
||||
warning: 'bg-[var(--color-bg-surface)] text-[var(--color-warning)] border-[var(--color-warning)]/30 shadow-lg shadow-[var(--color-warning)]/5',
|
||||
info: 'bg-[var(--color-bg-surface)] text-[var(--color-accent)] border-[var(--color-accent)]/30 shadow-lg shadow-[var(--color-accent)]/5',
|
||||
}
|
||||
|
||||
function ToastItem({ toast: t }: { toast: Toast }) {
|
||||
const removeToast = useToastStore((state) => state.removeToast)
|
||||
const Icon = icons[t.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg animate-slide-in-right ${styles[t.type]}`}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="flex-1 text-sm font-medium">{t.message}</p>
|
||||
<button
|
||||
onClick={() => removeToast(t.id)}
|
||||
className="p-0.5 rounded hover:bg-black/10 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((state) => state.toasts)
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-md">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 通用组件导出
|
||||
export { Button } from './Button'
|
||||
export { Card } from './Card'
|
||||
export { StatCard } from './StatCard'
|
||||
export { Table } from './Table'
|
||||
export type { TableColumn } from './Table'
|
||||
export { FormItem, Input, Select, Textarea, Switch } from './Form'
|
||||
export { ThemeSwitcher } from './ThemeSwitcher'
|
||||
export { Pagination } from './Pagination'
|
||||
export { Modal, ConfirmModal } from './Modal'
|
||||
export { ToastContainer, toast, useToastStore } from './Toast'
|
||||
export { Badge } from './Badge'
|
||||
export { Tabs } from './Tabs'
|
||||
export type { TabItem } from './Tabs'
|
||||
export { Alert } from './Alert'
|
||||
export { Drawer } from './Drawer'
|
||||
export { Loading, Skeleton } from './Loading'
|
||||
export { Popover } from './Popover'
|
||||
export { Dropdown } from './Dropdown'
|
||||
export type { DropdownItem } from './Dropdown'
|
||||
export { Progress, CircleProgress } from './Progress'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Topbar } from './Topbar'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--color-bg-base)]">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-auto p-5">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Activity,
|
||||
Bookmark,
|
||||
BookOpen,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Monitor,
|
||||
Settings,
|
||||
Database,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
PieChart,
|
||||
Cpu,
|
||||
Globe,
|
||||
Tag,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useLayoutStore } from '../../store/layoutStore'
|
||||
import { projectConfig, navigationConfig } from '../../config'
|
||||
|
||||
// 导入应用logo
|
||||
import logoImage from '../../resources/images/logo.png'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Database,
|
||||
Layers,
|
||||
PieChart,
|
||||
Monitor,
|
||||
ListChecks,
|
||||
Activity,
|
||||
FileText,
|
||||
Cpu,
|
||||
Globe,
|
||||
Bookmark,
|
||||
BookOpen,
|
||||
Tag,
|
||||
}
|
||||
|
||||
function getIcon(iconName: string): LucideIcon {
|
||||
return iconMap[iconName] || LayoutDashboard
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation()
|
||||
const { sidebarCollapsed, toggleSidebar } = useLayoutStore()
|
||||
|
||||
return (
|
||||
<aside className={clsx(
|
||||
'bg-[var(--color-bg-surface)] flex flex-col transition-all duration-300 border-r border-[var(--color-border-default)]',
|
||||
sidebarCollapsed ? 'w-16' : 'w-60'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className={clsx(
|
||||
'h-14 flex items-center border-b border-[var(--color-border-muted)]',
|
||||
sidebarCollapsed ? 'justify-center px-2' : 'px-5'
|
||||
)}>
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full overflow-hidden flex-shrink-0 bg-[var(--color-accent)] flex items-center justify-center">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="应用Logo"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示首字母
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-logo');
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-bold text-[var(--color-text-inverse)] hidden fallback-content">
|
||||
{projectConfig.shortName.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] tracking-tight truncate">
|
||||
{projectConfig.name}
|
||||
</h2>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-[var(--color-accent)] flex items-center justify-center">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="应用Logo"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示首字母
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-logo');
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-bold text-[var(--color-text-inverse)] hidden fallback-content">
|
||||
{projectConfig.shortName.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 px-3 space-y-6 overflow-y-auto">
|
||||
{navigationConfig.map((section) => (
|
||||
<div key={section.title}>
|
||||
{!sidebarCollapsed && (
|
||||
<h3 className="px-3 mb-2 text-[10px] font-semibold text-[var(--color-text-muted)] uppercase tracking-widest">
|
||||
{section.title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const Icon = getIcon(item.icon)
|
||||
const isActive = location.pathname === item.path
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
title={sidebarCollapsed ? item.name : undefined}
|
||||
className={clsx(
|
||||
'flex items-center rounded-lg transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-text-inverse)] shadow-sm'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-primary)]',
|
||||
sidebarCollapsed
|
||||
? 'justify-center w-10 h-10 mx-auto'
|
||||
: 'px-3 py-2.5 gap-3'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-sm font-medium truncate">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<div className="p-3 border-t border-[var(--color-border-muted)]">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={clsx(
|
||||
'flex items-center rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-accent-muted)] hover:text-[var(--color-text-secondary)] transition-all duration-150',
|
||||
sidebarCollapsed
|
||||
? 'justify-center w-10 h-10 mx-auto'
|
||||
: 'w-full px-3 py-2 gap-3'
|
||||
)}
|
||||
title={sidebarCollapsed ? '展开' : '收起'}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="w-[18px] h-[18px]" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-[18px] h-[18px]" />
|
||||
<span className="text-sm">收起侧边栏</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bell, Search, User, Settings, Check, Trash2, Info, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { useNotificationStore, type Notification } from '../../store/notificationStore'
|
||||
|
||||
function NotificationDropdown({
|
||||
notifications,
|
||||
onMarkAsRead,
|
||||
onMarkAllAsRead,
|
||||
onClear
|
||||
}: {
|
||||
notifications: Notification[]
|
||||
onMarkAsRead: (id: string) => void
|
||||
onMarkAllAsRead: () => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const unreadCount = notifications.filter(n => !n.read).length
|
||||
|
||||
const getIcon = (type: Notification['type']) => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
case 'warning': return <AlertCircle className="w-4 h-4 text-[var(--color-warning)]" />
|
||||
case 'error': return <AlertCircle className="w-4 h-4 text-[var(--color-error)]" />
|
||||
default: return <Info className="w-4 h-4 text-[var(--color-accent)]" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-[var(--color-border-muted)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">通知</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-[var(--color-accent)] text-white rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={onMarkAllAsRead}
|
||||
className="p-1.5 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-accent)] hover:bg-[var(--color-bg-muted)] rounded transition-colors"
|
||||
title="全部标为已读"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="p-1.5 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-error)] hover:bg-[var(--color-bg-muted)] rounded transition-colors"
|
||||
title="清空通知"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-8 text-center text-[var(--color-text-muted)]">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无通知</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => onMarkAsRead(notification.id)}
|
||||
className={clsx(
|
||||
'px-4 py-3 border-b border-[var(--color-border-muted)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--color-bg-muted)]',
|
||||
!notification.read && 'bg-[var(--color-accent)]/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className={clsx(
|
||||
'text-sm truncate',
|
||||
notification.read ? 'text-[var(--color-text-secondary)]' : 'text-[var(--color-text-primary)] font-medium'
|
||||
)}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.read && (
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-accent)] shrink-0 mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--color-text-muted)] mt-1">
|
||||
{notification.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-[var(--color-border-muted)] bg-[var(--color-bg-muted)]/50">
|
||||
<button className="w-full text-xs text-center text-[var(--color-accent)] hover:underline">
|
||||
查看全部通知
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
const { notifications, markAsRead, markAllAsRead, clearNotifications } = useNotificationStore()
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowNotifications(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-default)] px-4 flex items-center justify-between gap-4">
|
||||
{/* 搜索框 - 固定宽度,不随容器拉伸 */}
|
||||
<div className="w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
className="w-full h-8 pl-9 pr-3 bg-[var(--color-bg-muted)] border border-transparent rounded-md text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:bg-[var(--color-bg-surface)] focus:border-[var(--color-border-strong)] transition-all duration-150"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间留白 */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 通知按钮 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className={clsx(
|
||||
'relative w-8 h-8 flex items-center justify-center rounded-md transition-colors duration-150',
|
||||
showNotifications
|
||||
? 'text-[var(--color-accent)] bg-[var(--color-accent-muted)]'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)]'
|
||||
)}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 text-[10px] font-medium bg-[var(--color-error)] text-white rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showNotifications && (
|
||||
<NotificationDropdown
|
||||
notifications={notifications}
|
||||
onMarkAsRead={markAsRead}
|
||||
onMarkAllAsRead={markAllAsRead}
|
||||
onClear={() => {
|
||||
clearNotifications()
|
||||
setShowNotifications(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
className="w-8 h-8 flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-accent-muted)] rounded-md transition-colors duration-150"
|
||||
title="设置"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
<div className="w-px h-5 bg-[var(--color-border-default)] mx-1.5" />
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-2 pl-1 pr-2.5 py-1 rounded-md hover:bg-[var(--color-accent-muted)] transition-colors duration-150"
|
||||
>
|
||||
<div className="w-7 h-7 bg-[var(--color-accent)] rounded-md flex items-center justify-center">
|
||||
<User className="w-3.5 h-3.5 text-[var(--color-text-inverse)]" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--color-text-secondary)]">Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Layout } from './Layout'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { Topbar } from './Topbar'
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
import { ThemeType, DEFAULT_THEME } from './types'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeType
|
||||
setTheme: (theme: ThemeType) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
||||
|
||||
const THEME_STORAGE_KEY = 'app-theme'
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode
|
||||
defaultTheme?: ThemeType
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children, defaultTheme = DEFAULT_THEME }: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<ThemeType>(() => {
|
||||
// 从 localStorage 读取保存的主题
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (saved && ['dark', 'light', 'cream', 'mint', 'ocean'].includes(saved)) {
|
||||
return saved as ThemeType
|
||||
}
|
||||
return defaultTheme
|
||||
})
|
||||
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setThemeState(newTheme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
|
||||
}
|
||||
|
||||
// 应用主题到 document
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 主题模块导出
|
||||
export { ThemeProvider, useTheme } from './ThemeContext'
|
||||
export { themeConfigs, DEFAULT_THEME } from './types'
|
||||
export type { ThemeType, ThemeConfig } from './types'
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 基础主题变量定义 - 默认使用浅色主题 */
|
||||
|
||||
:root {
|
||||
--color-bg-base: #f8fafc;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-muted: #f1f5f9;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
|
||||
--color-border-default: #e2e8f0;
|
||||
--color-border-muted: #f1f5f9;
|
||||
--color-border-strong: #cbd5e1;
|
||||
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-accent: #1e293b;
|
||||
--color-accent-hover: #334155;
|
||||
--color-accent-muted: #f1f5f9;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 奶油主题 - 温暖柔和 */
|
||||
|
||||
[data-theme='cream'] {
|
||||
--color-bg-base: #faf7f2;
|
||||
--color-bg-surface: #fffdf8;
|
||||
--color-bg-elevated: #fffdf8;
|
||||
--color-bg-muted: #f3ede4;
|
||||
--color-bg-subtle: #faf7f2;
|
||||
|
||||
--color-border-default: #e6ddd0;
|
||||
--color-border-muted: #f0e9df;
|
||||
--color-border-strong: #d4c7b5;
|
||||
|
||||
--color-text-primary: #44392c;
|
||||
--color-text-secondary: #6b5c4a;
|
||||
--color-text-muted: #a69780;
|
||||
--color-text-inverse: #fffdf8;
|
||||
|
||||
--color-accent: #8b7355;
|
||||
--color-accent-hover: #7a6349;
|
||||
--color-accent-muted: #f3ede4;
|
||||
|
||||
--color-success: #6b9b5a;
|
||||
--color-warning: #c9a227;
|
||||
--color-error: #c45c4a;
|
||||
--color-info: #5a8ab0;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(107 92 74 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(107 92 74 / 0.08);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(107 92 74 / 0.1);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 深色主题 - 沉稳专业 */
|
||||
|
||||
[data-theme='dark'] {
|
||||
--color-bg-base: #0c0c0e;
|
||||
--color-bg-surface: #18181b;
|
||||
--color-bg-elevated: #27272a;
|
||||
--color-bg-muted: #27272a;
|
||||
--color-bg-subtle: #18181b;
|
||||
|
||||
--color-border-default: #27272a;
|
||||
--color-border-muted: #1f1f23;
|
||||
--color-border-strong: #3f3f46;
|
||||
|
||||
--color-text-primary: #fafafa;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-text-muted: #71717a;
|
||||
--color-text-inverse: #18181b;
|
||||
|
||||
--color-accent: #fafafa;
|
||||
--color-accent-hover: #e4e4e7;
|
||||
--color-accent-muted: #27272a;
|
||||
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 主题样式汇总 */
|
||||
@import './base.css';
|
||||
@import './dark.css';
|
||||
@import './light.css';
|
||||
@import './cream.css';
|
||||
@import './mint.css';
|
||||
@import './ocean.css';
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 浅色主题 - 简洁明亮 */
|
||||
|
||||
[data-theme='light'] {
|
||||
--color-bg-base: #f8fafc;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-muted: #f1f5f9;
|
||||
--color-bg-subtle: #f8fafc;
|
||||
|
||||
--color-border-default: #e2e8f0;
|
||||
--color-border-muted: #f1f5f9;
|
||||
--color-border-strong: #cbd5e1;
|
||||
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-accent: #1e293b;
|
||||
--color-accent-hover: #334155;
|
||||
--color-accent-muted: #f1f5f9;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 薄荷主题 - 沉稳内敛的绿 */
|
||||
|
||||
[data-theme='mint'] {
|
||||
--color-bg-base: #f6f9f8;
|
||||
--color-bg-surface: #fbfdfc;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-muted: #eef3f1;
|
||||
--color-bg-subtle: #f6f9f8;
|
||||
|
||||
--color-border-default: #d8e0dc;
|
||||
--color-border-muted: #e8eeeb;
|
||||
--color-border-strong: #c2cdc7;
|
||||
|
||||
--color-text-primary: #1f2d27;
|
||||
--color-text-secondary: #3d4f45;
|
||||
--color-text-muted: #7a8b82;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-accent: #3d5a4c;
|
||||
--color-accent-hover: #2f4a3d;
|
||||
--color-accent-muted: #eef3f1;
|
||||
|
||||
--color-success: #3d6b52;
|
||||
--color-warning: #9a7b32;
|
||||
--color-error: #9b4a42;
|
||||
--color-info: #4a6b8a;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(31 45 39 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(31 45 39 / 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(31 45 39 / 0.08);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 海洋主题 - 沉稳深邃的蓝 */
|
||||
|
||||
[data-theme='ocean'] {
|
||||
--color-bg-base: #f5f8fa;
|
||||
--color-bg-surface: #fafcfd;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-muted: #edf2f6;
|
||||
--color-bg-subtle: #f5f8fa;
|
||||
|
||||
--color-border-default: #d5dde5;
|
||||
--color-border-muted: #e6ebf0;
|
||||
--color-border-strong: #bec9d4;
|
||||
|
||||
--color-text-primary: #1c2a36;
|
||||
--color-text-secondary: #3a4d5c;
|
||||
--color-text-muted: #758999;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-accent: #3a5068;
|
||||
--color-accent-hover: #2d4256;
|
||||
--color-accent-muted: #edf2f6;
|
||||
|
||||
--color-success: #3d6b52;
|
||||
--color-warning: #9a7b32;
|
||||
--color-error: #9b4a42;
|
||||
--color-info: #3a5068;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(28 42 54 / 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgb(28 42 54 / 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(28 42 54 / 0.08);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 主题类型定义
|
||||
export type ThemeType = 'dark' | 'light' | 'cream' | 'mint' | 'ocean'
|
||||
|
||||
export interface ThemeConfig {
|
||||
id: ThemeType
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const themeConfigs: ThemeConfig[] = [
|
||||
{ id: 'dark', name: '深色主题', description: '沉稳专业的深色风格' },
|
||||
{ id: 'light', name: '浅色主题', description: '简洁明亮的浅色风格' },
|
||||
{ id: 'cream', name: '奶油主题', description: '温暖柔和的奶油色调' },
|
||||
{ id: 'mint', name: '薄荷主题', description: '清新自然的浅绿风格' },
|
||||
{ id: 'ocean', name: '海洋主题', description: '深邃宁静的蓝色风格' },
|
||||
]
|
||||
|
||||
export const DEFAULT_THEME: ThemeType = 'light'
|
||||
Reference in New Issue
Block a user