feat: Implement initial full-stack application structure including frontend pages, components, hooks, API integration, and backend services for account pooling and management.

This commit is contained in:
2026-01-30 07:40:35 +08:00
commit f4448bbef2
106 changed files with 19282 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
describe('rendering', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('renders with default props', () => {
render(<Button>Default Button</Button>)
const button = screen.getByRole('button')
// Default variant is primary, default size is md
expect(button).toHaveClass('bg-primary-600')
expect(button).toHaveClass('px-4', 'py-2')
})
})
describe('variants', () => {
it('renders primary variant correctly', () => {
render(<Button variant="primary">Primary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-primary-600', 'text-white')
})
it('renders secondary variant correctly', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-slate-100', 'text-slate-900')
})
it('renders danger variant correctly', () => {
render(<Button variant="danger">Danger</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-error-500', 'text-white')
})
it('renders ghost variant correctly', () => {
render(<Button variant="ghost">Ghost</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('text-slate-700', 'bg-transparent')
})
it('renders outline variant correctly', () => {
render(<Button variant="outline">Outline</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('border', 'border-slate-300', 'text-slate-700')
})
})
describe('sizes', () => {
it('renders small size correctly', () => {
render(<Button size="sm">Small</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-3', 'py-1.5', 'text-sm')
})
it('renders medium size correctly', () => {
render(<Button size="md">Medium</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-4', 'py-2', 'text-sm')
})
it('renders large size correctly', () => {
render(<Button size="lg">Large</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-6', 'py-3', 'text-base')
})
})
describe('loading state', () => {
it('shows spinner when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
// Check for the spinner (Loader2 icon with animate-spin class)
const spinner = button.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('disables button when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('sets aria-busy when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-busy', 'true')
})
it('hides icon when loading', () => {
const icon = <span data-testid="test-icon">Icon</span>
render(
<Button loading icon={icon}>
With Icon
</Button>
)
expect(screen.queryByTestId('test-icon')).not.toBeInTheDocument()
})
})
describe('disabled state', () => {
it('disables button when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('applies disabled styles', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed')
})
it('sets aria-disabled when disabled', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-disabled', 'true')
})
it('does not call onClick when disabled', () => {
const handleClick = vi.fn()
render(
<Button disabled onClick={handleClick}>
Disabled
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('icon support', () => {
it('renders icon when provided', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('renders icon before text', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
const button = screen.getByRole('button')
const iconElement = screen.getByTestId('test-icon')
// Icon should be a child of the button
expect(button).toContainElement(iconElement)
})
})
describe('click handling', () => {
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('does not call onClick when loading', () => {
const handleClick = vi.fn()
render(
<Button loading onClick={handleClick}>
Loading
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('custom className', () => {
it('applies custom className', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('merges custom className with default classes', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class', 'bg-primary-600')
})
})
describe('forwarded ref', () => {
it('forwards ref to button element', () => {
const ref = vi.fn()
render(<Button ref={ref}>Ref Button</Button>)
expect(ref).toHaveBeenCalled()
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLButtonElement)
})
})
describe('HTML button attributes', () => {
it('passes through type attribute', () => {
render(<Button type="submit">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'submit')
})
it('passes through form attribute', () => {
render(<Button form="my-form">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('form', 'my-form')
})
it('passes through aria-label', () => {
render(<Button aria-label="Close dialog">×</Button>)
const button = screen.getByRole('button', { name: 'Close dialog' })
expect(button).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,158 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { Loader2 } from 'lucide-react'
/**
* Button component variants
* - primary: Main action button using design system primary color (Blue-600)
* - secondary: Secondary action button with subtle background
* - outline: Bordered button with transparent background
* - danger: Destructive action button using design system error color (Red-500)
* - ghost: Minimal button with no background, only hover state
*/
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
/**
* Button component sizes
* - sm: Small button for compact UIs
* - md: Medium button (default)
* - lg: Large button for prominent actions
*/
export type ButtonSize = 'sm' | 'md' | 'lg'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style variant of the button */
variant?: ButtonVariant
/** Size of the button */
size?: ButtonSize
/** Shows a loading spinner and disables the button */
loading?: boolean
/** Optional icon to display before the button text */
icon?: React.ReactNode
}
/**
* A reusable Button component with support for multiple variants, sizes,
* loading state, and disabled state. Uses TailwindCSS with design system colors.
*
* @example
* // Primary button
* <Button variant="primary">Submit</Button>
*
* @example
* // Loading state
* <Button loading>Processing...</Button>
*
* @example
* // With icon
* <Button icon={<PlusIcon />}>Add Item</Button>
*/
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className = '',
variant = 'primary',
size = 'md',
loading = false,
disabled,
icon,
children,
...props
},
ref
) => {
// Base styles applied to all button variants
const baseStyles = [
'inline-flex items-center justify-center',
'font-medium rounded-lg',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
].join(' ')
// Variant-specific styles using design system colors
const variantStyles: Record<ButtonVariant, string> = {
// Primary: Blue-600 (#2563EB) - Main action button
primary: [
'bg-primary-600 text-white',
'hover:bg-primary-700',
'focus:ring-primary-500',
'dark:bg-primary-500 dark:hover:bg-primary-600',
].join(' '),
// Secondary: Slate background - Secondary action button
secondary: [
'bg-slate-100 text-slate-900',
'hover:bg-slate-200',
'focus:ring-slate-500',
'dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
].join(' '),
// Outline: Bordered button with transparent background
outline: [
'border border-slate-300 text-slate-700 bg-transparent',
'hover:bg-slate-50',
'focus:ring-slate-500',
'dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
// Danger: Red-500 (#EF4444) - Destructive action button
danger: [
'bg-error-500 text-white',
'hover:bg-error-600',
'focus:ring-error-500',
'dark:bg-error-500 dark:hover:bg-error-600',
].join(' '),
// Ghost: Transparent background - Minimal button
ghost: [
'text-slate-700 bg-transparent',
'hover:bg-slate-100',
'focus:ring-slate-500',
'dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
}
// Size-specific styles
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
}
// Spinner size based on button size
const spinnerSizeStyles: Record<ButtonSize, string> = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
}
const isDisabled = disabled || loading
return (
<button
ref={ref}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={isDisabled}
aria-busy={loading}
aria-disabled={isDisabled}
{...props}
>
{loading ? (
<Loader2
className={`${spinnerSizeStyles[size]} animate-spin`}
aria-hidden="true"
/>
) : icon ? (
<span className="flex-shrink-0" aria-hidden="true">
{icon}
</span>
) : null}
{children}
</button>
)
}
)
Button.displayName = 'Button'
export default Button

View File

@@ -0,0 +1,84 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg'
hoverable?: boolean
variant?: 'default' | 'glass'
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className = '', padding = 'md', hoverable = false, variant = 'default', children, ...props }, ref) => {
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
const baseStyles = variant === 'glass'
? 'glass-card'
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-sm rounded-xl'
const hoverStyles = hoverable ? 'card-hover cursor-pointer' : ''
return (
<div
ref={ref}
className={`${baseStyles} ${paddingStyles[padding]} ${hoverStyles} ${className}`}
{...props}
>
{children}
</div>
)
}
)
Card.displayName = 'Card'
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`flex items-center justify-between mb-4 ${className}`} {...props}>
{children}
</div>
)
}
)
CardHeader.displayName = 'CardHeader'
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {}
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className = '', children, ...props }, ref) => {
return (
<h3
ref={ref}
className={`text-lg font-semibold text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</h3>
)
}
)
CardTitle.displayName = 'CardTitle'
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {}
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={className} {...props}>
{children}
</div>
)
}
)
CardContent.displayName = 'CardContent'
export default Card

View File

@@ -0,0 +1,61 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import Button from './Button'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-[400px] flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="h-16 w-16 mx-auto mb-4 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-4">
{this.state.error?.message || '发生了一个意外错误'}
</p>
<Button onClick={this.handleReset} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,100 @@
import { type InputHTMLAttributes, type TextareaHTMLAttributes, forwardRef } from 'react'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
hint?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
resize-none
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'
export default Input

View File

@@ -0,0 +1,68 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
value: number
max?: number
size?: 'sm' | 'md' | 'lg'
color?: 'blue' | 'green' | 'yellow' | 'red'
showLabel?: boolean
label?: string
}
const Progress = forwardRef<HTMLDivElement, ProgressProps>(
(
{
className = '',
value,
max = 100,
size = 'md',
color = 'blue',
showLabel = false,
label,
...props
},
ref
) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
const sizeStyles = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
}
const colorStyles = {
blue: 'bg-blue-600 dark:bg-blue-500',
green: 'bg-green-600 dark:bg-green-500',
yellow: 'bg-yellow-500 dark:bg-yellow-400',
red: 'bg-red-600 dark:bg-red-500',
}
return (
<div ref={ref} className={className} {...props}>
{(showLabel || label) && (
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{label || '进度'}
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{percentage.toFixed(0)}%
</span>
</div>
)}
<div
className={`w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden ${sizeStyles[size]}`}
>
<div
className={`${sizeStyles[size]} ${colorStyles[color]} rounded-full transition-all duration-300 ease-out`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
)
Progress.displayName = 'Progress'
export default Progress

View File

@@ -0,0 +1,74 @@
import { type SelectHTMLAttributes, forwardRef } from 'react'
import { ChevronDown } from 'lucide-react'
export interface SelectOption {
value: string | number
label: string
disabled?: boolean
}
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
label?: string
error?: string
hint?: string
options: SelectOption[]
placeholder?: string
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors appearance-none
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
pr-10
${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Select.displayName = 'Select'
export default Select

View File

@@ -0,0 +1,66 @@
import { type HTMLAttributes, forwardRef } from 'react'
import type { AccountStatus } from '../../types'
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
status: AccountStatus | 'active' | 'inactive' | 'error'
size?: 'sm' | 'md'
}
const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ className = '', status, size = 'md', ...props }, ref) => {
const getStatusConfig = (status: string) => {
const configs: Record<string, { label: string; color: string }> = {
pending: {
label: '待检查',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
checking: {
label: '检查中',
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
active: {
label: '正常',
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
banned: {
label: '封禁',
color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
token_expired: {
label: '过期',
color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
},
error: {
label: '错误',
color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
},
inactive: {
label: '停用',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
}
return configs[status] || configs.error
}
const config = getStatusConfig(status)
const sizeStyles = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-xs',
}
return (
<span
ref={ref}
className={`inline-flex items-center font-medium rounded-full ${config.color} ${sizeStyles[size]} ${className}`}
{...props}
>
{config.label}
</span>
)
}
)
StatusBadge.displayName = 'StatusBadge'
export default StatusBadge

View File

@@ -0,0 +1,110 @@
import {
type HTMLAttributes,
type ThHTMLAttributes,
type TdHTMLAttributes,
forwardRef,
} from 'react'
export interface TableProps extends HTMLAttributes<HTMLTableElement> {}
const Table = forwardRef<HTMLTableElement, TableProps>(
({ className = '', children, ...props }, ref) => {
return (
<div className="overflow-x-auto">
<table ref={ref} className={`w-full text-sm text-left ${className}`} {...props}>
{children}
</table>
</div>
)
}
)
Table.displayName = 'Table'
export interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<thead
ref={ref}
className={`text-xs text-slate-700 uppercase bg-slate-50 dark:bg-slate-700 dark:text-slate-400 ${className}`}
{...props}
>
{children}
</thead>
)
}
)
TableHeader.displayName = 'TableHeader'
export interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className = '', children, ...props }, ref) => {
return (
<tbody ref={ref} className={className} {...props}>
{children}
</tbody>
)
}
)
TableBody.displayName = 'TableBody'
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
hoverable?: boolean
}
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className = '', hoverable = true, children, ...props }, ref) => {
return (
<tr
ref={ref}
className={`border-b border-slate-200 dark:border-slate-700 ${
hoverable ? 'hover:bg-slate-50 dark:hover:bg-slate-700/50' : ''
} ${className}`}
{...props}
>
{children}
</tr>
)
}
)
TableRow.displayName = 'TableRow'
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {}
export const TableHead = forwardRef<HTMLTableCellElement, TableHeadProps>(
({ className = '', children, ...props }, ref) => {
return (
<th ref={ref} className={`px-4 py-3 font-medium ${className}`} {...props}>
{children}
</th>
)
}
)
TableHead.displayName = 'TableHead'
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {}
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className = '', children, ...props }, ref) => {
return (
<td
ref={ref}
className={`px-4 py-3 text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</td>
)
}
)
TableCell.displayName = 'TableCell'
export default Table

View File

@@ -0,0 +1,48 @@
import type { LucideIcon } from 'lucide-react'
export interface TabItem {
id: string
label: string
icon?: LucideIcon
count?: number
}
interface TabsProps {
tabs: TabItem[]
activeTab: string
onChange: (id: string) => void
className?: string
}
export function Tabs({ tabs, activeTab, onChange, className = '' }: TabsProps) {
return (
<div className={`flex gap-2 border-b border-slate-200 dark:border-slate-800 ${className}`}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200 ${activeTab === tab.id
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
}`}
>
{tab.icon && <tab.icon className="h-4 w-4" />}
{tab.label}
{tab.count !== undefined && (
<span
className={`px-2 py-0.5 text-xs rounded-full ${activeTab === tab.id
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{tab.count}
</span>
)}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastContextValue {
toasts: Toast[]
addToast: (type: ToastType, message: string, duration?: number) => void
removeToast: (id: string) => void
success: (message: string) => void
error: (message: string) => void
warning: (message: string) => void
info: (message: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback(
(type: ToastType, message: string, duration = 3000) => {
const id = Math.random().toString(36).substring(2, 9)
const toast: Toast = { id, type, message, duration }
setToasts((prev) => [...prev, toast])
if (duration > 0) {
setTimeout(() => removeToast(id), duration)
}
},
[removeToast]
)
const success = useCallback((message: string) => addToast('success', message), [addToast])
const error = useCallback((message: string) => addToast('error', message), [addToast])
const warning = useCallback((message: string) => addToast('warning', message), [addToast])
const info = useCallback((message: string) => addToast('info', message), [addToast])
return (
<ToastContext.Provider
value={{ toasts, addToast, removeToast, success, error, warning, info }}
>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
)
}
interface ToastContainerProps {
toasts: Toast[]
onRemove: (id: string) => void
}
function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-6 right-6 z-[9999] space-y-3">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
)
}
interface ToastItemProps {
toast: Toast
onRemove: (id: string) => void
}
const typeStyles: Record<ToastType, { bg: string; icon: typeof CheckCircle }> = {
success: {
bg: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
icon: CheckCircle,
},
error: {
bg: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
icon: XCircle,
},
warning: {
bg: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
icon: AlertTriangle,
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
icon: Info,
},
}
const iconColors: Record<ToastType, string> = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const { bg, icon: Icon } = typeStyles[toast.type]
const iconColor = iconColors[toast.type]
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg animate-fadeIn min-w-[300px] max-w-md ${bg}`}
>
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor}`} />
<p className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
{toast.message}
</p>
<button
onClick={() => onRemove(toast.id)}
className="p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="关闭"
>
<X className="h-4 w-4 text-slate-400" />
</button>
</div>
)
}

View File

@@ -0,0 +1,33 @@
export { default as Button } from './Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
export { default as Card, CardHeader, CardTitle, CardContent } from './Card'
export type { CardProps, CardHeaderProps, CardTitleProps, CardContentProps } from './Card'
export { default as Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table'
export type {
TableProps,
TableHeaderProps,
TableBodyProps,
TableRowProps,
TableHeadProps,
TableCellProps,
} from './Table'
export { default as Progress } from './Progress'
export type { ProgressProps } from './Progress'
export { default as StatusBadge } from './StatusBadge'
export type { StatusBadgeProps } from './StatusBadge'
export { default as Input, Textarea } from './Input'
export type { InputProps, TextareaProps } from './Input'
export { default as Select } from './Select'
export type { SelectProps, SelectOption } from './Select'
export { ErrorBoundary } from './ErrorBoundary'
export { ToastProvider, useToast } from './Toast'
export { Tabs } from './Tabs'
export type { TabItem } from './Tabs'