Add frontend component library and UI development tasks
- Create Tailwind CSS configuration with design tokens from UIDesignSpec - Create globals.css with CSS variables and component styles - Add React component library: - UI components: Button, Card, Tag, Input, Select, ProgressBar, Modal - Navigation: BottomNav, Sidebar, StatusBar - Layout: MobileLayout, DesktopLayout - Add constants for colors, icons, and layout - Update tasks.md with 31 UI development tasks linked to design node IDs - Configure package.json, tsconfig.json, and postcss.config.js Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
dd06502004
commit
f166c04422
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Button 按钮组件
|
||||
* 设计稿参考: UIDesignSpec.md 3.4
|
||||
*/
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: LucideIcon;
|
||||
iconPosition?: 'left' | 'right';
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-accent-indigo text-white hover:opacity-90 active:opacity-80',
|
||||
secondary: 'bg-bg-elevated text-text-secondary hover:bg-opacity-80',
|
||||
danger: 'bg-accent-coral text-white hover:opacity-90 active:opacity-80',
|
||||
success: 'bg-accent-green text-white hover:opacity-90 active:opacity-80',
|
||||
ghost: 'bg-transparent text-text-secondary hover:bg-bg-elevated',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-small',
|
||||
md: 'px-4 py-2.5 text-body',
|
||||
lg: 'px-6 py-3 text-section-title',
|
||||
};
|
||||
|
||||
const iconSizes: Record<ButtonSize, number> = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
iconPosition = 'left',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = disabled || loading;
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 font-semibold
|
||||
rounded-btn transition-all duration-200
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
${className}
|
||||
`}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin"
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{!loading && Icon && iconPosition === 'left' && (
|
||||
<Icon size={iconSize} />
|
||||
)}
|
||||
{children}
|
||||
{!loading && Icon && iconPosition === 'right' && (
|
||||
<Icon size={iconSize} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Card 卡片组件
|
||||
* 设计稿参考: UIDesignSpec.md 3.3
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'elevated';
|
||||
padding?: 'mobile' | 'desktop' | 'none';
|
||||
onClick?: () => void;
|
||||
hoverable?: boolean;
|
||||
}
|
||||
|
||||
const paddingStyles = {
|
||||
mobile: 'p-[14px_16px]',
|
||||
desktop: 'p-[16px_20px]',
|
||||
none: 'p-0',
|
||||
};
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
padding = 'mobile',
|
||||
onClick,
|
||||
hoverable = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-bg-card rounded-card
|
||||
${paddingStyles[padding]}
|
||||
${variant === 'elevated' ? 'bg-bg-elevated shadow-elevated' : ''}
|
||||
${hoverable ? 'cursor-pointer transition-all duration-200 hover:bg-bg-elevated hover:shadow-card' : ''}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<div className={`flex items-center justify-between mb-3 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardTitle: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<h3 className={`text-section-title text-text-primary font-semibold ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const CardContent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<div className={className}>{children}</div>
|
||||
);
|
||||
|
||||
export const CardFooter: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => (
|
||||
<div className={`mt-4 pt-4 border-t border-border-subtle ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Input 输入框组件
|
||||
* 设计稿参考: UIDesignSpec.md
|
||||
*/
|
||||
import React, { forwardRef } from 'react';
|
||||
import { LucideIcon, Search, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
leftIcon?: LucideIcon;
|
||||
rightIcon?: LucideIcon;
|
||||
onRightIconClick?: () => void;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
leftIcon: LeftIcon,
|
||||
rightIcon: RightIcon,
|
||||
onRightIconClick,
|
||||
fullWidth = true,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : ''}`}>
|
||||
{label && (
|
||||
<label className="block mb-1.5 text-caption text-text-secondary">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{LeftIcon && (
|
||||
<LeftIcon
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full bg-bg-elevated text-text-primary
|
||||
border border-border-subtle rounded-btn
|
||||
px-4 py-2.5 text-body
|
||||
transition-colors duration-200
|
||||
placeholder:text-text-tertiary
|
||||
focus:outline-none focus:border-accent-indigo
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${LeftIcon ? 'pl-10' : ''}
|
||||
${RightIcon ? 'pr-10' : ''}
|
||||
${error ? 'border-accent-coral focus:border-accent-coral' : ''}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
{RightIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRightIconClick}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary"
|
||||
>
|
||||
<RightIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-small text-accent-coral">{error}</p>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-small text-text-tertiary">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
// 搜索输入框
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Omit<InputProps, 'leftIcon'>>(
|
||||
(props, ref) => (
|
||||
<Input ref={ref} leftIcon={Search} placeholder="搜索..." {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
|
||||
// 密码输入框
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, Omit<InputProps, 'type' | 'rightIcon'>>(
|
||||
(props, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
rightIcon={showPassword ? EyeOff : Eye}
|
||||
onRightIconClick={() => setShowPassword(!showPassword)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PasswordInput.displayName = 'PasswordInput';
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Modal 弹窗组件
|
||||
* 设计稿参考: UIDesignSpec.md
|
||||
*/
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
closeOnOverlay?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
full: 'max-w-[90vw] max-h-[90vh]',
|
||||
};
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
closeOnOverlay = true,
|
||||
closeOnEsc = true,
|
||||
showCloseButton = true,
|
||||
className = '',
|
||||
}) => {
|
||||
// Handle ESC key
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (closeOnEsc && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}, [closeOnEsc, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-modal flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-xs"
|
||||
onClick={closeOnOverlay ? onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className={`
|
||||
relative w-full mx-4
|
||||
bg-bg-card rounded-card
|
||||
shadow-elevated
|
||||
animate-scale-in
|
||||
${sizeStyles[size]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
||||
{title && (
|
||||
<h2 className="text-section-title text-text-primary font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 max-h-[60vh] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-border-subtle flex justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 确认弹窗
|
||||
export interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === 'danger' ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-body text-text-secondary">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ProgressBar 进度条组件
|
||||
* 用于审核进度展示
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
value: number; // 0-100
|
||||
max?: number;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'success' | 'warning' | 'error';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'h-1',
|
||||
md: 'h-2',
|
||||
lg: 'h-3',
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-accent-indigo',
|
||||
success: 'bg-accent-green',
|
||||
warning: 'bg-accent-amber',
|
||||
error: 'bg-accent-coral',
|
||||
};
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
value,
|
||||
max = 100,
|
||||
showLabel = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
className = '',
|
||||
}) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{showLabel && (
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-small text-text-secondary">进度</span>
|
||||
<span className="text-small text-text-primary">{Math.round(percentage)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-full bg-bg-elevated rounded-full overflow-hidden ${sizeStyles[size]}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${variantStyles[variant]}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 环形进度条 (用于审核中状态)
|
||||
export interface CircularProgressProps {
|
||||
value: number; // 0-100
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error';
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const circularVariantColors = {
|
||||
default: '#6366F1',
|
||||
success: '#32D583',
|
||||
warning: '#F59E0B',
|
||||
error: '#E85A4F',
|
||||
};
|
||||
|
||||
export const CircularProgress: React.FC<CircularProgressProps> = ({
|
||||
value,
|
||||
size = 120,
|
||||
strokeWidth = 8,
|
||||
variant = 'default',
|
||||
showLabel = true,
|
||||
label,
|
||||
className = '',
|
||||
}) => {
|
||||
const percentage = Math.min(100, Math.max(0, value));
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex items-center justify-center ${className}`}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#27272A"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={circularVariantColors[variant]}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
{showLabel && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-card-title text-text-primary">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-small text-text-tertiary mt-1">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Select 下拉选择组件
|
||||
* 设计稿参考: UIDesignSpec.md
|
||||
*/
|
||||
import React, { forwardRef } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
options,
|
||||
placeholder,
|
||||
fullWidth = true,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className={`${fullWidth ? 'w-full' : ''}`}>
|
||||
{label && (
|
||||
<label className="block mb-1.5 text-caption text-text-secondary">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full bg-bg-elevated text-text-primary
|
||||
border border-border-subtle rounded-btn
|
||||
px-4 py-2.5 text-body
|
||||
appearance-none cursor-pointer
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:border-accent-indigo
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${error ? 'border-accent-coral focus:border-accent-coral' : ''}
|
||||
${className}
|
||||
`}
|
||||
disabled={disabled}
|
||||
{...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
|
||||
size={18}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-small text-accent-coral">{error}</p>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-small text-text-tertiary">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default Select;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tag 状态标签组件
|
||||
* 设计稿参考: UIDesignSpec.md 3.5
|
||||
*/
|
||||
import React from 'react';
|
||||
import { LucideIcon, Check, Clock, AlertTriangle, XCircle } from 'lucide-react';
|
||||
|
||||
export type TagStatus = 'success' | 'pending' | 'warning' | 'error';
|
||||
export type TagSize = 'sm' | 'md';
|
||||
|
||||
export interface TagProps {
|
||||
status: TagStatus;
|
||||
children: React.ReactNode;
|
||||
size?: TagSize;
|
||||
icon?: LucideIcon | boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusStyles: Record<TagStatus, { bg: string; text: string; defaultIcon: LucideIcon }> = {
|
||||
success: {
|
||||
bg: 'bg-status-success',
|
||||
text: 'text-accent-green',
|
||||
defaultIcon: Check,
|
||||
},
|
||||
pending: {
|
||||
bg: 'bg-status-pending',
|
||||
text: 'text-accent-indigo',
|
||||
defaultIcon: Clock,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-status-warning',
|
||||
text: 'text-accent-amber',
|
||||
defaultIcon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-status-error',
|
||||
text: 'text-accent-coral',
|
||||
defaultIcon: XCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const sizeStyles: Record<TagSize, { padding: string; text: string; iconSize: number }> = {
|
||||
sm: { padding: 'px-1.5 py-0.5', text: 'text-[11px]', iconSize: 12 },
|
||||
md: { padding: 'px-2 py-1', text: 'text-small', iconSize: 14 },
|
||||
};
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({
|
||||
status,
|
||||
children,
|
||||
size = 'md',
|
||||
icon,
|
||||
className = '',
|
||||
}) => {
|
||||
const styles = statusStyles[status];
|
||||
const sizeStyle = sizeStyles[size];
|
||||
|
||||
const showIcon = icon !== false;
|
||||
const IconComponent = icon === true || icon === undefined ? styles.defaultIcon : icon;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1 font-medium rounded-tag
|
||||
${styles.bg} ${styles.text}
|
||||
${sizeStyle.padding} ${sizeStyle.text}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{showIcon && IconComponent && (
|
||||
<IconComponent size={sizeStyle.iconSize} />
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 预定义的状态标签
|
||||
export const SuccessTag: React.FC<{ children: React.ReactNode; size?: TagSize }> = ({
|
||||
children,
|
||||
size,
|
||||
}) => <Tag status="success" size={size}>{children}</Tag>;
|
||||
|
||||
export const PendingTag: React.FC<{ children: React.ReactNode; size?: TagSize }> = ({
|
||||
children,
|
||||
size,
|
||||
}) => <Tag status="pending" size={size}>{children}</Tag>;
|
||||
|
||||
export const WarningTag: React.FC<{ children: React.ReactNode; size?: TagSize }> = ({
|
||||
children,
|
||||
size,
|
||||
}) => <Tag status="warning" size={size}>{children}</Tag>;
|
||||
|
||||
export const ErrorTag: React.FC<{ children: React.ReactNode; size?: TagSize }> = ({
|
||||
children,
|
||||
size,
|
||||
}) => <Tag status="error" size={size}>{children}</Tag>;
|
||||
|
||||
export default Tag;
|
||||
Reference in New Issue
Block a user