原子设计:有机体Skill atomic-design-organisms

原子设计有机体技能专注于构建用户界面中的复杂组件,如导航栏、内容卡片和表单部分。它基于原子设计方法论,通过组合原子和分子来创建可重用、状态管理的独立UI部分,提高前端开发效率和代码可维护性。关键词:原子设计、UI组件、前端开发、React、组件化、状态管理、可重用性。

前端开发 0 次安装 0 次浏览 更新于 3/25/2026

名称: 原子设计-有机体 描述: 用于从分子和原子构建复杂的有机体,如页眉、页脚、产品卡片和侧边栏。有机体是独立的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 - 无内容的页面布局