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:
Your Name
2026-02-03 17:44:22 +08:00
co-authored by Claude Opus 4.5
parent dd06502004
commit f166c04422
22 changed files with 2509 additions and 1 deletions
+104
View File
@@ -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;
+81
View File
@@ -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;
+112
View File
@@ -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;
+166
View File
@@ -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;
+129
View File
@@ -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;
+90
View File
@@ -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;
+98
View File
@@ -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;