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'
|
||||
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Users, CheckCircle, XCircle, AlertTriangle, Zap, Activity } from 'lucide-react'
|
||||
import type { DashboardStats } from '../../types'
|
||||
import StatsCard from './StatsCard'
|
||||
|
||||
interface PoolStatusProps {
|
||||
stats: DashboardStats | null
|
||||
loading: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function PoolStatus({ stats, loading, error }: PoolStatusProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="font-medium">获取数据失败</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<StatsCard
|
||||
title="总账号数"
|
||||
value={stats?.total_accounts ?? 0}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="正常账号"
|
||||
value={stats?.normal_accounts ?? 0}
|
||||
icon={CheckCircle}
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="错误账号"
|
||||
value={stats?.error_accounts ?? 0}
|
||||
icon={XCircle}
|
||||
color="red"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="限流账号"
|
||||
value={stats?.ratelimit_accounts ?? 0}
|
||||
icon={AlertTriangle}
|
||||
color="yellow"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="今日请求"
|
||||
value={stats?.today_requests ?? 0}
|
||||
icon={Activity}
|
||||
color="slate"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard title="RPM" value={stats?.rpm ?? 0} icon={Zap} color="blue" loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { AddRecord } from '../../types'
|
||||
import { formatRelativeTime } from '../../utils/format'
|
||||
import { Card, CardHeader, CardTitle, Button } from '../common'
|
||||
|
||||
interface RecentRecordsProps {
|
||||
records: AddRecord[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function RecentRecords({ records, loading = false }: RecentRecordsProps) {
|
||||
const recentRecords = records.slice(0, 5)
|
||||
|
||||
return (
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>最近加号记录</CardTitle>
|
||||
<Link to="/records">
|
||||
<Button variant="ghost" size="sm">
|
||||
查看全部
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : recentRecords.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">暂无加号记录</p>
|
||||
<Link to="/upload" className="mt-4 inline-block">
|
||||
<Button size="sm">开始上传</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRecords.map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
record.failed === 0
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
}`}
|
||||
>
|
||||
{record.failed === 0 ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{record.source === 'manual' ? '手动上传' : '自动补号'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatRelativeTime(record.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
+{record.success}
|
||||
</p>
|
||||
{record.failed > 0 && <p className="text-xs text-red-500">失败 {record.failed}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import { Card } from '../common'
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: number | string
|
||||
icon: LucideIcon
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
trendValue?: string
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red' | 'slate'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function StatsCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
trend,
|
||||
trendValue,
|
||||
color = 'blue',
|
||||
loading = false,
|
||||
}: StatsCardProps) {
|
||||
const colorStyles = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/40',
|
||||
icon: 'text-blue-600 dark:text-blue-400',
|
||||
gradient: 'from-blue-500/20 to-blue-600/20',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20 group-hover:bg-green-100 dark:group-hover:bg-green-900/40',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
gradient: 'from-green-500/20 to-green-600/20',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-900/40',
|
||||
icon: 'text-yellow-600 dark:text-yellow-400',
|
||||
gradient: 'from-yellow-500/20 to-yellow-600/20',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20 group-hover:bg-red-100 dark:group-hover:bg-red-900/40',
|
||||
icon: 'text-red-600 dark:text-red-400',
|
||||
gradient: 'from-red-500/20 to-red-600/20',
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-slate-50 dark:bg-slate-800 group-hover:bg-slate-100 dark:group-hover:bg-slate-700',
|
||||
icon: 'text-slate-600 dark:text-slate-400',
|
||||
gradient: 'from-slate-500/20 to-slate-600/20',
|
||||
},
|
||||
}
|
||||
|
||||
const trendStyles = {
|
||||
up: {
|
||||
icon: TrendingUp,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
},
|
||||
down: {
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
},
|
||||
stable: {
|
||||
icon: Minus,
|
||||
color: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
}
|
||||
|
||||
const TrendIcon = trend ? trendStyles[trend].icon : null
|
||||
|
||||
return (
|
||||
<Card hoverable className="stat-card group transition-all duration-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{title}</p>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100 tracking-tight">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{trend && trendValue && TrendIcon && (
|
||||
<div className={`mt-2 flex items-center gap-1 text-sm ${trendStyles[trend].color}`}>
|
||||
<TrendIcon className="h-4 w-4" />
|
||||
<span>{trendValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl transition-colors duration-300 ${colorStyles[color].bg} bg-gradient-to-br ${colorStyles[color].gradient}`}>
|
||||
<Icon className={`h-6 w-6 ${colorStyles[color].icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/dashboard/index.ts
Normal file
3
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as StatsCard } from './StatsCard'
|
||||
export { default as PoolStatus } from './PoolStatus'
|
||||
export { default as RecentRecords } from './RecentRecords'
|
||||
97
frontend/src/components/layout/Header.tsx
Normal file
97
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Menu, Moon, Sun } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void
|
||||
isConnected?: boolean
|
||||
}
|
||||
|
||||
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check for saved theme preference or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
setIsDark(true)
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDark(!isDark)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light')
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl px-4 lg:px-6 transition-all duration-300">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* Logo - Mobile Only */}
|
||||
<div className="flex lg:hidden items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-slate-900 dark:text-slate-100 hidden sm:inline">
|
||||
Codex Pool
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Connection status */}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
|
||||
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="hidden sm:inline">已连接</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
<span className="hidden sm:inline">未连接</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/layout/Layout.tsx
Normal file
30
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useConfig } from '../../hooks/useConfig'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const { isConnected } = useConfig()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen lg:ml-0">
|
||||
{/* Header */}
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} isConnected={isConnected} />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
174
frontend/src/components/layout/Sidebar.tsx
Normal file
174
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
History,
|
||||
Users,
|
||||
Settings,
|
||||
X,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Mail,
|
||||
Cog,
|
||||
UsersRound
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
|
||||
{ to: '/upload', icon: Upload, label: '上传入库' },
|
||||
{ to: '/team', icon: UsersRound, label: 'Team 批量处理' },
|
||||
{ to: '/records', icon: History, label: '加号记录' },
|
||||
{ to: '/accounts', icon: Users, label: '号池账号' },
|
||||
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
||||
{
|
||||
to: '/config',
|
||||
icon: Settings,
|
||||
label: '系统配置',
|
||||
children: [
|
||||
{ to: '/config', icon: Cog, label: '配置概览' },
|
||||
{ to: '/config/s2a', icon: Server, label: 'S2A 配置' },
|
||||
{ to: '/config/email', icon: Mail, label: '邮箱配置' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
|
||||
|
||||
const toggleExpand = (path: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(path)
|
||||
? prev.filter(p => p !== path)
|
||||
: [...prev, path]
|
||||
)
|
||||
}
|
||||
|
||||
const isItemActive = (item: NavItem): boolean => {
|
||||
if (item.children) {
|
||||
return item.children.some(child => location.pathname === child.to)
|
||||
}
|
||||
return location.pathname === item.to
|
||||
}
|
||||
|
||||
const renderNavItem = (item: NavItem, isChild = false) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedItems.includes(item.to)
|
||||
const isActive = isItemActive(item)
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={item.to} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleExpand(item.to)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 子菜单 */}
|
||||
<div className={`ml-4 pl-3 border-l-2 border-slate-200 dark:border-slate-700 space-y-1 overflow-hidden transition-all duration-200 ${isExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
{item.children?.map(child => renderNavItem(child, true))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
end={item.to === '/config'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isChild ? 'py-2' : ''
|
||||
} ${isActive
|
||||
? isChild
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400'
|
||||
: 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className={`${isChild ? 'h-4 w-4' : 'h-5 w-5'}`} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden" onClick={onClose} />}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="关闭菜单"
|
||||
>
|
||||
<X className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Header (Logo) */}
|
||||
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="p-4 space-y-1.5">
|
||||
{navItems.map(item => renderNavItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200/50 dark:border-slate-800/50 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 text-center font-medium">
|
||||
Codex Pool v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Layout } from './Layout'
|
||||
export { default as Header } from './Header'
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
103
frontend/src/components/records/RecordList.tsx
Normal file
103
frontend/src/components/records/RecordList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { CheckCircle, Trash2 } from 'lucide-react'
|
||||
import type { AddRecord } from '../../types'
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Button } from '../common'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
interface RecordListProps {
|
||||
records: AddRecord[]
|
||||
onDelete?: (id: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function RecordList({ records, onDelete, loading = false }: RecordListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 mb-4">
|
||||
<CheckCircle className="h-8 w-8 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<p className="text-slate-500 dark:text-slate-400">暂无加号记录</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead className="text-right">总数</TableHead>
|
||||
<TableHead className="text-right">成功</TableHead>
|
||||
<TableHead className="text-right">失败</TableHead>
|
||||
<TableHead>详情</TableHead>
|
||||
{onDelete && <TableHead className="w-16"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>
|
||||
<span className="text-sm">{formatDateTime(record.timestamp)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
record.source === 'manual'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{record.source === 'manual' ? '手动上传' : '自动补号'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{record.total}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
{record.success}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.failed > 0 ? (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
{record.failed}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 truncate max-w-[200px] block">
|
||||
{record.details || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{onDelete && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/records/RecordStats.tsx
Normal file
76
frontend/src/components/records/RecordStats.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TrendingUp, Calendar, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { Card } from '../common'
|
||||
|
||||
interface RecordStatsProps {
|
||||
stats: {
|
||||
totalRecords: number
|
||||
totalAdded: number
|
||||
totalSuccess: number
|
||||
totalFailed: number
|
||||
todayAdded: number
|
||||
weekAdded: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function RecordStats({ stats }: RecordStatsProps) {
|
||||
const successRate =
|
||||
stats.totalAdded > 0 ? ((stats.totalSuccess / stats.totalAdded) * 100).toFixed(1) : '0'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">总入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.totalSuccess}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">成功率</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{successRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">今日入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.todayAdded}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">本周入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.weekAdded}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/records/index.ts
Normal file
2
frontend/src/components/records/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RecordList } from './RecordList'
|
||||
export { default as RecordStats } from './RecordStats'
|
||||
182
frontend/src/components/upload/AccountTable.tsx
Normal file
182
frontend/src/components/upload/AccountTable.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { CheckSquare, Square, MinusSquare } from 'lucide-react'
|
||||
import type { CheckedAccount } from '../../types'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
StatusBadge,
|
||||
Button,
|
||||
} from '../common'
|
||||
import { maskEmail, maskToken } from '../../utils/format'
|
||||
|
||||
interface AccountTableProps {
|
||||
accounts: CheckedAccount[]
|
||||
selectedIds: number[]
|
||||
onSelectionChange: (ids: number[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AccountTable({
|
||||
accounts,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
}: AccountTableProps) {
|
||||
const [showTokens, setShowTokens] = useState(false)
|
||||
|
||||
const activeAccounts = useMemo(
|
||||
() => accounts.filter((acc) => acc.status === 'active'),
|
||||
[accounts]
|
||||
)
|
||||
|
||||
const allSelected = selectedIds.length === accounts.length && accounts.length > 0
|
||||
const someSelected = selectedIds.length > 0 && selectedIds.length < accounts.length
|
||||
const activeSelected = activeAccounts.every((acc) => selectedIds.includes(acc.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
onSelectionChange([])
|
||||
} else {
|
||||
onSelectionChange(accounts.map((acc) => acc.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectActive = () => {
|
||||
onSelectionChange(activeAccounts.map((acc) => acc.id))
|
||||
}
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onSelectionChange([])
|
||||
}
|
||||
|
||||
const handleToggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter((i) => i !== id))
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Selection controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectActive}
|
||||
disabled={disabled || activeAccounts.length === 0}
|
||||
>
|
||||
全选正常 ({activeAccounts.length})
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectNone}
|
||||
disabled={disabled || selectedIds.length === 0}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTokens(!showTokens)}>
|
||||
{showTokens ? '隐藏 Token' : '显示 Token'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selection summary */}
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
已选择{' '}
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{selectedIds.length}</span>{' '}
|
||||
个账号
|
||||
{activeSelected && activeAccounts.length > 0 && (
|
||||
<span className="ml-2 text-green-600 dark:text-green-400">
|
||||
(包含全部 {activeAccounts.length} 个正常账号)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead className="w-12">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
disabled={disabled}
|
||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : someSelected ? (
|
||||
<MinusSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>Account ID</TableHead>
|
||||
<TableHead>Plan Type</TableHead>
|
||||
{showTokens && <TableHead>Token</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => handleToggle(account.id)}
|
||||
disabled={disabled}
|
||||
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-50"
|
||||
>
|
||||
{selectedIds.includes(account.id) ? (
|
||||
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{maskEmail(account.account)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={account.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{account.accountId || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{account.planType || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showTokens && (
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{maskToken(account.token, 12)}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { CheckCircle, XCircle, AlertTriangle, Clock } from 'lucide-react'
|
||||
import { Progress } from '../common'
|
||||
|
||||
interface CheckProgressProps {
|
||||
total: number
|
||||
checked: number
|
||||
results: {
|
||||
active: number
|
||||
banned: number
|
||||
token_expired: number
|
||||
error: number
|
||||
}
|
||||
checking: boolean
|
||||
}
|
||||
|
||||
export default function CheckProgress({ total, checked, results, checking }: CheckProgressProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{checking ? '检查中...' : checked === total && total > 0 ? '检查完成' : '待检查'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{checked} / {total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={checked} max={total} color={checking ? 'blue' : 'green'} size="md" />
|
||||
</div>
|
||||
|
||||
{/* Results summary */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">正常</p>
|
||||
<p className="text-lg font-bold text-green-700 dark:text-green-300">{results.active}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">封禁</p>
|
||||
<p className="text-lg font-bold text-red-700 dark:text-red-300">{results.banned}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
<div>
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400">过期</p>
|
||||
<p className="text-lg font-bold text-orange-700 dark:text-orange-300">
|
||||
{results.token_expired}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">错误</p>
|
||||
<p className="text-lg font-bold text-yellow-700 dark:text-yellow-300">
|
||||
{results.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Upload, FileJson, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onFileSelect: (file: File) => void
|
||||
disabled?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function FileDropzone({ onFileSelect, disabled = false, error }: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
onFileSelect(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onFileSelect]
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0])
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
e.target.value = ''
|
||||
},
|
||||
[onFileSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
disabled
|
||||
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 cursor-not-allowed'
|
||||
: isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={`p-4 rounded-full ${
|
||||
isDragging ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-slate-100 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isDragging ? (
|
||||
<Upload className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<FileJson className="h-8 w-8 text-slate-400 dark:text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{isDragging ? '释放文件以上传' : '拖拽 JSON 文件到此处'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">或点击选择文件</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500">
|
||||
支持格式: [{"account": "email", "password": "pwd", "token": "..."}]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
frontend/src/components/upload/LogStream.tsx
Normal file
173
frontend/src/components/upload/LogStream.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Terminal, Trash2, Play, Pause } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
message: string
|
||||
email?: string
|
||||
step?: string
|
||||
}
|
||||
|
||||
interface LogStreamProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'text-blue-400',
|
||||
success: 'text-green-400',
|
||||
warning: 'text-yellow-400',
|
||||
error: 'text-red-400',
|
||||
}
|
||||
|
||||
const stepColors: Record<string, string> = {
|
||||
validate: 'bg-purple-500/20 text-purple-400',
|
||||
register: 'bg-blue-500/20 text-blue-400',
|
||||
authorize: 'bg-orange-500/20 text-orange-400',
|
||||
pool: 'bg-green-500/20 text-green-400',
|
||||
database: 'bg-slate-500/20 text-slate-400',
|
||||
}
|
||||
|
||||
const stepLabels: Record<string, string> = {
|
||||
validate: '验证',
|
||||
register: '注册',
|
||||
authorize: '授权',
|
||||
pool: '入库',
|
||||
database: '数据库',
|
||||
}
|
||||
|
||||
export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return
|
||||
|
||||
const eventSource = new EventSource(`${apiBase}/api/logs/stream`)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data) as LogEntry
|
||||
setLogs((prev) => [...prev.slice(-199), log])
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [apiBase, paused])
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await fetch(`${apiBase}/api/logs/clear`, { method: 'POST' })
|
||||
setLogs([])
|
||||
} catch (e) {
|
||||
console.error('Failed to clear logs:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePause = () => {
|
||||
if (!paused && eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
setPaused(!paused)
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
实时日志
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={togglePause}
|
||||
icon={paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
>
|
||||
{paused ? '继续' : '暂停'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500 text-center py-8">等待日志...</div>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50">
|
||||
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span>
|
||||
{log.step && (
|
||||
<span
|
||||
className={`px-1.5 rounded text-[10px] uppercase flex-shrink-0 ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
|
||||
>
|
||||
{stepLabels[log.step] || log.step}
|
||||
</span>
|
||||
)}
|
||||
<span className={`flex-shrink-0 ${levelColors[log.level] || 'text-slate-300'}`}>
|
||||
{log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
|
||||
</span>
|
||||
<span className="text-slate-300 break-all">{log.message}</span>
|
||||
{log.email && (
|
||||
<span className="text-slate-500 flex-shrink-0 ml-auto">[{log.email}]</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/upload/OwnerList.tsx
Normal file
218
frontend/src/components/upload/OwnerList.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||
|
||||
interface TeamOwner {
|
||||
id: number
|
||||
email: string
|
||||
account_id: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface OwnerListProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
valid: '有效',
|
||||
registered: '已注册',
|
||||
pooled: '已入库',
|
||||
}
|
||||
|
||||
export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) {
|
||||
const [owners, setOwners] = useState<TeamOwner[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const limit = 20
|
||||
|
||||
const loadOwners = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(page * limit),
|
||||
})
|
||||
if (filter) {
|
||||
params.set('status', filter)
|
||||
}
|
||||
|
||||
const res = await fetch(`${apiBase}/api/db/owners?${params}`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setOwners(data.data.owners || [])
|
||||
setTotal(data.data.total || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load owners:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOwners()
|
||||
}, [page, filter])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确认删除此账号?')) return
|
||||
try {
|
||||
await fetch(`${apiBase}/api/db/owners/${id}`, { method: 'DELETE' })
|
||||
loadOwners()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm('确认清空所有账号?此操作不可恢复!')) return
|
||||
try {
|
||||
await fetch(`${apiBase}/api/db/owners/clear`, { method: 'POST' })
|
||||
loadOwners()
|
||||
} catch (e) {
|
||||
console.error('Failed to clear:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
const formatTime = (ts: string) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
母号列表 ({total})
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="px-2 py-1 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="valid">有效</option>
|
||||
<option value="registered">已注册</option>
|
||||
<option value="pooled">已入库</option>
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">邮箱</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">状态</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">创建时间</th>
|
||||
<th className="text-center p-3 font-medium text-slate-600 dark:text-slate-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : owners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
owners.map((owner) => (
|
||||
<tr key={owner.id} className="border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
|
||||
<td className="p-3 font-mono text-xs text-slate-500">{owner.account_id?.slice(0, 20)}...</td>
|
||||
<td className="p-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[owner.status] || 'bg-slate-100 text-slate-700'}`}>
|
||||
{statusLabels[owner.status] || owner.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-slate-500 text-xs">{formatTime(owner.created_at)}</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(owner.id)}
|
||||
className="text-red-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex-shrink-0 p-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-500">
|
||||
第 {page + 1} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
icon={<ChevronLeft className="h-4 w-4" />}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
icon={<ChevronRight className="h-4 w-4" />}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/upload/PoolActions.tsx
Normal file
141
frontend/src/components/upload/PoolActions.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react'
|
||||
import { Upload, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import type { CheckedAccount } from '../../types'
|
||||
import { Button, Progress } from '../common'
|
||||
|
||||
interface PoolActionsProps {
|
||||
selectedAccounts: CheckedAccount[]
|
||||
onPool: (accounts: CheckedAccount[]) => Promise<{ success: number; failed: number }>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function PoolActions({
|
||||
selectedAccounts,
|
||||
onPool,
|
||||
disabled = false,
|
||||
}: PoolActionsProps) {
|
||||
const [pooling, setPooling] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [result, setResult] = useState<{ success: number; failed: number } | null>(null)
|
||||
|
||||
const activeSelected = selectedAccounts.filter((acc) => acc.status === 'active')
|
||||
const hasNonActive = selectedAccounts.some((acc) => acc.status !== 'active')
|
||||
|
||||
const handlePool = async () => {
|
||||
if (selectedAccounts.length === 0) return
|
||||
|
||||
setPooling(true)
|
||||
setProgress({ current: 0, total: selectedAccounts.length })
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const poolResult = await onPool(selectedAccounts)
|
||||
setResult(poolResult)
|
||||
} catch (error) {
|
||||
console.error('Pool error:', error)
|
||||
setResult({ success: 0, failed: selectedAccounts.length })
|
||||
} finally {
|
||||
setPooling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAccounts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
|
||||
请先选择要入库的账号
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Warning for non-active accounts */}
|
||||
{hasNonActive && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||
选中的账号包含非正常状态
|
||||
</p>
|
||||
<p className="text-yellow-700 dark:text-yellow-300">
|
||||
建议只入库状态为"正常"的账号,非正常账号可能无法正常使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">即将入库</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{selectedAccounts.length} 个账号
|
||||
</p>
|
||||
{activeSelected.length !== selectedAccounts.length && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
其中 {activeSelected.length} 个正常账号
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePool}
|
||||
disabled={disabled || pooling}
|
||||
loading={pooling}
|
||||
icon={pooling ? undefined : <Upload className="h-4 w-4" />}
|
||||
>
|
||||
{pooling ? '入库中...' : '入库选中'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{pooling && (
|
||||
<div>
|
||||
<Progress
|
||||
value={progress.current}
|
||||
max={progress.total}
|
||||
color="blue"
|
||||
showLabel
|
||||
label="入库进度"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-4 rounded-lg ${
|
||||
result.failed === 0
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{result.failed === 0 ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
result.failed === 0
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-yellow-800 dark:text-yellow-200'
|
||||
}`}
|
||||
>
|
||||
入库完成
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
result.failed === 0
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-yellow-700 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
成功 {result.success} 个,失败 {result.failed} 个
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
frontend/src/components/upload/index.ts
Normal file
4
frontend/src/components/upload/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as FileDropzone } from './FileDropzone'
|
||||
export { default as AccountTable } from './AccountTable'
|
||||
export { default as CheckProgress } from './CheckProgress'
|
||||
export { default as PoolActions } from './PoolActions'
|
||||
Reference in New Issue
Block a user