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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export { default as StatsCard } from './StatsCard'
export { default as PoolStatus } from './PoolStatus'
export { default as RecentRecords } from './RecentRecords'

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View File

@@ -0,0 +1,3 @@
export { default as Layout } from './Layout'
export { default as Header } from './Header'
export { default as Sidebar } from './Sidebar'

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { default as RecordList } from './RecordList'
export { default as RecordStats } from './RecordStats'

View 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>
)
}

View 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>
)
}

View 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">
: [&#123;"account": "email", "password": "pwd", "token": "..."&#125;]
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'