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,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;
|
||||
@@ -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;
|
||||
@@ -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