feat: shared UI component library (11 components)
Add Button, Card, Table, Badge, Modal, Drawer, Toast, Skeleton, Toggle, Timeline, SearchBox — all using CSS variables from tokens.css with className prop extension and exported Props interfaces. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
variant?: 'success' | 'danger' | 'warning' | 'info' | 'honey' | 'outline'
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<string, { bg: string; color: string; border?: string }> = {
|
||||||
|
success: { bg: 'rgba(0, 200, 83, 0.15)', color: 'var(--green)' },
|
||||||
|
danger: { bg: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' },
|
||||||
|
warning: { bg: 'rgba(255, 171, 0, 0.15)', color: 'var(--yellow)' },
|
||||||
|
info: { bg: 'rgba(68, 138, 255, 0.15)', color: 'var(--blue)' },
|
||||||
|
honey: { bg: 'rgba(240, 185, 11, 0.15)', color: 'var(--honey)' },
|
||||||
|
outline: { bg: 'transparent', color: 'var(--text-dim)', border: '1px solid var(--border)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ variant = 'outline', children, className = '' }: BadgeProps) {
|
||||||
|
const s = variantStyles[variant]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold ${className}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: s.bg,
|
||||||
|
color: s.color,
|
||||||
|
border: s.border ?? 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'danger' | 'outline' | 'ghost'
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
loading?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
type = 'button',
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base =
|
||||||
|
'inline-flex items-center justify-center rounded-lg font-semibold cursor-pointer border transition-all duration-150'
|
||||||
|
|
||||||
|
const variantStyles: Record<string, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-[var(--honey)] text-black border-[var(--honey)] font-semibold hover:brightness-110',
|
||||||
|
danger:
|
||||||
|
'bg-transparent text-[var(--red)] border-[var(--red)] hover:bg-[var(--red)] hover:text-white',
|
||||||
|
outline:
|
||||||
|
'bg-transparent text-[var(--text-dim)] border-[var(--border)] hover:bg-[var(--bg-hover)]',
|
||||||
|
ghost:
|
||||||
|
'bg-transparent text-[var(--text-dim)] border-transparent hover:bg-[var(--bg-hover)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<string, string> = {
|
||||||
|
sm: 'px-[10px] py-[4px] text-[12px]',
|
||||||
|
md: 'px-[14px] py-[7px] text-[13px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimClass =
|
||||||
|
disabled || loading ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${dimClass} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<span className="absolute inline-block w-[14px] h-[14px] border-2 border-transparent border-t-current rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
<span className={loading ? 'text-transparent' : ''}>{children}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface CardProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
valueColor?: 'green' | 'red' | 'yellow' | 'blue'
|
||||||
|
change?: string
|
||||||
|
changeDirection?: 'up' | 'down' | 'neutral'
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueColorMap: Record<string, string> = {
|
||||||
|
green: 'var(--green)',
|
||||||
|
red: 'var(--red)',
|
||||||
|
yellow: 'var(--yellow)',
|
||||||
|
blue: 'var(--blue)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionIcon: Record<string, string> = {
|
||||||
|
up: '↑',
|
||||||
|
down: '↓',
|
||||||
|
neutral: '→',
|
||||||
|
}
|
||||||
|
|
||||||
|
const directionColor: Record<string, string> = {
|
||||||
|
up: 'var(--green)',
|
||||||
|
down: 'var(--red)',
|
||||||
|
neutral: 'var(--text-dim)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
valueColor,
|
||||||
|
change,
|
||||||
|
changeDirection,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
}: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`bg-[var(--bg-card)] rounded-[var(--radius)] p-4 ${onClick ? 'cursor-pointer hover:bg-[var(--bg-hover)]' : ''} transition-colors duration-150 ${className}`}
|
||||||
|
>
|
||||||
|
<div className="text-[12px] uppercase tracking-wider text-[var(--text-dim)]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[28px] font-bold leading-tight mt-1"
|
||||||
|
style={{ color: valueColor ? valueColorMap[valueColor] : undefined }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{change !== undefined && changeDirection && (
|
||||||
|
<div
|
||||||
|
className="text-[12px] mt-1 flex items-center gap-1"
|
||||||
|
style={{ color: directionColor[changeDirection] }}
|
||||||
|
>
|
||||||
|
<span>{directionIcon[changeDirection]}</span>
|
||||||
|
<span>{change}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useCallback, useState, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export interface DrawerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
width?: string | number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
width = 480,
|
||||||
|
className = '',
|
||||||
|
}: DrawerProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true)
|
||||||
|
requestAnimationFrame(() => setVisible(true))
|
||||||
|
} else {
|
||||||
|
setVisible(false)
|
||||||
|
const timer = setTimeout(() => setMounted(false), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [open, handleKeyDown])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999]">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 transition-opacity duration-250 ${
|
||||||
|
visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 right-0 h-full bg-[var(--bg-nav)] shadow-[var(--shadow-lg)] flex flex-col transition-transform duration-[250ms] ease-in-out ${className}`}
|
||||||
|
style={{
|
||||||
|
width: typeof width === 'number' ? `${width}px` : width,
|
||||||
|
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)] shrink-0">
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="px-5 py-4 border-t border-[var(--border)] shrink-0">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useCallback, type ReactNode } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import Button from './Button'
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
confirmText?: string
|
||||||
|
onConfirm?: () => void
|
||||||
|
confirmDanger?: boolean
|
||||||
|
width?: string | number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
confirmText,
|
||||||
|
onConfirm,
|
||||||
|
confirmDanger,
|
||||||
|
width = 500,
|
||||||
|
className = '',
|
||||||
|
}: ModalProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [open, handleKeyDown])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-[var(--bg-nav)] rounded-[8px] shadow-[var(--shadow-modal)] w-full mx-4 flex flex-col max-h-[85vh] ${className}`}
|
||||||
|
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
||||||
|
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
{(footer || onConfirm) && (
|
||||||
|
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-[var(--border)]">
|
||||||
|
{footer || (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmDanger ? 'danger' : 'primary'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText || '确认'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"
|
||||||
|
|
||||||
|
export interface SearchGroup {
|
||||||
|
label: string
|
||||||
|
items: { value: string; label: ReactNode }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchBoxProps {
|
||||||
|
onSearch: (query: string) => SearchGroup[] | Promise<SearchGroup[]>
|
||||||
|
placeholder?: string
|
||||||
|
debounceMs?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBox({
|
||||||
|
onSearch,
|
||||||
|
placeholder = "搜索...",
|
||||||
|
debounceMs = 300,
|
||||||
|
className = "",
|
||||||
|
}: SearchBoxProps) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [groups, setGroups] = useState<SearchGroup[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
const flatItems = groups.flatMap((g) => g.items)
|
||||||
|
|
||||||
|
const doSearch = useCallback(
|
||||||
|
async (q: string) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setGroups([])
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await onSearch(q)
|
||||||
|
setGroups(result)
|
||||||
|
setOpen(result.length > 0 && result.some((g) => g.items.length > 0))
|
||||||
|
setActiveIdx(-1)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSearch],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = setTimeout(() => doSearch(query), debounceMs)
|
||||||
|
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
|
||||||
|
}, [query, debounceMs, doSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setActiveIdx(-1)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
panelRef.current &&
|
||||||
|
!panelRef.current.contains(e.target as Node) &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!open || flatItems.length === 0) return
|
||||||
|
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0))
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1))
|
||||||
|
} else if (e.key === "Enter" && activeIdx >= 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setQuery(String(flatItems[activeIdx].label))
|
||||||
|
setOpen(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} style={{ width: "420px", maxWidth: "100%" }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => { if (groups.length > 0) setOpen(true) }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full h-[36px] px-3 text-[13px] rounded-lg border transition-colors duration-150 outline-none bg-[var(--bg-card)] text-[var(--text)] placeholder:text-[var(--text-muted)]"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
onFocusCapture={(e) => {
|
||||||
|
;(e.target as HTMLInputElement).style.borderColor = "var(--honey)"
|
||||||
|
}}
|
||||||
|
onBlurCapture={(e) => {
|
||||||
|
;(e.target as HTMLInputElement).style.borderColor = "var(--border)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 w-[14px] h-[14px] border-2 border-[var(--border)] border-t-[var(--honey)] rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{open && !loading && (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 rounded-lg shadow-[var(--shadow-lg)] z-50 max-h-[320px] overflow-y-auto"
|
||||||
|
style={{ backgroundColor: "var(--bg-elevated)" }}
|
||||||
|
>
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const startIdx = cursor
|
||||||
|
cursor += group.items.length
|
||||||
|
return (
|
||||||
|
<div key={gi}>
|
||||||
|
<div className="px-3 py-2 text-[11px] font-semibold uppercase text-[var(--text-dim)]">
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
{group.items.map((item, ii) => {
|
||||||
|
const idx = startIdx + ii
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={`px-3 py-[7px] text-[13px] cursor-pointer transition-colors duration-100 ${
|
||||||
|
idx === activeIdx ? "bg-[var(--bg-hover)]" : ""
|
||||||
|
}`}
|
||||||
|
style={{ color: "var(--text)" }}
|
||||||
|
onMouseEnter={() => setActiveIdx(idx)}
|
||||||
|
onMouseDown={() => {
|
||||||
|
setQuery(String(item.label))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
const styleId = "ui-skeleton-keyframes"
|
||||||
|
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement("style")
|
||||||
|
style.id = styleId
|
||||||
|
style.textContent = `@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkeletonProps {
|
||||||
|
variant?: "card" | "table" | "text"
|
||||||
|
count?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-[var(--radius-sm)] bg-[var(--bg-hover)] ${className}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)",
|
||||||
|
backgroundSize: "200% 100%",
|
||||||
|
animation: "shimmer 1500ms infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--bg-card)] rounded-[var(--radius)] p-4 space-y-3">
|
||||||
|
<Bar className="h-3 w-1/3" />
|
||||||
|
<Bar className="h-8 w-1/2" />
|
||||||
|
<Bar className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<Bar key={i} className="h-5 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Bar key={i} className={`h-3 ${i % 2 === 0 ? "w-3/4" : "w-1/2"}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Skeleton({
|
||||||
|
variant = "text",
|
||||||
|
count = 3,
|
||||||
|
className = "",
|
||||||
|
}: SkeletonProps) {
|
||||||
|
if (variant === "card")
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<CardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (variant === "table") return <TableSkeleton rows={count} />
|
||||||
|
return <TextSkeleton count={count} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface TableColumn {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
width?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps {
|
||||||
|
columns: TableColumn[]
|
||||||
|
data: Record<string, ReactNode>[]
|
||||||
|
rowKey: string
|
||||||
|
onRowClick?: (record: Record<string, ReactNode>) => void
|
||||||
|
rowClassName?: (record: Record<string, ReactNode>) => string
|
||||||
|
emptyText?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Table({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
onRowClick,
|
||||||
|
rowClassName,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
className = '',
|
||||||
|
}: TableProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full overflow-auto rounded-[var(--radius)] border border-[var(--border)] ${className}`}
|
||||||
|
>
|
||||||
|
<table className="w-full border-collapse text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="sticky top-0 z-10 bg-[var(--bg-nav)] text-[12px] uppercase text-[var(--text-dim)] font-semibold text-left px-4 h-[44px] whitespace-nowrap"
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center text-[var(--text-dim)] py-8"
|
||||||
|
>
|
||||||
|
{emptyText}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((record) => {
|
||||||
|
const extra = rowClassName?.(record) ?? ''
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={String(record[rowKey])}
|
||||||
|
onClick={() => onRowClick?.(record)}
|
||||||
|
className={`h-[44px] border-t border-[var(--border)] transition-colors duration-100 ${
|
||||||
|
onRowClick ? 'cursor-pointer' : ''
|
||||||
|
} hover:bg-[var(--bg-hover)] ${
|
||||||
|
extra.includes('row-danger')
|
||||||
|
? 'bg-[var(--bg-error)] hover:bg-[var(--bg-error)]'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className="px-4">
|
||||||
|
{record[col.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
time: string
|
||||||
|
type: string
|
||||||
|
label: ReactNode
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineProps {
|
||||||
|
events: TimelineEvent[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColors: Record<string, string> = {
|
||||||
|
green: "var(--green)",
|
||||||
|
red: "var(--red)",
|
||||||
|
orange: "var(--orange)",
|
||||||
|
blue: "var(--blue)",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline({ events, className = "" }: TimelineProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-0 ${className}`}>
|
||||||
|
{events.map((evt, i) => {
|
||||||
|
const dotColor = evt.color || defaultColors[evt.type] || "var(--text-dim)"
|
||||||
|
const isLast = i === events.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-[8px] h-[8px] rounded-full transition-transform duration-150 hover:-translate-y-[2px] group relative"
|
||||||
|
style={{ backgroundColor: dotColor }}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
|
||||||
|
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
|
||||||
|
{evt.time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className="flex-1 h-[2px] mx-1 self-start mt-[3px]"
|
||||||
|
style={{ backgroundColor: "var(--border)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect, useCallback, type ReactNode } from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "warn" | "info"
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (item: ToastItem) => void
|
||||||
|
|
||||||
|
let emit: Listener | null = null
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
function ToastStack() {
|
||||||
|
const [list, setList] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
emit = (item: ToastItem) => {
|
||||||
|
setList((prev) => {
|
||||||
|
const next = [...prev, item]
|
||||||
|
return next.length > 3 ? next.slice(-3) : next
|
||||||
|
})
|
||||||
|
if (item.type === "success") {
|
||||||
|
setTimeout(() => dismiss(item.id), 3000)
|
||||||
|
} else if (item.type === "error") {
|
||||||
|
setTimeout(() => dismiss(item.id), 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => { emit = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setList((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (list.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed top-4 left-1/2 -translate-x-1/2 z-[99999] flex flex-col gap-2 w-full pointer-events-none"
|
||||||
|
style={{ maxWidth: "480px" }}
|
||||||
|
>
|
||||||
|
{list.map((item) => {
|
||||||
|
const bgMap: Record<ToastType, string> = {
|
||||||
|
success: "var(--bg-success)",
|
||||||
|
error: "var(--bg-error)",
|
||||||
|
warn: "var(--bg-warn)",
|
||||||
|
info: "var(--bg-info)",
|
||||||
|
}
|
||||||
|
const colorMap: Record<ToastType, string> = {
|
||||||
|
success: "var(--green)",
|
||||||
|
error: "var(--red)",
|
||||||
|
warn: "var(--yellow)",
|
||||||
|
info: "var(--blue)",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-[var(--radius)] shadow-[var(--shadow)] text-[13px] font-medium transition-all duration-250 animate-in"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgMap[item.type],
|
||||||
|
color: colorMap[item.type],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{item.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(item.id)}
|
||||||
|
className="bg-transparent border-none cursor-pointer text-inherit opacity-60 hover:opacity-100 text-[14px] leading-none p-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = false
|
||||||
|
function ensureMounted() {
|
||||||
|
if (mounted) return
|
||||||
|
mounted = true
|
||||||
|
const el = document.createElement("div")
|
||||||
|
el.id = "ui-toast-root"
|
||||||
|
document.body.appendChild(el)
|
||||||
|
createRoot(el).render(<ToastStack />)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(type: ToastType, message: string) {
|
||||||
|
ensureMounted()
|
||||||
|
const id = ++seq
|
||||||
|
const tryEmit = () => {
|
||||||
|
if (emit) {
|
||||||
|
emit({ id, type, message })
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => tryEmit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tryEmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
success: (msg: string) => toast("success", msg),
|
||||||
|
error: (msg: string) => toast("error", msg),
|
||||||
|
warn: (msg: string) => toast("warn", msg),
|
||||||
|
info: (msg: string) => toast("info", msg),
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
}: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`inline-flex items-center gap-[8px] text-[13px] ${
|
||||||
|
disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(!checked)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative inline-block w-[44px] h-[24px] rounded-[12px] transition-colors duration-200"
|
||||||
|
style={{ backgroundColor: checked ? "var(--honey)" : "var(--bg-hover)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-[2px] left-[2px] w-[20px] h-[20px] rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: checked ? "translateX(20px)" : "translateX(0)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[var(--text)] select-none">{label}</span>}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user