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:
Ant Browser Release Bot
2026-03-13 23:19:29 +08:00
commit 6f58a6c19a
230 changed files with 44624 additions and 0 deletions
+71
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}
+56
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+103
View File
@@ -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>
)
}
+138
View File
@@ -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 }}
/>
)
}
+134
View File
@@ -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>
)
}
+115
View File
@@ -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>
)
}
+175
View File
@@ -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>
)
}
+75
View File
@@ -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>
)
}
+99
View File
@@ -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>
)
}
+21
View File
@@ -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'
+21
View File
@@ -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>
)
}
+168
View File
@@ -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>
)
}
+211
View File
@@ -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>
)
}
+3
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
// 主题模块导出
export { ThemeProvider, useTheme } from './ThemeContext'
export { themeConfigs, DEFAULT_THEME } from './types'
export type { ThemeType, ThemeConfig } from './types'
+36
View File
@@ -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);
}
+31
View File
@@ -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);
}
+31
View File
@@ -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);
}
+18
View File
@@ -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'