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
+22
View File
@@ -0,0 +1,22 @@
/**
* SmartAudit 组件库统一导出
* 基于 UIDesignSpec.md 设计规范
*/
// UI 基础组件
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './ui/Button';
export { Card, CardHeader, CardTitle, CardContent, CardFooter, type CardProps } from './ui/Card';
export { Tag, SuccessTag, PendingTag, WarningTag, ErrorTag, type TagProps, type TagStatus } from './ui/Tag';
export { Input, SearchInput, PasswordInput, type InputProps } from './ui/Input';
export { Select, type SelectProps, type SelectOption } from './ui/Select';
export { ProgressBar, CircularProgress, type ProgressBarProps, type CircularProgressProps } from './ui/ProgressBar';
export { Modal, ConfirmModal, type ModalProps, type ConfirmModalProps } from './ui/Modal';
// 导航组件
export { BottomNav, type BottomNavProps, type NavItem } from './navigation/BottomNav';
export { Sidebar, type SidebarProps, type SidebarItem, type SidebarSection } from './navigation/Sidebar';
export { StatusBar, type StatusBarProps } from './navigation/StatusBar';
// 布局组件
export { MobileLayout, type MobileLayoutProps } from './layout/MobileLayout';
export { DesktopLayout, type DesktopLayoutProps } from './layout/DesktopLayout';
@@ -0,0 +1,61 @@
/**
* DesktopLayout 桌面端布局组件
* 设计稿参考: UIDesignSpec.md 3.2
* 尺寸: 1440x900,侧边栏260px
*/
import React from 'react';
import { Sidebar, SidebarSection } from '../navigation/Sidebar';
export interface DesktopLayoutProps {
children: React.ReactNode;
logo?: React.ReactNode;
sidebarSections: SidebarSection[];
activeNavId: string;
onNavItemClick?: (id: string) => void;
sidebarFooter?: React.ReactNode;
headerContent?: React.ReactNode;
className?: string;
contentClassName?: string;
}
export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
children,
logo,
sidebarSections,
activeNavId,
onNavItemClick,
sidebarFooter,
headerContent,
className = '',
contentClassName = '',
}) => {
return (
<div className={`min-h-screen bg-bg-page ${className}`}>
{/* Sidebar */}
<Sidebar
logo={logo}
sections={sidebarSections}
activeId={activeNavId}
onItemClick={onNavItemClick}
footer={sidebarFooter}
/>
{/* Main Content */}
<div className="ml-sidebar">
{/* Header (optional) */}
{headerContent && (
<header className="px-8 py-4 border-b border-border-subtle bg-bg-page sticky top-0 z-10">
{headerContent}
</header>
)}
{/* Content Area */}
<main className={`p-8 ${contentClassName}`}>
{children}
</main>
</div>
</div>
);
};
export default DesktopLayout;
@@ -0,0 +1,66 @@
/**
* MobileLayout 移动端布局组件
* 设计稿参考: UIDesignSpec.md 3.1
* 尺寸: 402x874
*/
import React from 'react';
import { StatusBar } from '../navigation/StatusBar';
import { BottomNav, NavItem } from '../navigation/BottomNav';
export interface MobileLayoutProps {
children: React.ReactNode;
navItems?: NavItem[];
activeNavId?: string;
onNavItemClick?: (id: string) => void;
showStatusBar?: boolean;
showBottomNav?: boolean;
className?: string;
contentClassName?: string;
}
export const MobileLayout: React.FC<MobileLayoutProps> = ({
children,
navItems = [],
activeNavId = '',
onNavItemClick,
showStatusBar = true,
showBottomNav = true,
className = '',
contentClassName = '',
}) => {
return (
<div
className={`
min-h-screen bg-bg-page
flex flex-col
${className}
`}
>
{/* Status Bar */}
{showStatusBar && <StatusBar />}
{/* Content Area */}
<main
className={`
flex-1 overflow-y-auto
px-6 py-4
${showBottomNav ? 'pb-[99px]' : ''}
${contentClassName}
`}
>
{children}
</main>
{/* Bottom Navigation */}
{showBottomNav && navItems.length > 0 && (
<BottomNav
items={navItems}
activeId={activeNavId}
onItemClick={onNavItemClick}
/>
)}
</div>
);
};
export default MobileLayout;
@@ -0,0 +1,81 @@
/**
* BottomNav 底部导航组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.6
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
export interface NavItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
}
export interface BottomNavProps {
items: NavItem[];
activeId: string;
onItemClick?: (id: string) => void;
className?: string;
}
export const BottomNav: React.FC<BottomNavProps> = ({
items,
activeId,
onItemClick,
className = '',
}) => {
return (
<nav
className={`
fixed bottom-0 left-0 right-0 z-bottom-nav
flex justify-around items-center
h-bottom-nav px-[21px] py-3
safe-area-bottom
${className}
`}
style={{
background: 'linear-gradient(180deg, transparent 0%, #0B0B0E 50%)',
}}
>
{items.map((item) => {
const isActive = item.id === activeId;
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => onItemClick?.(item.id)}
className={`
flex flex-col items-center gap-1
transition-colors duration-200
${isActive ? 'text-accent-indigo' : 'text-text-secondary'}
`}
>
<div className="relative">
<Icon size={24} />
{item.badge !== undefined && item.badge > 0 && (
<span
className="
absolute -top-1 -right-1
min-w-[16px] h-4 px-1
flex items-center justify-center
bg-accent-coral text-white
text-[10px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</div>
<span className="text-nav">{item.label}</span>
</button>
);
})}
</nav>
);
};
export default BottomNav;
+136
View File
@@ -0,0 +1,136 @@
/**
* Sidebar 侧边栏导航组件 (桌面端)
* 设计稿参考: UIDesignSpec.md 3.7
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
export interface SidebarItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
children?: SidebarItem[];
}
export interface SidebarSection {
title?: string;
items: SidebarItem[];
}
export interface SidebarProps {
logo?: React.ReactNode;
sections: SidebarSection[];
activeId: string;
onItemClick?: (id: string) => void;
footer?: React.ReactNode;
className?: string;
}
export const Sidebar: React.FC<SidebarProps> = ({
logo,
sections,
activeId,
onItemClick,
footer,
className = '',
}) => {
return (
<aside
className={`
fixed left-0 top-0 bottom-0 z-sidebar
w-sidebar bg-bg-card
flex flex-col
border-r border-border-subtle
${className}
`}
>
{/* Logo */}
{logo && (
<div className="px-4 py-5 border-b border-border-subtle">
{logo}
</div>
)}
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="mb-6">
{section.title && (
<h4 className="px-3 mb-2 text-small text-text-tertiary uppercase tracking-wider">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
isActive={item.id === activeId}
onClick={() => onItemClick?.(item.id)}
/>
))}
</ul>
</div>
))}
</nav>
{/* Footer */}
{footer && (
<div className="px-4 py-4 border-t border-border-subtle">
{footer}
</div>
)}
</aside>
);
};
interface SidebarNavItemProps {
item: SidebarItem;
isActive: boolean;
onClick: () => void;
}
const SidebarNavItem: React.FC<SidebarNavItemProps> = ({
item,
isActive,
onClick,
}) => {
const Icon = item.icon;
return (
<li>
<button
onClick={onClick}
className={`
w-full flex items-center gap-2.5
px-3 py-2.5 rounded-btn
transition-colors duration-200
${isActive
? 'bg-bg-elevated text-accent-indigo font-semibold'
: 'text-text-secondary hover:bg-bg-elevated'
}
`}
>
<Icon size={20} className="flex-shrink-0" />
<span className="flex-1 text-left text-body">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span
className="
min-w-[20px] h-5 px-1.5
flex items-center justify-center
bg-accent-coral text-white
text-[11px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</button>
</li>
);
};
export default Sidebar;
@@ -0,0 +1,41 @@
/**
* StatusBar 状态栏组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.1
*/
import React from 'react';
import { Signal, Wifi, BatteryFull } from 'lucide-react';
export interface StatusBarProps {
time?: string;
className?: string;
}
export const StatusBar: React.FC<StatusBarProps> = ({
time = '9:41',
className = '',
}) => {
return (
<div
className={`
flex items-center justify-between
h-status-bar px-6
bg-bg-page safe-area-top
${className}
`}
>
{/* Time */}
<span className="text-body font-semibold text-text-primary">
{time}
</span>
{/* Status Icons */}
<div className="flex items-center gap-1">
<Signal size={16} className="text-text-primary" />
<Wifi size={16} className="text-text-primary" />
<BatteryFull size={16} className="text-text-primary" />
</div>
</div>
);
};
export default StatusBar;
+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;