名称: 原子设计-有机体 描述: 用于从分子和原子构建复杂的有机体,如页眉、页脚、产品卡片和侧边栏。有机体是独立的UI部分。 允许工具:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
原子设计:有机体
掌握有机体的创建 - 由分子和原子组成的复杂、独立的界面部分。有机体代表可以独立存在的UI部分。
什么是有机体?
有机体是相对复杂的UI组件,形成界面的独立部分。它们是:
- 由分子和原子组成:可能包括两个级别
- 独立部分:可以在页面上独立存在
- 上下文感知:通常与特定的业务上下文相关
- 有状态的:可能管理重要的内部状态
- 可重用的:用于不同的模板和页面
常见有机体类型
导航有机体
- 页眉(标志 + 导航 + 用户菜单)
- 页脚(链接 + 社交图标 + 法律信息)
- 侧边栏(导航 + 用户信息 + 操作)
- 面包屑(完整导航路径)
内容有机体
- 产品卡片(图像 + 详情 + 操作)
- 评论部分(评论 + 回复表单)
- 文章预览(标题 + 摘要 + 元数据)
- 用户资料(头像 + 简介 + 统计)
表单有机体
- 登录表单(字段 + 操作 + 链接)
- 注册表单(多步字段)
- 结账表单(支付 + 配送)
- 带过滤器的搜索
数据展示有机体
- 数据表格(表头 + 行 + 分页)
- 仪表板(统计 + 图表 + 操作)
- 时间线(事件 + 连接器)
- 画廊(图像 + 导航)
页眉有机体示例
完整实现
// 有机体/Header/Header.tsx
import React, { useState } from 'react';
import { Icon } from '@/components/atoms/Icon';
import { Button } from '@/components/atoms/Button';
import { Avatar } from '@/components/atoms/Avatar';
import { NavItem } from '@/components/molecules/NavItem';
import { SearchForm } from '@/components/molecules/SearchForm';
import styles from './Header.module.css';
export interface NavLink {
id: string;
label: string;
href: string;
icon?: string;
badge?: number;
}
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
}
export interface HeaderProps {
/** 标志元素或图像 */
logo: React.ReactNode;
/** 导航链接 */
navigation: NavLink[];
/** 当前活动导航项 */
activeNavId?: string;
/** 认证用户 */
user?: User | null;
/** 显示搜索表单 */
showSearch?: boolean;
/** 搜索提交处理器 */
onSearch?: (query: string) => void;
/** 登录点击处理器 */
onLogin?: () => void;
/** 登出点击处理器 */
onLogout?: () => void;
/** 资料点击处理器 */
onProfileClick?: () => void;
}
export const Header: React.FC<HeaderProps> = ({
logo,
navigation,
activeNavId,
user,
showSearch = true,
onSearch,
onLogin,
onLogout,
onProfileClick,
}) => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
return (
<header className={styles.header}>
<div className={styles.container}>
{/* 标志 */}
<div className={styles.logo}>{logo}</div>
{/* 桌面导航 */}
<nav className={styles.nav} aria-label="主导航">
<ul className={styles.navList}>
{navigation.map((item) => (
<li key={item.id}>
<NavItem
label={item.label}
href={item.href}
icon={item.icon}
badge={item.badge}
isActive={item.id === activeNavId}
/>
</li>
))}
</ul>
</nav>
{/* 搜索 */}
{showSearch && onSearch && (
<div className={styles.search}>
<SearchForm
onSubmit={onSearch}
placeholder="搜索..."
size="sm"
/>
</div>
)}
{/* 用户操作 */}
<div className={styles.actions}>
{user ? (
<div className={styles.userMenu}>
<button
className={styles.userButton}
onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-expanded={userMenuOpen}
aria-haspopup="true"
>
<Avatar
src={user.avatar}
alt={user.name}
initials={user.name.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className={styles.userName}>{user.name}</span>
<Icon name="chevron-down" size="xs" />
</button>
{userMenuOpen && (
<div className={styles.dropdown}>
<button onClick={onProfileClick}>
<Icon name="user" size="sm" />
资料
</button>
<button onClick={onLogout}>
<Icon name="log-out" size="sm" />
登出
</button>
</div>
)}
</div>
) : (
<Button variant="primary" size="sm" onClick={onLogin}>
登录
</Button>
)}
</div>
{/* 移动菜单切换 */}
<button
className={styles.mobileToggle}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-expanded={mobileMenuOpen}
aria-label="切换菜单"
>
<Icon name={mobileMenuOpen ? 'x' : 'menu'} size="md" />
</button>
</div>
{/* 移动导航 */}
{mobileMenuOpen && (
<nav className={styles.mobileNav} aria-label="移动导航">
<ul>
{navigation.map((item) => (
<li key={item.id}>
<NavItem
label={item.label}
href={item.href}
icon={item.icon}
badge={item.badge}
isActive={item.id === activeNavId}
onClick={() => setMobileMenuOpen(false)}
/>
</li>
))}
</ul>
</nav>
)}
</header>
);
};
Header.displayName = 'Header';
/* 有机体/Header/Header.module.css */
.header {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--color-white);
border-bottom: 1px solid var(--color-neutral-200);
}
.container {
display: flex;
align-items: center;
gap: 24px;
max-width: 1280px;
margin: 0 auto;
padding: 12px 24px;
}
.logo {
flex-shrink: 0;
}
.nav {
display: none;
flex: 1;
}
@media (min-width: 768px) {
.nav {
display: block;
}
}
.navList {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.search {
display: none;
width: 280px;
}
@media (min-width: 1024px) {
.search {
display: block;
}
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.userMenu {
position: relative;
}
.userButton {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--color-neutral-200);
border-radius: 6px;
cursor: pointer;
}
.userName {
display: none;
}
@media (min-width: 640px) {
.userName {
display: inline;
}
}
.dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 160px;
background: var(--color-white);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.dropdown button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.dropdown button:hover {
background-color: var(--color-neutral-50);
}
.mobileToggle {
display: flex;
padding: 8px;
background: transparent;
border: none;
cursor: pointer;
}
@media (min-width: 768px) {
.mobileToggle {
display: none;
}
}
.mobileNav {
border-top: 1px solid var(--color-neutral-200);
padding: 16px;
}
.mobileNav ul {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
页脚有机体示例
// 有机体/Footer/Footer.tsx
import React from 'react';
import { Icon } from '@/components/atoms/Icon';
import { Text } from '@/components/atoms/Typography';
import styles from './Footer.module.css';
export interface FooterLink {
label: string;
href: string;
}
export interface FooterSection {
title: string;
links: FooterLink[];
}
export interface SocialLink {
platform: string;
href: string;
icon: string;
}
export interface FooterProps {
/** 页脚标志 */
logo: React.ReactNode;
/** 标语或描述 */
tagline?: string;
/** 链接部分 */
sections: FooterSection[];
/** 社交媒体链接 */
socialLinks?: SocialLink[];
/** 版权文本 */
copyright?: string;
/** 法律链接 */
legalLinks?: FooterLink[];
}
export const Footer: React.FC<FooterProps> = ({
logo,
tagline,
sections,
socialLinks = [],
copyright,
legalLinks = [],
}) => {
const currentYear = new Date().getFullYear();
const copyrightText = copyright || `${currentYear} 公司。版权所有。`;
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.top}>
{/* 品牌列 */}
<div className={styles.brand}>
<div className={styles.logo}>{logo}</div>
{tagline && (
<Text color="muted" className={styles.tagline}>
{tagline}
</Text>
)}
{socialLinks.length > 0 && (
<div className={styles.social}>
{socialLinks.map((link) => (
<a
key={link.platform}
href={link.href}
className={styles.socialLink}
aria-label={`在 ${link.platform} 上关注我们`}
target="_blank"
rel="noopener noreferrer"
>
<Icon name={link.icon} size="md" />
</a>
))}
</div>
)}
</div>
{/* 链接部分 */}
<div className={styles.sections}>
{sections.map((section) => (
<div key={section.title} className={styles.section}>
<Text weight="semibold" className={styles.sectionTitle}>
{section.title}
</Text>
<ul className={styles.linkList}>
{section.links.map((link) => (
<li key={link.href}>
<a href={link.href} className={styles.link}>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
{/* 底部栏 */}
<div className={styles.bottom}>
<Text size="sm" color="muted">
{copyrightText}
</Text>
{legalLinks.length > 0 && (
<div className={styles.legalLinks}>
{legalLinks.map((link, index) => (
<React.Fragment key={link.href}>
{index > 0 && <span className={styles.separator}>|</span>}
<a href={link.href} className={styles.legalLink}>
{link.label}
</a>
</React.Fragment>
))}
</div>
)}
</div>
</div>
</footer>
);
};
Footer.displayName = 'Footer';
产品卡片有机体示例
// 有机体/ProductCard/ProductCard.tsx
import React from 'react';
import { Button } from '@/components/atoms/Button';
import { Badge } from '@/components/atoms/Badge';
import { Icon } from '@/components/atoms/Icon';
import { Text, Heading } from '@/components/atoms/Typography';
import styles from './ProductCard.module.css';
export interface Product {
id: string;
name: string;
description?: string;
price: number;
originalPrice?: number;
currency?: string;
image: string;
rating?: number;
reviewCount?: number;
inStock?: boolean;
badge?: string;
}
export interface ProductCardProps {
/** 产品数据 */
product: Product;
/** 添加到购物车处理器 */
onAddToCart?: (productId: string) => void;
/** 快速查看处理器 */
onQuickView?: (productId: string) => void;
/** 收藏切换处理器 */
onFavorite?: (productId: string) => void;
/** 产品是否已收藏 */
isFavorite?: boolean;
/** 添加到购物车的加载状态 */
isAddingToCart?: boolean;
}
export const ProductCard: React.FC<ProductCardProps> = ({
product,
onAddToCart,
onQuickView,
onFavorite,
isFavorite = false,
isAddingToCart = false,
}) => {
const {
id,
name,
description,
price,
originalPrice,
currency = '$',
image,
rating,
reviewCount,
inStock = true,
badge,
} = product;
const discount = originalPrice
? Math.round(((originalPrice - price) / originalPrice) * 100)
: null;
const formatPrice = (value: number) => {
return `${currency}${value.toFixed(2)}`;
};
return (
<article className={styles.card}>
{/* 图像部分 */}
<div className={styles.imageContainer}>
<img src={image} alt={name} className={styles.image} />
{badge && (
<Badge variant="primary" className={styles.badge}>
{badge}
</Badge>
)}
{discount && (
<Badge variant="danger" className={styles.discountBadge}>
-{discount}%
</Badge>
)}
{/* 快速操作覆盖层 */}
<div className={styles.overlay}>
{onFavorite && (
<button
className={`${styles.iconButton} ${isFavorite ? styles.favorited : ''}`}
onClick={() => onFavorite(id)}
aria-label={isFavorite ? '从收藏移除' : '添加到收藏'}
>
<Icon name={isFavorite ? 'heart-filled' : 'heart'} size="sm" />
</button>
)}
{onQuickView && (
<button
className={styles.iconButton}
onClick={() => onQuickView(id)}
aria-label="快速查看"
>
<Icon name="eye" size="sm" />
</button>
)}
</div>
</div>
{/* 内容部分 */}
<div className={styles.content}>
<Heading level={4} className={styles.name}>
<a href={`/products/${id}`}>{name}</a>
</Heading>
{description && (
<Text size="sm" color="muted" className={styles.description} truncate>
{description}
</Text>
)}
{/* 评分 */}
{rating !== undefined && (
<div className={styles.rating}>
<div className={styles.stars}>
{[1, 2, 3, 4, 5].map((star) => (
<Icon
key={star}
name={star <= Math.round(rating) ? 'star-filled' : 'star'}
size="xs"
className={star <= Math.round(rating) ? styles.starFilled : styles.starEmpty}
/>
))}
</div>
{reviewCount !== undefined && (
<Text size="xs" color="muted">
({reviewCount})
</Text>
)}
</div>
)}
{/* 价格 */}
<div className={styles.priceContainer}>
<Text weight="bold" className={styles.price}>
{formatPrice(price)}
</Text>
{originalPrice && (
<Text size="sm" color="muted" className={styles.originalPrice}>
<s>{formatPrice(originalPrice)}</s>
</Text>
)}
</div>
{/* 操作 */}
<div className={styles.actions}>
{inStock ? (
<Button
fullWidth
variant="primary"
onClick={() => onAddToCart?.(id)}
isLoading={isAddingToCart}
leftIcon={<Icon name="shopping-cart" size="sm" />}
>
添加到购物车
</Button>
) : (
<Button fullWidth variant="secondary" disabled>
缺货
</Button>
)}
</div>
</div>
</article>
);
};
ProductCard.displayName = 'ProductCard';
评论部分有机体示例
// 有机体/CommentSection/CommentSection.tsx
import React, { useState } from 'react';
import { Button } from '@/components/atoms/Button';
import { Heading, Text } from '@/components/atoms/Typography';
import { MediaObject } from '@/components/molecules/MediaObject';
import { FormField } from '@/components/molecules/FormField';
import styles from './CommentSection.module.css';
export interface Comment {
id: string;
author: {
name: string;
avatar?: string;
};
content: string;
createdAt: string;
likes: number;
replies?: Comment[];
}
export interface CommentSectionProps {
/** 评论列表 */
comments: Comment[];
/** 总评论数 */
totalCount: number;
/** 当前用户(用于评论表单) */
currentUser?: { name: string; avatar?: string };
/** 提交评论处理器 */
onSubmit?: (content: string, parentId?: string) => void;
/** 点赞评论处理器 */
onLike?: (commentId: string) => void;
/** 删除评论处理器 */
onDelete?: (commentId: string) => void;
/** 加载状态 */
isLoading?: boolean;
}
export const CommentSection: React.FC<CommentSectionProps> = ({
comments,
totalCount,
currentUser,
onSubmit,
onLike,
onDelete,
isLoading = false,
}) => {
const [newComment, setNewComment] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyContent, setReplyContent] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newComment.trim() && onSubmit) {
onSubmit(newComment.trim());
setNewComment('');
}
};
const handleReply = (parentId: string) => {
if (replyContent.trim() && onSubmit) {
onSubmit(replyContent.trim(), parentId);
setReplyContent('');
setReplyingTo(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const renderComment = (comment: Comment, isReply = false) => (
<div
key={comment.id}
className={`${styles.comment} ${isReply ? styles.reply : ''}`}
>
<MediaObject
avatarSrc={comment.author.avatar}
avatarAlt={comment.author.name}
avatarInitials={comment.author.name.slice(0, 2).toUpperCase()}
avatarSize="sm"
title={
<Text weight="semibold" size="sm">
{comment.author.name}
</Text>
}
subtitle={formatDate(comment.createdAt)}
/>
<div className={styles.commentContent}>
<Text>{comment.content}</Text>
<div className={styles.commentActions}>
<button
className={styles.actionButton}
onClick={() => onLike?.(comment.id)}
>
点赞 {comment.likes > 0 && `(${comment.likes})`}
</button>
{!isReply && currentUser && (
<button
className={styles.actionButton}
onClick={() => setReplyingTo(comment.id)}
>
回复
</button>
)}
{onDelete && (
<button
className={styles.actionButton}
onClick={() => onDelete(comment.id)}
>
删除
</button>
)}
</div>
{/* 回复表单 */}
{replyingTo === comment.id && (
<div className={styles.replyForm}>
<FormField
name="reply"
label=""
placeholder="写回复..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
/>
<div className={styles.replyActions}>
<Button size="sm" onClick={() => handleReply(comment.id)}>
回复
</Button>
<Button
size="sm"
variant="tertiary"
onClick={() => setReplyingTo(null)}
>
取消
</Button>
</div>
</div>
)}
{/* 嵌套回复 */}
{comment.replies && comment.replies.length > 0 && (
<div className={styles.replies}>
{comment.replies.map((reply) => renderComment(reply, true))}
</div>
)}
</div>
</div>
);
return (
<section className={styles.section} aria-label="评论">
<Heading level={3} className={styles.title}>
评论 ({totalCount})
</Heading>
{/* 新评论表单 */}
{currentUser && onSubmit && (
<form onSubmit={handleSubmit} className={styles.form}>
<MediaObject
avatarSrc={currentUser.avatar}
avatarAlt={currentUser.name}
avatarInitials={currentUser.name.slice(0, 2).toUpperCase()}
avatarSize="sm"
title={
<FormField
name="comment"
label=""
placeholder="写评论..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
}
/>
<div className={styles.formActions}>
<Button type="submit" disabled={!newComment.trim()} isLoading={isLoading}>
发布评论
</Button>
</div>
</form>
)}
{/* 评论列表 */}
<div className={styles.list}>
{comments.length > 0 ? (
comments.map((comment) => renderComment(comment))
) : (
<Text color="muted" className={styles.empty}>
还没有评论。成为第一个评论者!
</Text>
)}
</div>
</section>
);
};
CommentSection.displayName = 'CommentSection';
侧边栏有机体示例
// 有机体/Sidebar/Sidebar.tsx
import React from 'react';
import { Avatar } from '@/components/atoms/Avatar';
import { Text } from '@/components/atoms/Typography';
import { NavItem } from '@/components/molecules/NavItem';
import styles from './Sidebar.module.css';
export interface SidebarLink {
id: string;
label: string;
href: string;
icon: string;
badge?: number;
}
export interface SidebarSection {
title?: string;
links: SidebarLink[];
}
export interface SidebarProps {
/** 用户信息 */
user?: {
name: string;
email: string;
avatar?: string;
};
/** 导航部分 */
sections: SidebarSection[];
/** 活动链接ID */
activeId?: string;
/** 折叠状态 */
isCollapsed?: boolean;
/** 切换折叠处理器 */
onToggleCollapse?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
user,
sections,
activeId,
isCollapsed = false,
onToggleCollapse,
}) => {
return (
<aside
className={`${styles.sidebar} ${isCollapsed ? styles.collapsed : ''}`}
>
{/* 用户信息 */}
{user && (
<div className={styles.user}>
<Avatar
src={user.avatar}
alt={user.name}
initials={user.name.slice(0, 2).toUpperCase()}
size={isCollapsed ? 'sm' : 'md'}
/>
{!isCollapsed && (
<div className={styles.userInfo}>
<Text weight="semibold" truncate>
{user.name}
</Text>
<Text size="sm" color="muted" truncate>
{user.email}
</Text>
</div>
)}
</div>
)}
{/* 导航部分 */}
<nav className={styles.nav}>
{sections.map((section, index) => (
<div key={index} className={styles.section}>
{section.title && !isCollapsed && (
<Text size="xs" color="muted" className={styles.sectionTitle}>
{section.title}
</Text>
)}
<ul className={styles.links}>
{section.links.map((link) => (
<li key={link.id}>
<NavItem
label={isCollapsed ? '' : link.label}
href={link.href}
icon={link.icon}
badge={isCollapsed ? undefined : link.badge}
isActive={link.id === activeId}
/>
</li>
))}
</ul>
</div>
))}
</nav>
{/* 折叠切换 */}
{onToggleCollapse && (
<button className={styles.collapseButton} onClick={onToggleCollapse}>
{isCollapsed ? '>' : '<'}
</button>
)}
</aside>
);
};
Sidebar.displayName = 'Sidebar';
数据表格有机体示例
// 有机体/DataTable/DataTable.tsx
import React, { useState } from 'react';
import { Checkbox } from '@/components/atoms/Checkbox';
import { Button } from '@/components/atoms/Button';
import { Icon } from '@/components/atoms/Icon';
import { Text } from '@/components/atoms/Typography';
import { ListItem } from '@/components/molecules/ListItem';
import styles from './DataTable.module.css';
export interface Column<T> {
id: string;
header: string;
accessor: keyof T | ((row: T) => React.ReactNode);
sortable?: boolean;
width?: string;
}
export interface DataTableProps<T extends { id: string }> {
/** 表格列 */
columns: Column<T>[];
/** 表格数据 */
data: T[];
/** 选择的行ID */
selectedIds?: string[];
/** 选择更改处理器 */
onSelectionChange?: (ids: string[]) => void;
/** 排序字段 */
sortField?: string;
/** 排序方向 */
sortDirection?: 'asc' | 'desc';
/** 排序更改处理器 */
onSort?: (field: string) => void;
/** 行点击处理器 */
onRowClick?: (row: T) => void;
/** 加载状态 */
isLoading?: boolean;
/** 空状态消息 */
emptyMessage?: string;
}
export function DataTable<T extends { id: string }>({
columns,
data,
selectedIds = [],
onSelectionChange,
sortField,
sortDirection,
onSort,
onRowClick,
isLoading = false,
emptyMessage = '没有可用数据',
}: DataTableProps<T>) {
const allSelected = data.length > 0 && selectedIds.length === data.length;
const someSelected = selectedIds.length > 0 && !allSelected;
const handleSelectAll = () => {
if (onSelectionChange) {
onSelectionChange(allSelected ? [] : data.map((row) => row.id));
}
};
const handleSelectRow = (id: string, selected: boolean) => {
if (onSelectionChange) {
onSelectionChange(
selected
? [...selectedIds, id]
: selectedIds.filter((selectedId) => selectedId !== id)
);
}
};
const getCellValue = (row: T, column: Column<T>) => {
if (typeof column.accessor === 'function') {
return column.accessor(row);
}
return row[column.accessor] as React.ReactNode;
};
return (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
{onSelectionChange && (
<th className={styles.checkboxCell}>
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={handleSelectAll}
aria-label="选择所有行"
/>
</th>
)}
{columns.map((column) => (
<th
key={column.id}
style={{ width: column.width }}
className={column.sortable ? styles.sortable : ''}
onClick={() => column.sortable && onSort?.(column.id)}
>
<div className={styles.headerContent}>
{column.header}
{column.sortable && sortField === column.id && (
<Icon
name={sortDirection === 'asc' ? 'arrow-up' : 'arrow-down'}
size="xs"
/>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className={styles.tbody}>
{isLoading ? (
<tr>
<td colSpan={columns.length + (onSelectionChange ? 1 : 0)}>
<div className={styles.loading}>加载中...</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length + (onSelectionChange ? 1 : 0)}>
<div className={styles.empty}>
<Text color="muted">{emptyMessage}</Text>
</div>
</td>
</tr>
) : (
data.map((row) => (
<tr
key={row.id}
className={`${styles.row} ${
selectedIds.includes(row.id) ? styles.selected : ''
} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row)}
>
{onSelectionChange && (
<td
className={styles.checkboxCell}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.includes(row.id)}
onChange={(e) => handleSelectRow(row.id, e.target.checked)}
aria-label={`选择行 ${row.id}`}
/>
</td>
)}
{columns.map((column) => (
<td key={column.id}>{getCellValue(row, column)}</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
DataTable.displayName = 'DataTable';
最佳实践
1. 保持业务逻辑包含
// 良好:有机体管理自己的相关状态
const ProductCard = ({ product, onAddToCart }) => {
const [isAdding, setIsAdding] = useState(false);
const handleAddToCart = async () => {
setIsAdding(true);
await onAddToCart(product.id);
setIsAdding(false);
};
return <Button onClick={handleAddToCart} isLoading={isAdding}>添加</Button>;
};
// 不佳:状态无理由外部管理
const ProductCard = ({ product, isAdding, onAddToCart }) => {
return <Button onClick={() => onAddToCart(product.id)} isLoading={isAdding}>添加</Button>;
};
2. 接受数据对象
// 良好:接受领域对象
interface HeaderProps {
user: User;
navigation: NavLink[];
}
// 不佳:扁平属性爆炸
interface HeaderProps {
userName: string;
userEmail: string;
userAvatar: string;
navItem1Label: string;
navItem1Href: string;
// ...无尽属性
}
3. 使用清晰层次结构组合
// 良好:清晰的分子/原子组合
const Header = () => (
<header>
<Logo /> {/* 原子 */}
<Navigation> {/* 分子 */}
<NavItem />
<NavItem />
</Navigation>
<SearchForm /> {/* 分子 */}
<UserMenu /> {/* 分子 */}
</header>
);
避免的反模式
1. 有机体内嵌有机体
// 不佳:嵌套有机体
const Dashboard = () => (
<div>
<Header /> {/* 有机体 */}
<Sidebar /> {/* 有机体 */}
<MainContent>
<DataTable /> {/* 另一个有机体 */}
</MainContent>
</div>
);
// 这应该是一个模板,而不是有机体!
2. 过于通用的有机体
// 不佳:通用的“部分”有机体
const Section = ({ children }) => <section>{children}</section>;
// 良好:特定、有目的的有机体
const ProductSection = ({ products }) => { ... };
const CommentSection = ({ comments }) => { ... };
何时使用此技能
- 构建页面部分,如页眉和页脚
- 创建复杂交互组件
- 组装产品或内容卡片
- 构建数据展示组件
- 创建带验证的表单容器
相关技能
atomic-design-fundamentals- 核心方法论概述atomic-design-molecules- 将原子组合成分子atomic-design-templates- 无内容的页面布局