---
title: 设计文档
order: 2
category: atom
---

# PisellCards 设计文档

## 设计思路

PisellCards 是一个卡片组件的命名空间容器，在我们的概念中，只要是按某种规律循环出来的 item，我们都称之为 card，比如：

- 商品列表 中的每一个商品
- tabs 列表 中的每一个 tab
- 菜单列表 中的每一个菜单
- 列表 中的每一个列表项

所以，我们需要一个最基础的 card 组件（PisellBasicCard），后续的所有业务卡片组件都应该基于它进行扩展。

## 一、PisellBasicCardProps 类型定义

### 1.1 设计说明

**重要：PisellBasicCard 不是一个实体组件，它仅仅是一个类型定义（Props 规范）。**

PisellBasicCardProps 作为所有卡片组件的基础类型，定义了卡片组件应该具备的通用能力：

- **通用性**：提供卡片所需的基础状态能力，不涉及具体业务逻辑
- **可扩展性**：业务方基于此 Props 扩展自己的业务字段
- **灵活性**：支持多种状态组合和自定义样式
- **约束性**：规范所有卡片组件的状态管理方式

### 1.2 类型定义

PisellBasicCardProps 基于 `React.HTMLAttributes<HTMLDivElement>` 扩展，新增参数如下：

```
/**
 * 基础卡片 Props 定义
 * 所有业务卡片都应该基于此 Props 进行扩展
 * 注意：这只是类型定义，不是实体组件
 */
export interface PisellBasicCardProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * 激活状态配置
   * 用于标识卡片是否处于激活/选中状态
   */
  active?: {
    value: boolean; // 是否激活
    style?: CSSProperties; // 激活状态下的自定义样式
    className?: string; // 激活状态下的自定义类名
    onChange?: (value: boolean) => void; // 激活状态改变回调
    extra?: any; // 激活状态下额外的数据或内容（根据业务场景可以是任意类型）
  };

  /**
   * 禁用状态配置
   * 用于标识卡片是否处于禁用状态
   */
  disabled?: {
    value: boolean; // 是否禁用
    style?: CSSProperties; // 禁用状态下的自定义样式
    className?: string; // 禁用状态下的自定义类名
    onChange?: (value: boolean) => void; // 禁用状态改变回调
    extra?: any; // 禁用状态下额外的数据或内容（根据业务场景可以是任意类型）
  };

  /**
   * 加载状态配置
   * 用于标识卡片是否处于加载状态
   */
  loading?: {
    value: boolean; // 是否加载中
    style?: CSSProperties; // 加载状态下的自定义样式
    className?: string; // 加载状态下的自定义类名
    onChange?: (value: boolean) => void; // 加载状态改变回调
    extra?: any; // 加载状态下额外的数据或内容（根据业务场景可以是任意类型）
  };

  /**
   * 卡片内容
   */
  children?: ReactNode;
}
```

### 1.3 字段说明

#### active 字段详解

- **value**：控制卡片是否处于激活状态，通常用于选中、聚焦等场景
- **style**：激活状态下应用的样式对象，会与卡片基础样式合并
- **className**：激活状态下应用的类名，用于自定义样式
- **onChange**：当激活状态改变时的回调函数，可用于受控模式
- **extra**：额外数据，根据业务场景可以是：
  - ReactNode：如角标、图标等 UI 元素
  - Object：自定义数据对象
  - String：提示文本
  - 任意其他类型

#### disabled 字段详解

- **value**：控制卡片是否处于禁用状态
- **style**：禁用状态下应用的样式对象
- **className**：禁用状态下应用的类名
- **onChange**：禁用状态改变回调（根据业务需要使用）
- **extra**：额外数据，如遮罩层提示、禁用原因说明等

#### loading 字段详解

- **value**：控制卡片是否处于加载状态
- **style**：加载状态下应用的样式对象
- **className**：加载状态下应用的类名
- **onChange**：加载状态改变回调（根据业务需要使用）
- **extra**：额外数据，如 loading 动画配置、骨架屏配置等

### 1.4 状态优先级建议

当多种状态同时存在时，建议遵循以下优先级（业务方可根据实际情况调整）：

1. **loading** > **disabled** > **active**
2. 当 loading 为 true 时，优先展示 loading 样式和 extra
3. 当 disabled 为 true 时（且非 loading 状态），展示 disabled 样式和 extra
4. 当 active 为 true 时（且非 loading、disabled 状态），展示 active 样式和 extra

### 1.5 业务方实现建议

业务方在实现卡片组件时，建议：

- 通过 `data-active`、`data-disabled`、`data-loading` 等属性标识当前状态
- 将状态的 style 和 className 合并到根元素上
- 合理处理 extra 字段的渲染逻辑
- 实现 onChange 回调的触发时机

## 二、业务卡片扩展规范

### 2.1 扩展原则

业务方在开发卡片组件时，应该遵循以下原则：

1. **必须基于 PisellBasicCardProps 进行 Props 扩展**
2. **必须实现 PisellBasicCardProps 中定义的状态处理逻辑**
3. **必须将业务卡片收纳到 PisellCards 命名空间下**
4. **不允许修改或删除 PisellBasicCardProps 的原有字段**

### 2.2 扩展示例

以商品卡片（ProductCard）为例：

```
// 1. 定义业务卡片的 Props，基于 PisellBasicCardProps 扩展
import { PisellBasicCardProps } from '../types';

export interface ProductCardProps extends PisellBasicCardProps {
  productId: string;
  productName: string;
  productImage: string;
  price: number;
  onBuy?: () => void;
  // ... 其他业务字段
}
```

```
// 2. 实现业务卡片组件
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { ProductCardProps } from './types';
import './ProductCard.less';

const ProductCard: React.FC<ProductCardProps> = (props) => {
  const {
    productId,
    productName,
    productImage,
    price,
    onBuy,
    active,
    disabled,
    loading,
    className,
    style,
    children,
    ...restProps
  } = props;

  // 合并状态样式
  const mergedStyle = useMemo(() => {
    let finalStyle = { ...style };

    if (loading?.value && loading?.style) {
      finalStyle = { ...finalStyle, ...loading.style };
    } else if (disabled?.value && disabled?.style) {
      finalStyle = { ...finalStyle, ...disabled.style };
    } else if (active?.value && active?.style) {
      finalStyle = { ...finalStyle, ...active.style };
    }

    return finalStyle;
  }, [style, active, disabled, loading]);

  // 合并状态类名
  const mergedClassName = useMemo(() => {
    return classNames(
      'product-card',
      className,
      {
        'product-card--active': active?.value,
        'product-card--disabled': disabled?.value,
        'product-card--loading': loading?.value,
      },
      active?.value && active?.className,
      disabled?.value && disabled?.className,
      loading?.value && loading?.className
    );
  }, [className, active, disabled, loading]);

  return (
    <div
      className={mergedClassName}
      style={mergedStyle}
      data-active={active?.value}
      data-disabled={disabled?.value}
      data-loading={loading?.value}
      {...restProps}
    >
      {/* 业务内容 */}
      <div className="product-card__content">
        <img src={productImage} alt={productName} />
        <h3>{productName}</h3>
        <p>¥{price}</p>
        <button onClick={onBuy} disabled={disabled?.value}>
          购买
        </button>
      </div>

      {/* 渲染状态的 extra 内容 */}
      {loading?.value && loading?.extra && (
        <div className="product-card__loading-extra">{loading.extra}</div>
      )}
      {disabled?.value && disabled?.extra && !loading?.value && (
        <div className="product-card__disabled-extra">{disabled.extra}</div>
      )}
      {active?.value &&
        active?.extra &&
        !loading?.value &&
        !disabled?.value && (
          <div className="product-card__active-extra">{active.extra}</div>
        )}

      {/* 自定义子内容 */}
      {children}
    </div>
  );
};

export default ProductCard;
```

### 2.3 实现要点

业务方在实现卡片组件时需要注意：

1. **状态样式合并**：将 active、disabled、loading 的 style 与基础 style 合并
2. **状态类名合并**：将 active、disabled、loading 的 className 与基础 className 合并
3. **data 属性标识**：通过 `data-active`、`data-disabled`、`data-loading` 标识状态，方便样式选择器
4. **extra 渲染**：根据状态优先级渲染对应的 extra 内容
5. **onChange 触发**：在合适的时机触发 onChange 回调（如点击、交互等）

### 2.4 目录结构

业务卡片的目录结构应该遵循以下规范：

```
packages/materials/src/components/PisellCards/
├── components/
│   ├── ProductCard/
│   │   ├── index.tsx              # 组件导出
│   │   ├── ProductCard.tsx        # 组件实现
│   │   ├── ProductCard.less       # 组件样式
│   │   └── types.ts               # 类型定义（基于 PisellBasicCardProps 扩展）
│   ├── TabCard/
│   │   ├── index.tsx
│   │   ├── TabCard.tsx
│   │   ├── TabCard.less
│   │   └── types.ts
│   └── ...
```

## 三、卡片收纳与使用规范

### 3.1 命名空间设计

PisellCards 作为命名空间容器，用于收纳所有卡片组件。**PisellCards 自身不作为组件直接使用。**

### 3.2 文件组织结构

```
packages/materials/src/components/PisellCards/
├── index.tsx                          # 主入口，导出 PisellCards 命名空间
├── PisellCards.tsx                    # 命名空间定义（空组件或仅用于类型导出）
├── PisellCards.less                   # 公共样式（如果需要）
├── PisellCards.test.tsx               # 测试文件
├── types.ts                           # PisellBasicCardProps 类型定义文件
├── components/                        # 所有卡片组件目录
│   ├── ProductCard/                   # 业务卡片示例（由业务方添加）
│   │   ├── index.tsx                  # 组件导出
│   │   ├── ProductCard.tsx            # 组件实现
│   │   ├── ProductCard.less           # 组件样式
│   │   └── types.ts                   # 类型定义（基于 PisellBasicCardProps 扩展）
│   ├── TabCard/
│   │   ├── index.tsx
│   │   ├── TabCard.tsx
│   │   ├── TabCard.less
│   │   └── types.ts
│   └── ...                            # 其他业务卡片
└── docs/                              # 文档目录
    ├── PisellCards.md
    ├── PisellCards.$tab-design.md
    ├── PisellCards.$tab-dev.md
    └── PisellCards.$tab-test.md
```

### 3.3 核心文件说明

#### types.ts - 基础类型定义

```
import { CSSProperties, ReactNode } from 'react';

/**
 * 基础卡片 Props 定义
 * 所有业务卡片都应该基于此 Props 进行扩展
 */
export interface PisellBasicCardProps
  extends React.HTMLAttributes<HTMLDivElement> {
  active?: {
    value: boolean;
    style?: CSSProperties;
    className?: string;
    onChange?: (value: boolean) => void;
    extra?: any;
  };
  disabled?: {
    value: boolean;
    style?: CSSProperties;
    className?: string;
    onChange?: (value: boolean) => void;
    extra?: any;
  };
  loading?: {
    value: boolean;
    style?: CSSProperties;
    className?: string;
    onChange?: (value: boolean) => void;
    extra?: any;
  };
  children?: ReactNode;
}
```

#### index.tsx - 命名空间导出

```
import ProductCard from './components/ProductCard';
import TabCard from './components/TabCard';
// ... 导入其他业务卡片

// 命名空间导出
const PisellCards = {
  ProductCard: ProductCard,
  TabCard: TabCard,
  // ... 其他业务卡片
};

export default PisellCards;

// 类型导出
export type { PisellBasicCardProps } from './types';
export type { ProductCardProps } from './components/ProductCard/types';
export type { TabCardProps } from './components/TabCard/types';
// ... 导出其他类型
```

### 3.4 使用方式

业务方在使用时，通过命名空间访问：

```
import PisellCards from '@pisell/materials';

// 使用商品卡片
<PisellCards.ProductCard
  productId="123"
  productName="商品名称"
  productImage="xxx"
  price={99}
  active={{ value: true, extra: <div>热卖</div> }}
  disabled={{ value: false }}
  loading={{ value: false }}
  onBuy={() => console.log('购买')}
/>

// 使用标签卡片
<PisellCards.TabCard
  label="首页"
  active={{ value: true, style: { borderColor: 'blue' } }}
  onClick={() => console.log('tab clicked')}
/>
```

## 四、业务方扩展流程

### 4.1 新增业务卡片的步骤

1. **在 components 目录下创建新卡片组件目录**

```
packages/materials/src/components/PisellCards/components/YourCard/
```

2. **定义类型（基于 PisellBasicCardProps 扩展）**

```
// YourCard/types.ts
import { PisellBasicCardProps } from '../../types';

/**
 * YourCard 组件 Props
 * 基于 PisellBasicCardProps 扩展
 */
export interface YourCardProps extends PisellBasicCardProps {
  // 添加你的业务字段
  field1: string;
  field2: number;
  onCustomEvent?: () => void;
  // ... 其他业务字段
}
```

3. **实现卡片组件**

```
// YourCard/YourCard.tsx
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { YourCardProps } from './types';
import './YourCard.less';

const YourCard: React.FC<YourCardProps> = (props) => {
  const {
    field1,
    field2,
    onCustomEvent,
    active,
    disabled,
    loading,
    className,
    style,
    children,
    ...restProps
  } = props;

  // 合并状态样式
  const mergedStyle = useMemo(() => {
    let finalStyle = { ...style };

    if (loading?.value && loading?.style) {
      finalStyle = { ...finalStyle, ...loading.style };
    } else if (disabled?.value && disabled?.style) {
      finalStyle = { ...finalStyle, ...disabled.style };
    } else if (active?.value && active?.style) {
      finalStyle = { ...finalStyle, ...active.style };
    }

    return finalStyle;
  }, [style, active, disabled, loading]);

  // 合并状态类名
  const mergedClassName = useMemo(() => {
    return classNames(
      'your-card',
      className,
      {
        'your-card--active': active?.value,
        'your-card--disabled': disabled?.value,
        'your-card--loading': loading?.value,
      },
      active?.value && active?.className,
      disabled?.value && disabled?.className,
      loading?.value && loading?.className
    );
  }, [className, active, disabled, loading]);

  return (
    <div
      className={mergedClassName}
      style={mergedStyle}
      data-active={active?.value}
      data-disabled={disabled?.value}
      data-loading={loading?.value}
      {...restProps}
    >
      {/* 你的业务内容 */}
      <div className="your-card__content">
        <div>{field1}</div>
        <div>{field2}</div>
      </div>

      {/* 渲染状态的 extra 内容 */}
      {loading?.value && loading?.extra}
      {disabled?.value && disabled?.extra && !loading?.value}
      {active?.value && active?.extra && !loading?.value && !disabled?.value}

      {/* 自定义子内容 */}
      {children}
    </div>
  );
};

export default YourCard;
```

```
// YourCard/index.tsx
export { default } from './YourCard';
export type { YourCardProps } from './types';
```

4. **在 PisellCards/index.tsx 中导出**

```
import YourCard from './components/YourCard';

const PisellCards = {
  YourCard: YourCard, // 添加这里
  // ... 其他卡片
};

export type { YourCardProps } from './components/YourCard/types'; // 添加类型导出
```

5. **编写样式文件**

```
// YourCard/YourCard.less
.your-card {
  position: relative;
  padding: 16px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;

  &--active {
    border-color: #1890ff;
    background-color: #e6f7ff;
  }

  &--disabled {
    cursor: not-allowed;
    opacity: 0.6;
  }

  &--loading {
    cursor: wait;
  }

  &__content {
    // 内容样式
  }
}
```

6. **编写测试文件**

```
// YourCard/YourCard.test.tsx
import { render } from '@testing-library/react';
import YourCard from './YourCard';

describe('YourCard', () => {
  it('should render correctly', () => {
    const { container } = render(<YourCard field1="test" field2={123} />);
    expect(container).toMatchSnapshot();
  });

  it('should apply active state', () => {
    const { container } = render(
      <YourCard field1="test" field2={123} active={{ value: true }} />
    );
    expect(container.querySelector('.your-card--active')).toBeTruthy();
  });
});
```

### 4.2 业务卡片命名规范

- **组件名称**：必须以 `Card` 结尾，例如：`ProductCard`、`TabCard`、`MenuCard`
- **目录名称**：与组件名称保持一致
- **文件命名**：
  - 组件文件：`[CardName].tsx`
  - 样式文件：`[CardName].less`
  - 类型文件：`types.ts`
  - 导出文件：`index.tsx`
  - 测试文件：`[CardName].test.tsx`
- **Props 接口命名**：`[CardName]Props`
- **类名前缀**：使用 kebab-case，如 `your-card`、`product-card`

## 五、注意事项

### 5.1 关于 PisellBasicCard

**重要：PisellBasicCard 不是一个实体组件，它仅仅是一个 Props 类型定义（PisellBasicCardProps）。**

业务方不需要也不应该导入或使用名为 `PisellBasicCard` 的组件，而是：

1. 导入 `PisellBasicCardProps` 类型
2. 基于此类型扩展自己的 Props
3. 自行实现状态处理逻辑

### 5.2 状态处理的一致性

所有业务卡片在实现状态处理时，建议遵循相同的模式：

- 合并状态样式（style）
- 合并状态类名（className）
- 添加 data 属性标识
- 按优先级渲染 extra 内容
- 在合适时机触发 onChange 回调

### 5.3 扩展性考虑

业务方在扩展 Props 时，注意：

- 不要删除或修改 PisellBasicCardProps 的原有字段
- 新增字段应该是可选的，除非业务必须
- 保持 Props 的扁平化，避免过度嵌套
- 使用语义化的字段命名

## 六、总结

1. **PisellBasicCardProps** 是所有卡片的基础类型定义，提供通用的状态管理能力（active、disabled、loading）
2. **PisellCards** 是命名空间容器，不作为组件直接使用，用于收纳所有业务卡片组件
3. 业务方必须基于 **PisellBasicCardProps** 扩展自己的 Props，并自行实现状态处理逻辑
4. 所有业务卡片必须收纳到 **PisellCards** 命名空间下，确保统一的使用方式
5. 通过统一的类型规范和实现模式，保证卡片组件的可维护性和一致性

## 七、快速检查清单

业务方在开发新卡片组件时，可以使用以下清单进行自检：

- [ ] Props 是否基于 PisellBasicCardProps 扩展？
- [ ] 是否实现了 active、disabled、loading 状态的样式合并？
- [ ] 是否实现了 active、disabled、loading 状态的类名合并？
- [ ] 是否添加了 data-\* 属性标识状态？
- [ ] 是否按优先级渲染了 extra 内容？
- [ ] 是否在合适时机触发了 onChange 回调？
- [ ] 组件是否收纳到 PisellCards 命名空间？
- [ ] 类型是否在 index.tsx 中导出？
- [ ] 是否编写了单元测试？
- [ ] 是否编写了使用文档？
