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:
215
frontend/src/components/common/Button.test.tsx
Normal file
215
frontend/src/components/common/Button.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
158
frontend/src/components/common/Button.tsx
Normal file
158
frontend/src/components/common/Button.tsx
Normal 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
|
||||
84
frontend/src/components/common/Card.tsx
Normal file
84
frontend/src/components/common/Card.tsx
Normal 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
|
||||
61
frontend/src/components/common/ErrorBoundary.tsx
Normal file
61
frontend/src/components/common/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
100
frontend/src/components/common/Input.tsx
Normal file
100
frontend/src/components/common/Input.tsx
Normal 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
|
||||
68
frontend/src/components/common/Progress.tsx
Normal file
68
frontend/src/components/common/Progress.tsx
Normal 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
|
||||
74
frontend/src/components/common/Select.tsx
Normal file
74
frontend/src/components/common/Select.tsx
Normal 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
|
||||
66
frontend/src/components/common/StatusBadge.tsx
Normal file
66
frontend/src/components/common/StatusBadge.tsx
Normal 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
|
||||
110
frontend/src/components/common/Table.tsx
Normal file
110
frontend/src/components/common/Table.tsx
Normal 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
|
||||
48
frontend/src/components/common/Tabs.tsx
Normal file
48
frontend/src/components/common/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/common/Toast.tsx
Normal file
141
frontend/src/components/common/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/common/index.ts
Normal file
33
frontend/src/components/common/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user