"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* @fileoverview 颜色面板组件
|
||||
* @description 展示和管理设计系统中的颜色令牌
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Search, Filter, Grid, List, Plus, MoreVertical } from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader } from '../ui/Card';
|
||||
import { Button, IconButton } from '../ui/Button';
|
||||
import { ColorToken } from '../../types/design-system';
|
||||
import { useColors, useDesignSystemStore } from '../../store/design-system-store';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
// 颜色面板属性接口
|
||||
interface ColorPaletteProps {
|
||||
title?: string;
|
||||
variant?: 'grid' | 'list' | 'swatch';
|
||||
showControls?: boolean;
|
||||
interactive?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 颜色色板属性接口
|
||||
interface ColorSwatchProps {
|
||||
token: ColorToken;
|
||||
variant?: 'default' | 'compact' | 'large';
|
||||
showLabel?: boolean;
|
||||
showValue?: boolean;
|
||||
interactive?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色色板组件
|
||||
*/
|
||||
const ColorSwatch: React.FC<ColorSwatchProps> = ({
|
||||
token,
|
||||
variant = 'default',
|
||||
showLabel = true,
|
||||
showValue = true,
|
||||
interactive = true,
|
||||
onClick,
|
||||
className
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { setSelectedToken } = useDesignSystemStore();
|
||||
|
||||
// 复制颜色值
|
||||
const copyColor = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(token.value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy color:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理点击
|
||||
const handleClick = () => {
|
||||
if (interactive) {
|
||||
setSelectedToken(token);
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
// 尺寸配置
|
||||
const sizeConfig = {
|
||||
default: { width: 'w-full', height: 'h-20', padding: 'p-4' },
|
||||
compact: { width: 'w-full', height: 'h-12', padding: 'p-2' },
|
||||
large: { width: 'w-full', height: 'h-32', padding: 'p-6' },
|
||||
};
|
||||
|
||||
const config = sizeConfig[variant];
|
||||
|
||||
// 显示文本颜色(基于背景亮度)
|
||||
const getContrastColor = (hexColor: string) => {
|
||||
// 简单的亮度计算
|
||||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return brightness > 128 ? '#000000' : '#ffffff';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={interactive ? { scale: 1.02 } : {}}
|
||||
whileTap={interactive ? { scale: 0.98 } : {}}
|
||||
className={clsx(
|
||||
'color-swatch cursor-pointer group relative overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700 transition-all duration-200',
|
||||
config.width,
|
||||
config.height,
|
||||
{
|
||||
'hover:shadow-lg': interactive,
|
||||
'hover:border-primary-300': interactive,
|
||||
},
|
||||
className
|
||||
)}
|
||||
data-component="ColorSwatch"
|
||||
data-element="swatch"
|
||||
data-token-name={token.name}
|
||||
data-palette={token.palette}
|
||||
data-scale={token.scale}
|
||||
style={{ backgroundColor: token.value }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 颜色背景 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: token.value }}
|
||||
data-element="background"
|
||||
/>
|
||||
|
||||
{/* 覆盖层 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-200"
|
||||
data-element="overlay"
|
||||
/>
|
||||
|
||||
{/* 色板内容 */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative z-10 flex flex-col justify-between h-full',
|
||||
config.padding
|
||||
)}
|
||||
data-element="content"
|
||||
>
|
||||
{/* 顶部标签 */}
|
||||
{showLabel && (
|
||||
<div className="flex items-center justify-between" data-element="header">
|
||||
<div className="flex items-center space-x-2" data-element="labels">
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-semibold rounded backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
color: getContrastColor(token.value)
|
||||
}}
|
||||
data-element="name"
|
||||
>
|
||||
{token.name}
|
||||
</span>
|
||||
{token.deprecated && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-warning-500 text-white rounded backdrop-blur-sm">
|
||||
Deprecated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{interactive && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={copyColor}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-component="IconButton"
|
||||
data-element="copy-button"
|
||||
data-action="copy-color"
|
||||
>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-success-400"
|
||||
>
|
||||
✓
|
||||
</motion.div>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-mono"
|
||||
style={{ color: getContrastColor(token.value) }}
|
||||
>
|
||||
#
|
||||
</span>
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部信息 */}
|
||||
{showValue && (
|
||||
<div className="space-y-1" data-element="footer">
|
||||
<div
|
||||
className="text-xs font-mono backdrop-blur-sm px-2 py-1 rounded"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
color: getContrastColor(token.value)
|
||||
}}
|
||||
data-element="value"
|
||||
>
|
||||
{token.value}
|
||||
</div>
|
||||
|
||||
{token.scale && token.palette && (
|
||||
<div
|
||||
className="text-xs backdrop-blur-sm px-2 py-1 rounded"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
color: getContrastColor(token.value)
|
||||
}}
|
||||
data-element="metadata"
|
||||
>
|
||||
{token.palette} • {token.scale}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选择指示器 */}
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute top-2 right-2 w-3 h-3 bg-primary-500 rounded-full border-2 border-white shadow-sm"
|
||||
data-element="selected-indicator"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 颜色面板主组件
|
||||
*/
|
||||
const ColorPalette: React.FC<ColorPaletteProps> = ({
|
||||
title = 'Color Palette',
|
||||
variant = 'grid',
|
||||
showControls = true,
|
||||
interactive = true,
|
||||
className
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPalette, setSelectedPalette] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const colors = useColors();
|
||||
const { setSelectedToken, selectedToken } = useDesignSystemStore();
|
||||
|
||||
// 过滤和搜索颜色
|
||||
const filteredColors = useMemo(() => {
|
||||
return colors.filter(color => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
color.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
color.value.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
color.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesPalette = selectedPalette === null ||
|
||||
color.palette === selectedPalette;
|
||||
|
||||
return matchesSearch && matchesPalette;
|
||||
});
|
||||
}, [colors, searchQuery, selectedPalette]);
|
||||
|
||||
// 按调色板分组
|
||||
const colorsByPalette = useMemo(() => {
|
||||
const grouped: Record<string, ColorToken[]> = {};
|
||||
filteredColors.forEach(color => {
|
||||
const palette = color.palette || 'other';
|
||||
if (!grouped[palette]) {
|
||||
grouped[palette] = [];
|
||||
}
|
||||
grouped[palette].push(color);
|
||||
});
|
||||
return grouped;
|
||||
}, [filteredColors]);
|
||||
|
||||
// 获取所有调色板
|
||||
const palettes = useMemo(() => {
|
||||
const paletteSet = new Set(colors.map(color => color.palette || 'other'));
|
||||
return Array.from(paletteSet);
|
||||
}, [colors]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('color-palette', className)}
|
||||
data-component="ColorPalette"
|
||||
data-element="palette"
|
||||
data-variant={variant}
|
||||
data-view-mode={viewMode}
|
||||
>
|
||||
{/* 控制面板 */}
|
||||
{showControls && (
|
||||
<Card className="mb-6">
|
||||
<CardBody className="p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||
{/* 标题 */}
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search colors..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
data-component="Input"
|
||||
data-element="search-input"
|
||||
data-placeholder="search-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 过滤器按钮 */}
|
||||
<IconButton
|
||||
variant={showFilters ? 'primary' : 'ghost'}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
data-component="IconButton"
|
||||
data-element="filter-button"
|
||||
data-action="toggle-filters"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
</IconButton>
|
||||
|
||||
{/* 视图模式切换 */}
|
||||
<div className="flex border border-neutral-300 dark:border-neutral-600 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
)}
|
||||
data-component="ToggleButton"
|
||||
data-element="grid-view-button"
|
||||
data-action="set-view-grid"
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
)}
|
||||
data-component="ToggleButton"
|
||||
data-element="list-view-button"
|
||||
data-action="set-view-list"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 过滤器面板 */}
|
||||
<AnimatePresence>
|
||||
{showFilters && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800"
|
||||
data-element="filters"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedPalette(null)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm rounded-full border transition-colors',
|
||||
selectedPalette === null
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
)}
|
||||
data-component="FilterChip"
|
||||
data-element="all-palettes-chip"
|
||||
data-action="select-all-palettes"
|
||||
>
|
||||
All Palettes
|
||||
</button>
|
||||
|
||||
{palettes.map(palette => (
|
||||
<button
|
||||
key={palette}
|
||||
onClick={() => setSelectedPalette(palette)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 text-sm rounded-full border transition-colors capitalize',
|
||||
selectedPalette === palette
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
)}
|
||||
data-component="FilterChip"
|
||||
data-element={`${palette}-palette-chip`}
|
||||
data-action={`select-${palette}-palette`}
|
||||
>
|
||||
{palette}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 颜色网格/列表 */}
|
||||
<div className="space-y-8">
|
||||
{Object.entries(colorsByPalette).map(([palette, paletteColors]) => (
|
||||
<div key={palette} className="palette-group" data-element="palette-group" data-palette={palette}>
|
||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 mb-4 capitalize">
|
||||
{palette} Palette ({paletteColors.length})
|
||||
</h4>
|
||||
|
||||
<div className={clsx(
|
||||
'grid gap-4',
|
||||
{
|
||||
'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8': viewMode === 'grid',
|
||||
'space-y-2': viewMode === 'list',
|
||||
}
|
||||
)}>
|
||||
<AnimatePresence>
|
||||
{paletteColors.map((color, index) => (
|
||||
<motion.div
|
||||
key={color.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<ColorSwatch
|
||||
token={color}
|
||||
variant={viewMode === 'list' ? 'compact' : 'default'}
|
||||
showLabel={viewMode === 'grid'}
|
||||
showValue={viewMode === 'grid'}
|
||||
interactive={interactive}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 空状态 */}
|
||||
{filteredColors.length === 0 && (
|
||||
<div className="text-center py-12" data-element="empty-state">
|
||||
<div className="w-12 h-12 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="w-6 h-6 text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
No colors found
|
||||
</h3>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Try adjusting your search or filter criteria.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedPalette(null);
|
||||
}}
|
||||
data-component="Button"
|
||||
data-element="clear-filters-button"
|
||||
data-action="clear-all-filters"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ColorPalette, ColorSwatch };
|
||||
export type { ColorPaletteProps, ColorSwatchProps };
|
||||
|
||||
export default ColorPalette;
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* @fileoverview 令牌卡片组件
|
||||
* @description 展示设计系统令牌的可视化组件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Copy, Check, Edit3, Trash2, Tag } from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader } from '../ui/Card';
|
||||
import { Button, IconButton } from '../ui/Button';
|
||||
import { DesignToken, ColorToken, TypographyToken, SpacingToken, ShadowToken, BorderRadiusToken, AnimationToken } from '../../types/design-system';
|
||||
import { useDesignSystemStore } from '../../store/design-system-store';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
// 令牌卡片属性接口
|
||||
interface TokenCardProps {
|
||||
token: DesignToken;
|
||||
variant?: 'default' | 'compact' | 'detailed';
|
||||
interactive?: boolean;
|
||||
showActions?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌卡片主组件
|
||||
*/
|
||||
const TokenCard: React.FC<TokenCardProps> = ({
|
||||
token,
|
||||
variant = 'default',
|
||||
interactive = true,
|
||||
showActions = true,
|
||||
className
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { setSelectedToken } = useDesignSystemStore();
|
||||
|
||||
// 复制令牌值
|
||||
const copyToken = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(token.value));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择令牌
|
||||
const handleSelect = () => {
|
||||
if (interactive) {
|
||||
setSelectedToken(token);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染令牌预览
|
||||
const renderTokenPreview = () => {
|
||||
switch (token.category) {
|
||||
case 'color':
|
||||
return <ColorTokenPreview token={token as ColorToken} />;
|
||||
case 'typography':
|
||||
return <TypographyTokenPreview token={token as TypographyToken} />;
|
||||
case 'spacing':
|
||||
return <SpacingTokenPreview token={token as SpacingToken} />;
|
||||
case 'shadow':
|
||||
return <ShadowTokenPreview token={token as ShadowToken} />;
|
||||
case 'border-radius':
|
||||
return <BorderRadiusTokenPreview token={token as BorderRadiusToken} />;
|
||||
case 'animation':
|
||||
return <AnimationTokenPreview token={token as AnimationToken} />;
|
||||
default:
|
||||
return <DefaultTokenPreview token={token} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染令牌信息
|
||||
const renderTokenInfo = () => {
|
||||
const categoryIcons = {
|
||||
color: '🎨',
|
||||
typography: '📝',
|
||||
spacing: '📏',
|
||||
shadow: '🌑',
|
||||
'border-radius': '🔘',
|
||||
'z-index': '📊',
|
||||
animation: '⚡',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="token-info" data-element="info">
|
||||
<div className="flex items-center space-x-2 mb-2" data-element="header">
|
||||
<span className="text-lg" data-element="icon">
|
||||
{categoryIcons[token.category]}
|
||||
</span>
|
||||
<h4 className="font-semibold text-sm text-neutral-900 dark:text-neutral-100" data-element="name">
|
||||
{token.name}
|
||||
</h4>
|
||||
{token.deprecated && (
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-100 text-warning-800 dark:bg-warning-900/40 dark:text-warning-200 rounded-full">
|
||||
Deprecated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{token.description && (
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-3" data-element="description">
|
||||
{token.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{token.tags && token.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3" data-element="tags">
|
||||
{token.tags.slice(0, 3).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 rounded"
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{token.tags.length > 3 && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
+{token.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={interactive ? { y: -2 } : {}}
|
||||
className={clsx(
|
||||
'token-card',
|
||||
{
|
||||
'cursor-pointer': interactive,
|
||||
'opacity-75': token.deprecated,
|
||||
},
|
||||
className
|
||||
)}
|
||||
data-component="TokenCard"
|
||||
data-element="card"
|
||||
data-category={token.category}
|
||||
data-name={token.name}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<Card
|
||||
variant="default"
|
||||
size="sm"
|
||||
hover={interactive}
|
||||
interactive={interactive}
|
||||
className="h-full"
|
||||
>
|
||||
<CardBody className="p-4">
|
||||
{/* 令牌预览 */}
|
||||
<div className="token-preview mb-4" data-element="preview">
|
||||
{renderTokenPreview()}
|
||||
</div>
|
||||
|
||||
{/* 令牌信息 */}
|
||||
{renderTokenInfo()}
|
||||
|
||||
{/* 令牌值和操作 */}
|
||||
<div className="token-actions flex items-center justify-between mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-800" data-element="actions">
|
||||
<div className="token-value flex items-center space-x-2" data-element="value">
|
||||
<code className="text-xs bg-neutral-100 dark:bg-neutral-800 px-2 py-1 rounded font-mono">
|
||||
{typeof token.value === 'object' ? JSON.stringify(token.value) : token.value}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="token-buttons flex items-center space-x-1" data-element="buttons">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToken();
|
||||
}}
|
||||
aria-label="Copy token value"
|
||||
data-component="IconButton"
|
||||
data-element="copy-button"
|
||||
data-action="copy-value"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-success-600" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{interactive && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(!isEditing);
|
||||
}}
|
||||
aria-label="Edit token"
|
||||
data-component="IconButton"
|
||||
data-element="edit-button"
|
||||
data-action="edit-token"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑模式 */}
|
||||
{isEditing && interactive && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="token-editor mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-800"
|
||||
data-element="editor"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Token Value
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full px-2 py-1 text-xs border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-800"
|
||||
rows={2}
|
||||
value={typeof token.value === 'object' ? JSON.stringify(token.value, null, 2) : String(token.value)}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 颜色令牌预览组件
|
||||
const ColorTokenPreview: React.FC<{ token: ColorToken }> = ({ token }) => {
|
||||
return (
|
||||
<div className="color-token-preview" data-element="color-preview">
|
||||
<div
|
||||
className="color-swatch w-full h-16 rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
style={{ backgroundColor: token.value }}
|
||||
data-element="swatch"
|
||||
/>
|
||||
<div className="color-info mt-2 text-center">
|
||||
<div className="text-xs font-mono" data-element="hex-value">
|
||||
{token.value}
|
||||
</div>
|
||||
{token.scale && (
|
||||
<div className="text-xs text-neutral-500" data-element="scale">
|
||||
{token.palette} {token.scale}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 字体令牌预览组件
|
||||
const TypographyTokenPreview: React.FC<{ token: TypographyToken }> = ({ token }) => {
|
||||
const { fontSize, fontWeight, lineHeight } = token.value;
|
||||
|
||||
return (
|
||||
<div className="typography-token-preview p-3 bg-neutral-50 dark:bg-neutral-800 rounded" data-element="typography-preview">
|
||||
<div
|
||||
style={{
|
||||
fontSize: fontSize || '1rem',
|
||||
fontWeight: fontWeight || 400,
|
||||
lineHeight: lineHeight || '1.5',
|
||||
}}
|
||||
data-element="text-sample"
|
||||
>
|
||||
The quick brown fox
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-neutral-600 dark:text-neutral-400 space-y-1" data-element="typography-info">
|
||||
{fontSize && <div>Size: {fontSize}</div>}
|
||||
{fontWeight && <div>Weight: {fontWeight}</div>}
|
||||
{lineHeight && <div>Line Height: {lineHeight}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 间距令牌预览组件
|
||||
const SpacingTokenPreview: React.FC<{ token: SpacingToken }> = ({ token }) => {
|
||||
return (
|
||||
<div className="spacing-token-preview" data-element="spacing-preview">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="bg-primary-200 dark:bg-primary-800 h-4 rounded"
|
||||
style={{ width: token.value }}
|
||||
data-element="spacing-bar"
|
||||
/>
|
||||
<span className="text-xs font-mono" data-element="spacing-value">
|
||||
{token.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 阴影令牌预览组件
|
||||
const ShadowTokenPreview: React.FC<{ token: ShadowToken }> = ({ token }) => {
|
||||
const { offsetX = '0', offsetY = '0', blurRadius = '0', spreadRadius = '0', color = 'transparent' } = token.value;
|
||||
|
||||
return (
|
||||
<div className="shadow-token-preview" data-element="shadow-preview">
|
||||
<div
|
||||
className="shadow-demo w-full h-16 bg-white dark:bg-neutral-900 rounded-lg border border-neutral-200 dark:border-neutral-700"
|
||||
style={{
|
||||
boxShadow: `${offsetX} ${offsetY} ${blurRadius} ${spreadRadius} ${color}`,
|
||||
}}
|
||||
data-element="shadow-box"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-neutral-600 dark:text-neutral-400 space-y-1" data-element="shadow-info">
|
||||
<div>X: {offsetX}</div>
|
||||
<div>Y: {offsetY}</div>
|
||||
<div>Blur: {blurRadius}</div>
|
||||
<div>Spread: {spreadRadius}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 圆角令牌预览组件
|
||||
const BorderRadiusTokenPreview: React.FC<{ token: BorderRadiusToken }> = ({ token }) => {
|
||||
return (
|
||||
<div className="border-radius-token-preview" data-element="radius-preview">
|
||||
<div
|
||||
className="radius-demo w-full h-16 bg-primary-100 dark:bg-primary-900/20"
|
||||
style={{ borderRadius: token.value }}
|
||||
data-element="radius-box"
|
||||
/>
|
||||
<div className="mt-2 text-center text-xs font-mono" data-element="radius-value">
|
||||
{token.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 动画令牌预览组件
|
||||
const AnimationTokenPreview: React.FC<{ token: AnimationToken }> = ({ token }) => {
|
||||
const { duration = '300ms', timingFunction = 'ease' } = token.value;
|
||||
|
||||
return (
|
||||
<div className="animation-token-preview" data-element="animation-preview">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animation-demo w-8 h-8 bg-primary-500 rounded-full" data-element="animated-circle" />
|
||||
<div className="text-xs space-y-1" data-element="animation-info">
|
||||
<div>Duration: {duration}</div>
|
||||
<div>Easing: {timingFunction}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 默认令牌预览组件
|
||||
const DefaultTokenPreview: React.FC<{ token: DesignToken }> = ({ token }) => {
|
||||
return (
|
||||
<div className="default-token-preview p-3 bg-neutral-50 dark:bg-neutral-800 rounded" data-element="default-preview">
|
||||
<div className="text-center text-sm font-mono" data-element="value">
|
||||
{typeof token.value === 'object' ? JSON.stringify(token.value) : String(token.value)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TokenCard, ColorTokenPreview, TypographyTokenPreview, SpacingTokenPreview, ShadowTokenPreview, BorderRadiusTokenPreview, AnimationTokenPreview };
|
||||
export type { TokenCardProps };
|
||||
|
||||
export default TokenCard;
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @fileoverview 应用布局组件
|
||||
* @description 提供侧边栏导航、顶部栏和主内容区域的布局结构
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useDesignModeStore } from '../../main';
|
||||
|
||||
// 导航项目类型定义
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用布局主组件
|
||||
*/
|
||||
const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { isDesignModeEnabled, toggleDesignMode } = useDesignModeStore();
|
||||
|
||||
// 导航菜单配置
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: '🏠',
|
||||
description: 'Overview and system status',
|
||||
badge: 'Main'
|
||||
},
|
||||
{
|
||||
path: '/editor',
|
||||
label: 'Component Editor',
|
||||
icon: '🎨',
|
||||
description: 'Visual component builder',
|
||||
badge: 'Pro'
|
||||
},
|
||||
{
|
||||
path: '/design-system',
|
||||
label: 'Design System',
|
||||
icon: '🎯',
|
||||
description: 'Design tokens and guidelines',
|
||||
badge: 'Core'
|
||||
},
|
||||
{
|
||||
path: '/preview',
|
||||
label: 'Live Preview',
|
||||
icon: '⚡',
|
||||
description: 'Real-time component preview'
|
||||
}
|
||||
];
|
||||
|
||||
// 导航项目组件
|
||||
const NavItemComponent: React.FC<{ item: NavItem; isActive: boolean }> = ({ item, isActive }) => (
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`
|
||||
nav-item group relative flex items-center px-4 py-3 text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:bg-neutral-100 dark:hover:bg-neutral-800
|
||||
${isActive
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-300'
|
||||
: 'text-neutral-700 dark:text-neutral-300'
|
||||
}
|
||||
`}
|
||||
data-component="NavItem"
|
||||
data-element="nav-link"
|
||||
data-nav-item={item.path.replace('/', '')}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="nav-item-icon text-lg mr-3" data-element="icon">
|
||||
{item.icon}
|
||||
</span>
|
||||
<div className="nav-item-content flex-1 min-w-0" data-element="content">
|
||||
<div className="nav-item-label font-medium" data-element="label">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="nav-item-description text-xs text-neutral-500 dark:text-neutral-400 mt-0.5" data-element="description">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span
|
||||
className="nav-item-badge ml-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
|
||||
data-element="badge"
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 设计模式属性 */}
|
||||
<div
|
||||
className="design-mode-indicator absolute right-2 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-design-mode={isDesignModeEnabled ? 'true' : 'false'}
|
||||
>
|
||||
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-layout layout-container"
|
||||
data-component="AppLayout"
|
||||
data-design-mode={isDesignModeEnabled ? 'true' : 'false'}
|
||||
>
|
||||
{/* 移动端遮罩层 */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-30 bg-black bg-opacity-50 lg:hidden"
|
||||
data-component="MobileOverlay"
|
||||
data-element="overlay"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{
|
||||
x: sidebarOpen ? 0 : '-100%',
|
||||
}}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className={`
|
||||
layout-sidebar fixed inset-y-0 left-0 z-40 flex flex-col
|
||||
bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-800
|
||||
lg:translate-x-0 lg:static lg:inset-0 lg:z-auto
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
data-component="Sidebar"
|
||||
data-element="sidebar"
|
||||
>
|
||||
{/* 侧边栏头部 */}
|
||||
<div className="sidebar-header flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-800" data-element="header">
|
||||
<div className="sidebar-brand flex items-center" data-element="brand">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white font-bold text-sm">AD</span>
|
||||
</div>
|
||||
<div className="brand-content">
|
||||
<h1 className="text-lg font-bold text-neutral-900 dark:text-neutral-100">
|
||||
Design Mode
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Full Featured Showcase
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端关闭按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden p-1 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
data-component="IconButton"
|
||||
data-element="close-button"
|
||||
data-action="close-sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="sidebar-nav flex-1 px-4 py-6 space-y-2 overflow-y-auto custom-scrollbar" data-element="nav">
|
||||
{navItems.map((item) => (
|
||||
<NavItemComponent
|
||||
key={item.path}
|
||||
item={item}
|
||||
isActive={location.pathname === item.path}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 侧边栏底部 */}
|
||||
<div className="sidebar-footer p-4 border-t border-neutral-200 dark:border-neutral-800" data-element="footer">
|
||||
<div className="sidebar-actions space-y-2" data-element="actions">
|
||||
<button
|
||||
onClick={toggleDesignMode}
|
||||
className={`
|
||||
w-full flex items-center justify-center px-3 py-2 text-sm font-medium rounded-md
|
||||
transition-colors duration-200
|
||||
${isDesignModeEnabled
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
|
||||
}
|
||||
`}
|
||||
data-component="ToggleButton"
|
||||
data-element="design-mode-toggle"
|
||||
data-action="toggle-design-mode"
|
||||
>
|
||||
<span className="mr-2">{isDesignModeEnabled ? '🎯' : '👁️'}</span>
|
||||
{isDesignModeEnabled ? 'Design Mode ON' : 'Design Mode OFF'}
|
||||
</button>
|
||||
|
||||
<div className="text-center text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Version 1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="layout-main flex flex-col min-h-screen">
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="layout-header h-16 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-800 flex items-center justify-between px-6 relative z-10" data-component="Header" data-element="header">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="lg:hidden p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
data-component="IconButton"
|
||||
data-element="mobile-menu-button"
|
||||
data-action="toggle-sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 页面标题和面包屑 */}
|
||||
<div className="header-content flex-1" data-element="content">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{navItems.find(item => item.path === location.pathname)?.label || 'Unknown Page'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 顶部操作区域 */}
|
||||
<div className="header-actions flex items-center space-x-4" data-element="actions">
|
||||
{/* 设计模式状态指示器 */}
|
||||
<div
|
||||
className={`
|
||||
header-status-indicator flex items-center px-3 py-1 rounded-full text-xs font-medium
|
||||
${isDesignModeEnabled
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300'
|
||||
: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
}
|
||||
`}
|
||||
data-element="status-indicator"
|
||||
>
|
||||
<div className={`
|
||||
w-2 h-2 rounded-full mr-2
|
||||
${isDesignModeEnabled ? 'bg-primary-500' : 'bg-neutral-400'}
|
||||
`}></div>
|
||||
{isDesignModeEnabled ? 'Design Mode' : 'Preview Mode'}
|
||||
</div>
|
||||
|
||||
{/* 用户头像/设置 */}
|
||||
<button
|
||||
className="header-user-button p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
data-component="AvatarButton"
|
||||
data-element="user-avatar"
|
||||
data-action="open-user-menu"
|
||||
>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-primary-400 to-secondary-400 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-medium text-sm">U</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className="layout-content flex-1 overflow-auto bg-neutral-50 dark:bg-neutral-800"
|
||||
data-component="Main"
|
||||
data-element="main-content"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @fileoverview 按钮组件
|
||||
* @description 提供各种样式和状态的按钮组件
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
// 按钮变体类型
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'danger';
|
||||
|
||||
// 按钮尺寸类型
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// 按钮属性接口
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
fullWidth?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variantClass?: string;
|
||||
sizeClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮组件实现
|
||||
*/
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
loadingText,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
fullWidth = false,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
variantClass,
|
||||
sizeClass,
|
||||
onClick,
|
||||
...props
|
||||
}, ref) => {
|
||||
// 按钮变体样式配置
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
bg-primary-600 border-primary-600 text-white
|
||||
hover:bg-primary-700 hover:border-primary-700
|
||||
focus:ring-primary-500 focus:ring-offset-2
|
||||
active:bg-primary-800 active:border-primary-800
|
||||
disabled:bg-primary-300 disabled:border-primary-300
|
||||
`,
|
||||
secondary: `
|
||||
bg-neutral-200 border-neutral-300 text-neutral-900
|
||||
hover:bg-neutral-300 hover:border-neutral-400
|
||||
focus:ring-neutral-500 focus:ring-offset-2
|
||||
active:bg-neutral-400 active:border-neutral-500
|
||||
disabled:bg-neutral-100 disabled:border-neutral-200
|
||||
dark:bg-neutral-700 dark:border-neutral-600 dark:text-neutral-100
|
||||
dark:hover:bg-neutral-600 dark:hover:border-neutral-500
|
||||
dark:active:bg-neutral-500 dark:active:border-neutral-400
|
||||
dark:disabled:bg-neutral-800 dark:disabled:border-neutral-700
|
||||
`,
|
||||
outline: `
|
||||
bg-transparent border-primary-600 text-primary-600
|
||||
hover:bg-primary-600 hover:text-white
|
||||
focus:ring-primary-500 focus:ring-offset-2
|
||||
active:bg-primary-700 active:border-primary-700
|
||||
disabled:text-primary-300 disabled:border-primary-300
|
||||
disabled:hover:bg-transparent disabled:hover:text-primary-300
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent border-transparent text-neutral-700
|
||||
hover:bg-neutral-100 hover:text-neutral-900
|
||||
focus:ring-neutral-500 focus:ring-offset-2
|
||||
active:bg-neutral-200 active:text-neutral-900
|
||||
disabled:text-neutral-400
|
||||
dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-100
|
||||
dark:active:bg-neutral-700 dark:active:text-neutral-100
|
||||
`,
|
||||
link: `
|
||||
bg-transparent border-transparent text-primary-600
|
||||
hover:text-primary-700 hover:underline
|
||||
focus:ring-primary-500 focus:ring-offset-2
|
||||
active:text-primary-800
|
||||
disabled:text-primary-300 disabled:no-underline
|
||||
`,
|
||||
danger: `
|
||||
bg-error-600 border-error-600 text-white
|
||||
hover:bg-error-700 hover:border-error-700
|
||||
focus:ring-error-500 focus:ring-offset-2
|
||||
active:bg-error-800 active:border-error-800
|
||||
disabled:bg-error-300 disabled:border-error-300
|
||||
`,
|
||||
};
|
||||
|
||||
// 按钮尺寸样式配置
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
xl: 'px-8 py-4 text-lg',
|
||||
};
|
||||
|
||||
// 计算按钮内容
|
||||
const buttonContent = (
|
||||
<span className="button-content flex items-center justify-center space-x-2">
|
||||
{icon && iconPosition === 'left' && !loading && (
|
||||
<span className="button-icon-left" data-element="icon-left">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<span className="button-loading-icon" data-element="loading-icon">
|
||||
<div className="loading-spinner w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="button-text" data-element="text">
|
||||
{loading && loadingText ? loadingText : children}
|
||||
</span>
|
||||
|
||||
{icon && iconPosition === 'right' && !loading && (
|
||||
<span className="button-icon-right" data-element="icon-right">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
// 应用自定义样式类
|
||||
const combinedVariantClass = variantClass || variantStyles[variant];
|
||||
const combinedSizeClass = sizeClass || sizeStyles[size];
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={clsx(
|
||||
// 基础样式
|
||||
`
|
||||
inline-flex items-center justify-center font-medium rounded-md border
|
||||
transition-all duration-200 focus:outline-none focus:ring-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
disabled:transform-none
|
||||
`,
|
||||
// 变体样式
|
||||
combinedVariantClass,
|
||||
// 尺寸样式
|
||||
combinedSizeClass,
|
||||
// 全宽度
|
||||
fullWidth && 'w-full',
|
||||
// 自定义类名
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
data-component="Button"
|
||||
data-element="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-loading={loading ? 'true' : 'false'}
|
||||
data-disabled={disabled ? 'true' : 'false'}
|
||||
{...props}
|
||||
>
|
||||
{buttonContent}
|
||||
</motion.button>
|
||||
);
|
||||
});
|
||||
|
||||
// 显示名称
|
||||
Button.displayName = 'Button';
|
||||
|
||||
/**
|
||||
* 图标按钮组件
|
||||
*/
|
||||
interface IconButtonProps extends Omit<ButtonProps, 'children' | 'size'> {
|
||||
size?: Exclude<ButtonSize, 'xl'>;
|
||||
'aria-label': string; // 无障碍属性必需
|
||||
}
|
||||
|
||||
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(({
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'w-8 h-8 p-1.5',
|
||||
md: 'w-10 h-10 p-2',
|
||||
lg: 'w-12 h-12 p-2.5',
|
||||
xl: 'w-14 h-14 p-3',
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
size={size}
|
||||
className={clsx(
|
||||
'!p-0 !rounded-full',
|
||||
iconSizeStyles[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
||||
/**
|
||||
* 浮动操作按钮
|
||||
*/
|
||||
interface FloatingActionButtonProps extends Omit<IconButtonProps, 'variant'> {
|
||||
variant?: ButtonVariant;
|
||||
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
||||
}
|
||||
|
||||
const FloatingActionButton = forwardRef<HTMLButtonElement, FloatingActionButtonProps>(({
|
||||
position = 'bottom-right',
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const positionStyles = {
|
||||
'bottom-right': 'fixed bottom-6 right-6 z-50',
|
||||
'bottom-left': 'fixed bottom-6 left-6 z-50',
|
||||
'top-right': 'fixed top-6 right-6 z-50',
|
||||
'top-left': 'fixed top-6 left-6 z-50',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={clsx('floating-action-button', positionStyles[position])}
|
||||
data-component="FloatingActionButton"
|
||||
data-element="fab"
|
||||
data-position={position}
|
||||
>
|
||||
<IconButton
|
||||
ref={ref}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
className={clsx(
|
||||
`
|
||||
!rounded-full !shadow-lg !shadow-primary-500/25
|
||||
hover:!shadow-xl hover:!shadow-primary-500/30
|
||||
bg-gradient-to-br from-primary-500 to-primary-600
|
||||
hover:from-primary-600 hover:to-primary-700
|
||||
border-0
|
||||
`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
FloatingActionButton.displayName = 'FloatingActionButton';
|
||||
|
||||
export { Button, IconButton, FloatingActionButton };
|
||||
export type { ButtonProps, IconButtonProps, FloatingActionButtonProps, ButtonVariant, ButtonSize };
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* @fileoverview 卡片组件
|
||||
* @description 提供各种样式和布局的卡片容器组件
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
// 卡片变体类型
|
||||
type CardVariant = 'default' | 'elevated' | 'outlined' | 'ghost' | 'filled';
|
||||
|
||||
// 卡片尺寸类型
|
||||
type CardSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
// 卡片属性接口
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CardVariant;
|
||||
size?: CardSize;
|
||||
hover?: boolean;
|
||||
interactive?: boolean;
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variantClass?: string;
|
||||
sizeClass?: string;
|
||||
}
|
||||
|
||||
// 卡片标题属性接口
|
||||
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 卡片内容属性接口
|
||||
interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 卡片底部属性接口
|
||||
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 卡片图片属性接口
|
||||
interface CardImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
overlay?: boolean;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片组件实现
|
||||
*/
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
hover = false,
|
||||
interactive = false,
|
||||
loading = false,
|
||||
children,
|
||||
className,
|
||||
variantClass,
|
||||
sizeClass,
|
||||
onClick,
|
||||
...props
|
||||
}, ref) => {
|
||||
// 卡片变体样式配置
|
||||
const variantStyles: Record<CardVariant, string> = {
|
||||
default: `
|
||||
bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800
|
||||
`,
|
||||
elevated: `
|
||||
bg-white dark:bg-neutral-900 border-0 shadow-lg dark:shadow-neutral-900/20
|
||||
hover:shadow-xl transition-shadow duration-300
|
||||
`,
|
||||
outlined: `
|
||||
bg-transparent border-2 border-primary-200 dark:border-primary-800
|
||||
hover:border-primary-300 dark:hover:border-primary-700
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent border-0
|
||||
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
|
||||
`,
|
||||
filled: `
|
||||
bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700
|
||||
`,
|
||||
};
|
||||
|
||||
// 卡片尺寸样式配置
|
||||
const sizeStyles: Record<CardSize, string> = {
|
||||
sm: 'p-3 space-y-2',
|
||||
md: 'p-4 space-y-3',
|
||||
lg: 'p-6 space-y-4',
|
||||
xl: 'p-8 space-y-6',
|
||||
};
|
||||
|
||||
// 悬停效果样式
|
||||
const hoverStyles = hover || interactive ? `
|
||||
transition-all duration-200 ease-in-out cursor-pointer
|
||||
hover:shadow-md hover:-translate-y-1
|
||||
hover:shadow-neutral-500/10 dark:hover:shadow-neutral-900/20
|
||||
` : '';
|
||||
|
||||
// 交互样式
|
||||
const interactiveStyles = interactive ? `
|
||||
active:scale-95 active:shadow-sm
|
||||
` : '';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={hover || interactive ? { y: -4 } : {}}
|
||||
whileTap={interactive ? { scale: 0.98 } : {}}
|
||||
className={clsx(
|
||||
// 基础样式
|
||||
`
|
||||
card rounded-lg overflow-hidden
|
||||
${interactive ? 'cursor-pointer' : ''}
|
||||
${loading ? 'pointer-events-none' : ''}
|
||||
`,
|
||||
// 变体样式
|
||||
variantStyles[variant],
|
||||
// 尺寸样式
|
||||
sizeStyles[size],
|
||||
// 悬停和交互样式
|
||||
hoverStyles,
|
||||
interactiveStyles,
|
||||
// 自定义类名
|
||||
className
|
||||
)}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
data-component="Card"
|
||||
data-element="card"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-hover={hover ? 'true' : 'false'}
|
||||
data-interactive={interactive ? 'true' : 'false'}
|
||||
data-loading={loading ? 'true' : 'false'}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<CardSkeleton variant={variant} size={size} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
/**
|
||||
* 卡片头部组件
|
||||
*/
|
||||
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'card-header flex items-start justify-between',
|
||||
className
|
||||
)}
|
||||
data-component="CardHeader"
|
||||
data-element="header"
|
||||
{...props}
|
||||
>
|
||||
<div className="header-content flex-1" data-element="content">
|
||||
{title && (
|
||||
<h3
|
||||
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
data-element="title"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p
|
||||
className="text-sm text-neutral-600 dark:text-neutral-400 mt-1"
|
||||
data-element="subtitle"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{action && (
|
||||
<div className="header-action ml-4 flex-shrink-0" data-element="action">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
/**
|
||||
* 卡片内容组件
|
||||
*/
|
||||
const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('card-body', className)}
|
||||
data-component="CardBody"
|
||||
data-element="body"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CardBody.displayName = 'CardBody';
|
||||
|
||||
/**
|
||||
* 卡片底部组件
|
||||
*/
|
||||
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'card-footer flex items-center justify-between',
|
||||
'border-t border-neutral-200 dark:border-neutral-800 pt-4 mt-4',
|
||||
className
|
||||
)}
|
||||
data-component="CardFooter"
|
||||
data-element="footer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
/**
|
||||
* 卡片图片组件
|
||||
*/
|
||||
const CardImage = forwardRef<HTMLImageElement, CardImageProps>(({
|
||||
src,
|
||||
alt,
|
||||
overlay = false,
|
||||
height = 200,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'card-image relative overflow-hidden',
|
||||
className
|
||||
)}
|
||||
data-component="CardImage"
|
||||
data-element="image"
|
||||
data-overlay={overlay ? 'true' : 'false'}
|
||||
>
|
||||
<img
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={clsx(
|
||||
'w-full object-cover',
|
||||
overlay && 'absolute inset-0 h-full'
|
||||
)}
|
||||
style={{ height }}
|
||||
data-element="img"
|
||||
{...props}
|
||||
/>
|
||||
{overlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent"
|
||||
data-element="overlay"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CardImage.displayName = 'CardImage';
|
||||
|
||||
/**
|
||||
* 卡片骨架屏组件
|
||||
*/
|
||||
const CardSkeleton: React.FC<{ variant: CardVariant; size: CardSize }> = ({ variant, size }) => {
|
||||
const skeletonSizes = {
|
||||
sm: { padding: 'p-3', gap: 'space-y-2' },
|
||||
md: { padding: 'p-4', gap: 'space-y-3' },
|
||||
lg: { padding: 'p-6', gap: 'space-y-4' },
|
||||
xl: { padding: 'p-8', gap: 'space-y-6' },
|
||||
};
|
||||
|
||||
const { padding, gap } = skeletonSizes[size];
|
||||
|
||||
return (
|
||||
<div className={`card-skeleton ${padding} ${gap}`} data-element="skeleton">
|
||||
{/* 头部骨架 */}
|
||||
<div className="flex items-start justify-between" data-element="header-skeleton">
|
||||
<div className="flex-1" data-element="content-skeleton">
|
||||
<div className="h-5 bg-neutral-200 dark:bg-neutral-700 rounded w-3/4 mb-2" />
|
||||
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-1/2" />
|
||||
</div>
|
||||
<div className="ml-4" data-element="action-skeleton">
|
||||
<div className="w-8 h-8 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容骨架 */}
|
||||
<div className="space-y-3" data-element="body-skeleton">
|
||||
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-5/6" />
|
||||
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-4/6" />
|
||||
</div>
|
||||
|
||||
{/* 底部骨架 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-neutral-200 dark:border-neutral-800" data-element="footer-skeleton">
|
||||
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-1/3" />
|
||||
<div className="h-8 w-20 bg-neutral-200 dark:bg-neutral-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 卡片组组件
|
||||
*/
|
||||
interface CardGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
gap?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const CardGroup: React.FC<CardGroupProps> = ({
|
||||
columns = 3,
|
||||
gap = 'md',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-3',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'card-group grid',
|
||||
columnClasses[columns],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
data-component="CardGroup"
|
||||
data-element="group"
|
||||
data-columns={columns}
|
||||
data-gap={gap}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
CardImage,
|
||||
CardGroup,
|
||||
CardSkeleton
|
||||
};
|
||||
|
||||
export type {
|
||||
CardProps,
|
||||
CardHeaderProps,
|
||||
CardBodyProps,
|
||||
CardFooterProps,
|
||||
CardImageProps,
|
||||
CardGroupProps,
|
||||
CardVariant,
|
||||
CardSize
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @fileoverview 加载动画组件
|
||||
* @description 提供各种加载状态的可复用组件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* 旋转加载器组件
|
||||
*/
|
||||
const LoadingSpinner: React.FC<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
color?: 'primary' | 'secondary' | 'neutral' | 'white';
|
||||
className?: string;
|
||||
}> = ({
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'border-primary-600 border-t-transparent',
|
||||
secondary: 'border-secondary-600 border-t-transparent',
|
||||
neutral: 'border-neutral-400 border-t-transparent',
|
||||
white: 'border-white border-t-transparent'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
inline-block border-2 rounded-full animate-spin
|
||||
${sizeClasses[size]}
|
||||
${colorClasses[color]}
|
||||
${className}
|
||||
`}
|
||||
data-component="LoadingSpinner"
|
||||
data-element="spinner"
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 点状加载器组件
|
||||
*/
|
||||
const LoadingDots: React.FC<{
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'primary' | 'secondary' | 'neutral' | 'white';
|
||||
className?: string;
|
||||
}> = ({
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-1 h-1',
|
||||
md: 'w-2 h-2',
|
||||
lg: 'w-3 h-3'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'bg-primary-600',
|
||||
secondary: 'bg-secondary-600',
|
||||
neutral: 'bg-neutral-400',
|
||||
white: 'bg-white'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
loading-dots flex space-x-1
|
||||
${className}
|
||||
`}
|
||||
data-component="LoadingDots"
|
||||
data-element="dots"
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={`
|
||||
${sizeClasses[size]} ${colorClasses[color]} rounded-full
|
||||
`}
|
||||
data-element="dot"
|
||||
data-dot-index={index}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.7, 1, 0.7],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.16,
|
||||
ease: 'easeInOut'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 脉冲加载器组件
|
||||
*/
|
||||
const LoadingPulse: React.FC<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
color?: 'primary' | 'secondary' | 'neutral' | 'white';
|
||||
className?: string;
|
||||
}> = ({
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'bg-primary-600',
|
||||
secondary: 'bg-secondary-600',
|
||||
neutral: 'bg-neutral-400',
|
||||
white: 'bg-white'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${sizeClasses[size]} ${colorClasses[color]} rounded-full
|
||||
animate-pulse
|
||||
${className}
|
||||
`}
|
||||
data-component="LoadingPulse"
|
||||
data-element="pulse"
|
||||
data-size={size}
|
||||
data-color={color}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面级加载组件
|
||||
*/
|
||||
const PageLoading: React.FC<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: 'spinner' | 'dots' | 'pulse';
|
||||
className?: string;
|
||||
}> = ({
|
||||
title = 'Loading...',
|
||||
description = 'Please wait while we prepare your content.',
|
||||
variant = 'spinner',
|
||||
className = ''
|
||||
}) => {
|
||||
const renderLoader = () => {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return <LoadingDots size="lg" color="primary" />;
|
||||
case 'pulse':
|
||||
return <LoadingPulse size="xl" color="primary" />;
|
||||
default:
|
||||
return <LoadingSpinner size="lg" color="primary" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`
|
||||
page-loading flex flex-col items-center justify-center min-h-[400px]
|
||||
text-center p-8
|
||||
${className}
|
||||
`}
|
||||
data-component="PageLoading"
|
||||
data-element="loading-container"
|
||||
data-variant={variant}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="loading-animation mb-6"
|
||||
data-element="animation"
|
||||
>
|
||||
{renderLoader()}
|
||||
</motion.div>
|
||||
|
||||
<motion.h3
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2"
|
||||
data-element="title"
|
||||
>
|
||||
{title}
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-neutral-600 dark:text-neutral-400 max-w-sm"
|
||||
data-element="description"
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 按钮内加载组件
|
||||
*/
|
||||
const ButtonLoading: React.FC<{
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'primary' | 'secondary' | 'neutral' | 'white';
|
||||
text?: string;
|
||||
}> = ({
|
||||
size = 'sm',
|
||||
color = 'white',
|
||||
text = 'Loading...'
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="button-loading flex items-center space-x-2"
|
||||
data-component="ButtonLoading"
|
||||
data-element="button-loading"
|
||||
>
|
||||
<LoadingSpinner size={size} color={color} />
|
||||
<span className="sr-only">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 骨架屏加载组件
|
||||
*/
|
||||
const SkeletonLoader: React.FC<{
|
||||
lines?: number;
|
||||
className?: string;
|
||||
}> = ({
|
||||
lines = 3,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`skeleton-loader space-y-3 ${className}`}
|
||||
data-component="SkeletonLoader"
|
||||
data-element="skeleton"
|
||||
>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={`
|
||||
skeleton-line h-4 bg-neutral-200 dark:bg-neutral-700 rounded
|
||||
${index === lines - 1 ? 'w-3/4' : 'w-full'}
|
||||
`}
|
||||
data-element="skeleton-line"
|
||||
data-line-index={index}
|
||||
animate={{
|
||||
opacity: [0.5, 1, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.1,
|
||||
ease: 'easeInOut'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
LoadingSpinner,
|
||||
LoadingDots,
|
||||
LoadingPulse,
|
||||
PageLoading,
|
||||
ButtonLoading,
|
||||
SkeletonLoader
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
Reference in New Issue
Block a user