---
title: 开发文档
order: 3
category: pro
---

﻿# PisellNumber 组件开发文档

## 组件信息

**组件名称**：PisellNumber

**组件版本**：1.0.0

**组件分类**：表单输入 / 数据展示

**开发状态**：开发中

**最后更新**：2026-01-26

---

## 组件描述

PisellNumber 是 Pisell 2.0 组件库中处理数值数据的基础组件。它封装了数值输入、展示、校验等核心功能，提供三种交互状态（只读/编辑/禁用），支持精度控制、格式化、范围校验等特性。

**核心价值**：

- 统一的数值输入体验
- 开箱即用的校验能力
- 灵活的格式化配置
- 可扩展为派生组件（货币、百分比等）

**适用场景**：

- 表单中的数值输入（数量、价格、重量等）
- 数据展示（统计数字、指标值）
- 表格中的数值列
- 需要精度控制的场景

---

## 组件需求点罗列

### 功能需求

1. **三态视图支持**
  - ✅ 只读态：纯文本展示
  - ✅ 编辑态：InputNumber 输入控件
  - ✅ 禁用态：禁用的输入控件

2. **数值输入能力**
  - ✅ 支持整数和小数输入
  - ✅ 支持负数输入
  - ✅ 支持步进器调节（+/-按钮）
  - ✅ 支持键盘快捷键（↑↓）
  - ✅ 支持鼠标滚轮调节

3. **格式化能力**
  - ✅ 精度控制（小数位数）
  - ✅ 千分位分隔符
  - ✅ 自定义格式化函数
  - ✅ 自定义解析函数

4. **校验能力**
  - ✅ 必填校验
  - ✅ 范围校验（min/max）
  - ✅ 自定义校验函数
  - ✅ 多种校验触发时机
  - ✅ 错误信息展示

5. **受控与非受控**
  - ✅ 受控模式（value + onChange）
  - ✅ 非受控模式（defaultValue）

6. **样式配置**
  - ✅ 前后缀支持（prefix/suffix）
  - ✅ 前后置标签（addonBefore/addonAfter）
  - ✅ 自定义样式（className/style）
  - ✅ 只读态字体样式配置

### 非功能需求

1. **性能要求**
  - 组件渲染性能优化（React.memo）
  - 事件处理函数缓存（useCallback）
  - 大数据量表格渲染优化

2. **可访问性**
  - 键盘导航支持
  - ARIA 属性支持
  - 屏幕阅读器友好

3. **可扩展性**
  - 可派生为货币、百分比等组件
  - 工具函数可复用
  - 类型定义可继承

---

## 组件 API

### Props 接口定义

```
/**
 * PisellNumber 组件 Props
 */
interface PisellNumberProps {
  // ========== 核心属性 ==========
  /** 显示模式：read=只读态，edit=编辑态 */
  mode?: 'read' | 'edit';
  
  /** 是否禁用 */
  disabled?: boolean;
  
  /** 当前值（受控） */
  value?: number | null;
  
  /** 默认值（非受控） */
  defaultValue?: number | null;
  
  /** 值变更回调 */
  onChange?: (value: number | null) => void;
  
  /** 失焦回调 */
  onBlur?: (value: number | null) => void;
  
  /** 聚焦回调 */
  onFocus?: () => void;
  
  /** 回车键回调 */
  onPressEnter?: (value: number | null) => void;
  
  // ========== 数值范围与精度 ==========
  /** 最小值 */
  min?: number;
  
  /** 最大值 */
  max?: number;
  
  /** 步进值，默认 1 */
  step?: number;
  
  /** 小数精度（小数位数） */
  precision?: number;
  
  // ========== 格式化配置 ==========
  /** 是否使用千分位分隔符 */
  useGrouping?: boolean;
  
  /** 自定义格式化函数 */
  formatter?: (value: number | null) => string;
  
  /** 自定义解析函数 */
  parser?: (displayValue: string) => number | null;
  
  // ========== 校验配置 ==========
  /** 是否必填 */
  required?: boolean;
  
  /** 自定义校验函数 */
  validator?: (value: number | null) => boolean | string;
  
  /** 校验触发时机 */
  validateTrigger?: 'onChange' | 'onBlur' | 'both';
  
  /** 校验回调 */
  onValidate?: (isValid: boolean, errors: string[]) => void;
  
  /** 是否自动修正超出范围的值 */
  autoCorrect?: boolean;
  
  /** 自定义错误提示信息 */
  errorMessage?: string;
  
  // ========== UI 配置（编辑态）==========
  /** 占位文本 */
  placeholder?: string;
  
  /** 是否显示清空按钮 */
  allowClear?: boolean;
  
  /** 是否自动聚焦 */
  autoFocus?: boolean;
  
  /** 前缀图标或文本 */
  prefix?: ReactNode;
  
  /** 后缀图标或文本 */
  suffix?: ReactNode;
  
  /** 前置标签 */
  addonBefore?: ReactNode;
  
  /** 后置标签 */
  addonAfter?: ReactNode;
  
  /** 输入框尺寸 */
  size?: 'large' | 'middle' | 'small';
  
  // ========== UI 配置（只读态）==========
  /** 字号 */
  fontSize?: string | number;
  
  /** 字重 */
  fontWeight?: string | number;
  
  /** 文本颜色 */
  color?: string;
  
  /** 文本对齐方式 */
  textAlign?: 'left' | 'center' | 'right';
  
  /** 空值时的显示文本 */
  emptyText?: string;
  
  // ========== 样式类名 ==========
  /** 自定义类名 */
  className?: string;
  
  /** 自定义样式 */
  style?: CSSProperties;
  
  // ========== 无障碍访问 ==========
  /** ARIA 标签 */
  'aria-label'?: string;
  
  /** ARIA 描述 */
  'aria-describedby'?: string;
}
```

---

## 组件模块拆解

### 文件结构

```
pisellNumber/
├── index.tsx                      # 对外导出入口
├── PisellNumber.tsx               # 主组件（状态路由）
├── PisellNumber.less              # 组件样式
├── PisellNumber.test.tsx          # 单元测试
├── PisellNumber.stories.tsx       # Storybook 示例
├── types.ts                       # TypeScript 类型定义
│
├── components/                    # 子组件目录
│   ├── index.tsx                 # 子组件导出
│   ├── ReadOnlyView.tsx          # 只读态视图
│   ├── EditView.tsx              # 编辑态视图
│   └── DisabledView.tsx          # 禁用态视图
│
├── utils/                         # 工具函数目录
│   ├── index.ts                  # 工具函数导出
│   ├── numberFormatter.ts        # 数值格式化工具
│   └── numberValidator.ts        # 数值校验工具
│
└── docs/                          # 文档目录
    ├── pisellNumber.md           # 使用文档
    ├── pisellNumber.$tab-design.md  # 设计文档
    ├── pisellNumber.$tab-dev.md     # 开发文档（本文件）
    └── pisellNumber.$tab-test.md    # 测试文档
```

### 核心模块说明

#### 1. 主组件（PisellNumber.tsx）

**职责**：

- 计算显示状态（read/edit/disabled）
- 根据状态路由到不同子组件
- 使用 React.memo 优化性能

**伪代码**：

```
PisellNumber = (props) => {
  // 计算显示状态
  const displayState = getDisplayState(props.mode, props.disabled);
  
  // 路由到对应子组件
  switch (displayState) {
    case 'read':
      return <ReadOnlyView {...props} />;
    case 'edit':
      return <EditView {...props} />;
    case 'disabled':
      return <DisabledView {...props} />;
  }
}

// 性能优化
export default React.memo(PisellNumber);
```

#### 2. 只读态视图（ReadOnlyView.tsx）

**职责**：

- 格式化数值并展示为纯文本
- 支持自定义字体样式
- 处理空值显示

**关键逻辑**：

```
ReadOnlyView = (props) => {
  // 格式化数值
  const displayValue = formatNumber(props.value, {
    precision: props.precision,
    useGrouping: props.useGrouping,
    formatter: props.formatter,
  });
  
  // 处理空值
  const text = displayValue ?? props.emptyText ?? '-';
  
  // 应用样式
  const style = {
    fontSize: props.fontSize,
    fontWeight: props.fontWeight,
    color: props.color,
    textAlign: props.textAlign,
  };
  
  return <span style={style}>{text}</span>;
}
```

#### 3. 编辑态视图（EditView.tsx）

**职责**：

- 渲染 Ant Design InputNumber
- 管理输入状态（使用扩展的 useNumberInputState hook）
- 执行校验并展示错误

**关键逻辑**：

```
EditView = (props) => {
  // 使用状态管理 hook（参考 useTextInputState，需扩展支持数值）
  const { value, isValid, errors, handleChange, handleBlur, handleFocus } = 
    useNumberInputState(props);
  
  return (
    <>
      <InputNumber
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        onFocus={handleFocus}
        min={props.min}
        max={props.max}
        step={props.step}
        precision={props.precision}
        formatter={props.formatter}
        parser={props.parser}
        placeholder={props.placeholder}
        status={isValid ? undefined : 'error'}
        // ... 其他 props
      />
      {!isValid && <ErrorMessage errors={errors} />}
    </>
  );
}
```

#### 4. 禁用态视图（DisabledView.tsx）

**职责**：

- 渲染禁用状态的 InputNumber
- 应用禁用样式

**实现**：

```
DisabledView = (props) => {
  return (
    <InputNumber
      disabled
      value={props.value}
      // ... 其他配置
    />
  );
}
```

#### 5. 工具函数

**numberFormatter.ts**：

- `formatNumber(value, options)`: 格式化数值
- `addThousandsSeparator(value)`: 添加千分位分隔符
- `formatPrecision(value, precision)`: 格式化精度

**numberValidator.ts**：

- `validateNumber(value, rules)`: 校验数值
- `isInRange(value, min, max)`: 范围校验
- `validateRequired(value)`: 必填校验

---

## 组件实现方案

### 技术选型

**基础依赖**：

- React 18+
- Ant Design 5.x（InputNumber 组件）
- @pisell/utils（公共逻辑）

**状态管理**：

- 使用 @pisell/utils 中的 `getDisplayState` 工具
- 扩展类似 `useTextInputState` 的 hook 为 `useNumberInputState`
- 支持受控和非受控两种模式

### 实现步骤

#### 第一步：定义类型（types.ts）

```
// 1. 定义基础 Props 接口
export interface PisellNumberProps {
  mode?: 'read' | 'edit';
  disabled?: boolean;
  value?: number | null;
  defaultValue?: number | null;
  onChange?: (value: number | null) => void;
  // ... 其他属性
}

// 2. 从 @pisell/utils 导入公共类型
export type { DisplayState, ValidationResult } from '@pisell/utils';
```

#### 第二步：实现工具函数

**numberFormatter.ts**：

```
/**
 * 格式化数值
 */
export function formatNumber(
  value: number | null,
  options: {
    precision?: number;
    useGrouping?: boolean;
    formatter?: (v: number | null) => string;
  }
): string | null {
  if (value === null || value === undefined) {
    return null;
  }
  
  // 自定义格式化优先
  if (options.formatter) {
    return options.formatter(value);
  }
  
  let formatted = value;
  
  // 精度处理
  if (options.precision !== undefined) {
    formatted = Number(value.toFixed(options.precision));
  }
  
  // 千分位分隔
  if (options.useGrouping) {
    return formatted.toLocaleString('zh-CN', {
      minimumFractionDigits: options.precision,
      maximumFractionDigits: options.precision,
    });
  }
  
  return String(formatted);
}
```

**numberValidator.ts**：

```
/**
 * 校验数值
 */
export function validateNumber(
  value: number | null,
  rules: {
    required?: boolean;
    min?: number;
    max?: number;
    validator?: (v: number | null) => boolean | string;
    errorMessage?: string;
  }
): ValidationResult {
  const errors: string[] = [];
  
  // 必填校验
  if (rules.required && (value === null || value === undefined)) {
    errors.push(rules.errorMessage || '此字段为必填项');
    return { isValid: false, errors };
  }
  
  // 如果值为空且非必填，通过
  if (value === null || value === undefined) {
    return { isValid: true, errors };
  }
  
  // 范围校验
  if (rules.min !== undefined && value < rules.min) {
    errors.push(`不能小于 ${rules.min}`);
  }
  
  if (rules.max !== undefined && value > rules.max) {
    errors.push(`不能大于 ${rules.max}`);
  }
  
  // 自定义校验
  if (rules.validator) {
    const result = rules.validator(value);
    if (result !== true) {
      errors.push(typeof result === 'string' ? result : '校验失败');
    }
  }
  
  return {
    isValid: errors.length === 0,
    errors,
  };
}
```

#### 第三步：实现状态管理 Hook

在 `@pisell/utils` 中扩展或创建新的 hook：

```
// packages/utils/src/number-input/hooks/useNumberInputState.ts

export function useNumberInputState(props: BaseNumberInputProps): NumberInputState {
  const isControlled = props.value !== undefined;
  
  const [innerValue, setInnerValue] = useState(props.defaultValue ?? null);
  const [isValid, setIsValid] = useState(true);
  const [errors, setErrors] = useState<string[]>([]);
  
  const currentValue = isControlled ? props.value : innerValue;
  
  const handleChange = useCallback((newValue: number | null) => {
    if (!isControlled) {
      setInnerValue(newValue);
    }
    
    // 校验
    const shouldValidate = props.validateTrigger === 'onChange' || 
                          props.validateTrigger === 'both';
    if (shouldValidate) {
      const validation = validateNumber(newValue, props);
      setIsValid(validation.isValid);
      setErrors(validation.errors);
      props.onValidate?.(validation.isValid, validation.errors);
    }
    
    props.onChange?.(newValue);
  }, [isControlled, props]);
  
  const handleBlur = useCallback(() => {
    const shouldValidate = props.validateTrigger !== 'onChange';
    if (shouldValidate) {
      const validation = validateNumber(currentValue, props);
      setIsValid(validation.isValid);
      setErrors(validation.errors);
      props.onValidate?.(validation.isValid, validation.errors);
    }
    
    props.onBlur?.(currentValue);
  }, [currentValue, props]);
  
  const handleFocus = useCallback(() => {
    props.onFocus?.();
  }, [props]);
  
  return {
    value: currentValue,
    isValid,
    errors,
    handleChange,
    handleBlur,
    handleFocus,
  };
}
```

#### 第四步：实现子组件

**ReadOnlyView.tsx**：

```
import React from 'react';
import { formatNumber } from '../utils/numberFormatter';
import type { PisellNumberProps } from '../types';

const ReadOnlyView: React.FC<PisellNumberProps> = (props) => {
  const displayValue = formatNumber(props.value, {
    precision: props.precision,
    useGrouping: props.useGrouping,
    formatter: props.formatter,
  });
  
  const text = displayValue ?? props.emptyText ?? '-';
  
  const style = {
    fontSize: props.fontSize,
    fontWeight: props.fontWeight,
    color: props.color,
    textAlign: props.textAlign,
    ...props.style,
  };
  
  return (
    <span 
      className={`pisell-number-read ${props.className || ''}`}
      style={style}
      aria-label={props['aria-label']}
    >
      {text}
    </span>
  );
};

export default ReadOnlyView;
```

**EditView.tsx**：

```
import React from 'react';
import { InputNumber } from 'antd';
import { useNumberInputState } from '@pisell/utils';
import type { PisellNumberProps } from '../types';

const EditView: React.FC<PisellNumberProps> = (props) => {
  const { value, isValid, errors, handleChange, handleBlur, handleFocus } = 
    useNumberInputState(props);
  
  return (
    <div className="pisell-number-edit">
      <InputNumber
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        onFocus={handleFocus}
        onPressEnter={() => props.onPressEnter?.(value)}
        min={props.min}
        max={props.max}
        step={props.step}
        precision={props.precision}
        formatter={props.formatter}
        parser={props.parser}
        placeholder={props.placeholder}
        prefix={props.prefix}
        addonBefore={props.addonBefore}
        addonAfter={props.addonAfter}
        disabled={props.disabled}
        autoFocus={props.autoFocus}
        size={props.size}
        status={isValid ? undefined : 'error'}
        className={props.className}
        style={props.style}
        aria-label={props['aria-label']}
        aria-describedby={props['aria-describedby']}
      />
      {!isValid && errors.length > 0 && (
        <div className="pisell-number-error">{errors[0]}</div>
      )}
    </div>
  );
};

export default EditView;
```

**DisabledView.tsx**：

```
import React from 'react';
import { InputNumber } from 'antd';
import type { PisellNumberProps } from '../types';

const DisabledView: React.FC<PisellNumberProps> = (props) => {
  return (
    <InputNumber
      disabled
      value={props.value}
      min={props.min}
      max={props.max}
      step={props.step}
      precision={props.precision}
      formatter={props.formatter}
      placeholder={props.placeholder}
      prefix={props.prefix}
      addonBefore={props.addonBefore}
      addonAfter={props.addonAfter}
      size={props.size}
      className={props.className}
      style={props.style}
    />
  );
};

export default DisabledView;
```

#### 第五步：实现主组件

**PisellNumber.tsx**：

```
import React from 'react';
import { getDisplayState } from '@pisell/utils';
import ReadOnlyView from './components/ReadOnlyView';
import EditView from './components/EditView';
import DisabledView from './components/DisabledView';
import type { PisellNumberProps } from './types';
import './PisellNumber.less';

/**
 * PisellNumber 数值组件
 * 
 * 支持三种状态：只读、编辑、禁用
 * 基于 Ant Design InputNumber 开发
 */
export const PisellNumber: React.FC<PisellNumberProps> = (props) => {
  const { mode = 'edit', disabled = false } = props;
  
  // 计算显示状态
  const displayState = getDisplayState(mode, disabled);
  
  // 根据状态渲染对应子组件
  switch (displayState) {
    case 'read':
      return <ReadOnlyView {...props} />;
    case 'edit':
      return <EditView {...props} />;
    case 'disabled':
      return <DisabledView {...props} />;
    default:
      return null;
  }
};

// 性能优化
export default React.memo(PisellNumber, (prevProps, nextProps) => {
  return (
    prevProps.value === nextProps.value &&
    prevProps.mode === nextProps.mode &&
    prevProps.disabled === nextProps.disabled
  );
});
```

#### 第六步：编写样式

**PisellNumber.less**：

```
.pisell-number-read {
  display: inline-block;
  color: rgba(0, 0, 0, 0.85);
}

.pisell-number-edit {
  position: relative;
  
  .pisell-number-error {
    margin-top: 4px;
    color: #ff4d4f;
    font-size: 14px;
    line-height: 1.5;
  }
}
```

#### 第七步：导出组件

**index.tsx**：

```
export { default as PisellNumber } from './PisellNumber';
export type { PisellNumberProps } from './types';

// 重新导出公共类型
export type { DisplayState, ValidationResult } from './types';
```

---

## 组件组成与引用关系

### 组件结构图

```
┌─────────────────────────────────────────┐
│        PisellNumber (主组件)            │
│  - 状态计算 (getDisplayState)          │
│  - 路由子组件                           │
│  - 性能优化 (React.memo)               │
└──────────┬──────────────────────────────┘
           │
           ├──> ReadOnlyView (只读态)
           │    - formatNumber (格式化)
           │    - 样式配置
           │
           ├──> EditView (编辑态)
           │    - InputNumber (Ant Design)
           │    - useNumberInputState (状态管理)
           │    - validateNumber (校验)
           │    - 错误提示
           │
           └──> DisabledView (禁用态)
                - InputNumber (Ant Design, disabled)
```

### 依赖关系

**外部依赖**：

- React 18+ (核心框架)
- Ant Design 5.x (InputNumber 组件)
- @pisell/utils (公共工具)

**内部依赖**：

- types.ts → 类型定义
- utils/numberFormatter.ts → 格式化工具
- utils/numberValidator.ts → 校验工具
- components/* → 子组件

**@pisell/utils 依赖**：

- getDisplayState → 状态计算
- useNumberInputState → 状态管理 (需要扩展或新建)
- ValidationResult → 类型定义
- DisplayState → 类型定义

### 可复用部分

**可被其他组件复用的部分**：

1. `formatNumber` - 格式化工具（PisellCurrency、PisellPercent 可用）
2. `validateNumber` - 校验工具（所有数值类组件可用）
3. `useNumberInputState` - 状态管理 hook（所有数值输入组件可用）
4. `PisellNumberProps` - 类型定义（派生组件可继承）

**为派生组件预留的扩展点**：

1. Props 接口可通过 `extends` 继承
2. 格式化函数可通过 `formatter` 属性覆盖
3. 工具函数可直接导入使用
4. 子组件可复用或包装

---

## 扩展性设计

### 如何基于 PisellNumber 创建派生组件

**示例：创建 PisellCurrency 组件**

```
// PisellCurrency.tsx
import React from 'react';
import { PisellNumber } from '../pisellNumber';
import type { PisellNumberProps } from '../pisellNumber';

interface PisellCurrencyProps extends PisellNumberProps {
  currencySymbol?: string;      // 新增：货币符号
  symbolPosition?: 'before' | 'after';  // 新增：符号位置
  showSymbol?: boolean;          // 新增：是否显示符号
}

export const PisellCurrency: React.FC<PisellCurrencyProps> = (props) => {
  const {
    currencySymbol = '¥',
    symbolPosition = 'before',
    showSymbol = true,
    ...numberProps
  } = props;
  
  // 自定义格式化函数
  const formatter = (value: number | null) => {
    if (value === null) return '';
    
    // 基础格式化（千分位 + 精度）
    const formatted = value.toLocaleString('zh-CN', {
      minimumFractionDigits: props.precision ?? 2,
      maximumFractionDigits: props.precision ?? 2,
    });
    
    // 添加货币符号
    if (showSymbol) {
      return symbolPosition === 'before' 
        ? `${currencySymbol}${formatted}`
        : `${formatted}${currencySymbol}`;
    }
    
    return formatted;
  };
  
  return (
    <PisellNumber
      {...numberProps}
      formatter={formatter}
      precision={props.precision ?? 2}
      useGrouping
    />
  );
};
```

---

## 开发注意事项

### 1. 类型安全

- 严格使用 TypeScript，避免 any
- 为所有 Props 提供明确的类型定义
- 导出所有公共类型供外部使用

### 2. 性能优化

- 主组件使用 React.memo
- 事件处理函数使用 useCallback
- 格式化函数使用 useMemo（如果复杂）
- 避免不必要的重渲染

### 3. 状态管理

- 明确区分受控和非受控模式
- 受控模式下不维护内部状态
- 非受控模式下使用内部状态

### 4. 校验逻辑

- 校验触发时机可配置
- 错误信息可自定义
- 支持多种校验规则组合

### 5. 可访问性

- 提供 aria-label 和 aria-describedby
- 确保键盘导航可用
- 错误提示与输入框关联

### 6. 测试覆盖

- 单元测试覆盖核心逻辑
- 快照测试确保 UI 稳定
- 集成测试验证交互流程

### 7. 代码规范

- 遵循项目 ESLint 规则
- 使用 Prettier 格式化代码
- 添加 JSDoc 注释

---

## 后续优化方向

1. **国际化支持**
  - 支持多语言错误提示
  - 支持不同地区的数字格式

2. **增强功能**
  - 支持单位自动转换
  - 支持科学计数法显示
  - 支持更多格式化选项

3. **性能优化**
  - 虚拟滚动（表格场景）
  - 懒加载校验规则
  - 防抖优化（大量输入场景）

4. **工具完善**
  - 提供更多预设格式化函数
  - 提供更多预设校验规则
  - 提供单元测试工具函数

---

## 开发时间评估

| 任务               | 预计时间    | 备注                       |
|------------------|---------|--------------------------|
| 类型定义             | 1h      | 参考设计文档定义接口               |
| 工具函数             | 2h      | 格式化 + 校验                 |
| @pisell/utils 扩展 | 2h      | useNumberInputState hook |
| 子组件开发            | 3h      | ReadOnly/Edit/Disabled   |
| 主组件开发            | 1h      | 状态路由                     |
| 样式编写             | 1h      | Less 样式                  |
| 单元测试             | 3h      | 覆盖核心逻辑                   |
| Storybook        | 2h      | 编写示例                     |
| 文档完善             | 1h      | 使用文档                     |
| **总计**           | **16h** | **约 2 个工作日**             |
