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

﻿# PisellCurrency 组件开发文档

## 组件信息

**组件名称**：PisellCurrency

**组件版本**：1.0.0

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

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

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

---

## 组件描述

PisellCurrency 是基于 PisellNumber 派生的货币组件，专门用于处理价格、费用、金额等货币相关的数值场景。它继承了 PisellNumber
的所有基础能力，并在此基础上添加了货币符号、千分位分隔等货币特有的语义化扩展。

**核心价值**：

- 语义化：通过组件名明确表达数据类型（金额）
- 规范化：默认启用千分位和2位小数，符合财务规范
- 国际化：支持多种货币符号和位置
- 业务友好：针对货币场景的特定校验（如负数控制）

**适用场景**：

- 商品价格输入/展示
- 订单金额计算
- 财务报表展示
- 退款金额处理

---

## 组件需求点罗列

### 功能需求

1. **继承 PisellNumber 的能力**

- ✅ 三态视图（只读/编辑/禁用）
- ✅ 受控/非受控模式
- ✅ 数值校验
- ✅ 事件回调

2. **货币符号处理**

- ✅ 自定义货币符号（¥、$、€、£ 等）
- ✅ 符号位置配置（前置/后置）
- ✅ 可选择是否显示符号
- ✅ 符号与数值无空格（¥100）

3. **格式化能力**

- ✅ 默认千分位分隔符
- ✅ 默认 2 位小数精度
- ✅ 自定义格式化函数

4. **货币特有校验**

- ✅ 负数控制（allowNegative）
- ✅ 价格场景：不允许负数
- ✅ 退款场景：允许负数

5. **国际化支持**

- ✅ 货币代码标识（currencyCode）
- ✅ 多种货币符号支持

---

## 组件 API

### Props 接口定义

```
/**
 * PisellCurrency 组件 Props
 * 继承 PisellNumber 的所有属性
 */
interface PisellCurrencyProps extends PisellNumberProps {
    // ========== 货币特有属性 ==========

    /** 货币符号，默认 '¥' */
    currencySymbol?: string;

    /** 符号位置，默认 'before' */
    symbolPosition?: 'before' | 'after';

    /** 是否显示货币符号，默认 true */
    showSymbol?: boolean;

    /** 货币代码（用于标识），如 'CNY', 'USD' */
    currencyCode?: string;

    /** 是否允许负数，默认 true */
    allowNegative?: boolean;

    // 以下属性从 PisellNumber 继承，但修改了默认值
    /** 小数精度，默认 2（货币规范） */
    precision?: number;

    /** 是否使用千分位，默认 true（货币规范） */
    useGrouping?: boolean;
}
```

---

## 组件模块拆解

### 文件结构

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

### 核心模块说明

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

**职责**：

- 计算显示状态
- 根据状态路由到子组件
- 处理货币特有属性的默认值

**关键逻辑**：

```
PisellCurrency = (props) => {
    // 解构货币特有属性，并设置默认值
    const {
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        precision = 2,           // 货币默认2位小数
        useGrouping = true,      // 货币默认千分位
        allowNegative = true,
        ...rest
    } = props;

    // 计算显示状态
    const displayState = getDisplayState(props.mode, props.disabled);

    // 路由到子组件，传入处理后的 props
    switch (displayState) {
        case 'read':
            return <ReadOnlyView {...enhancedProps} />;
        case 'edit':
            return <EditView {...enhancedProps} />;
        case 'disabled':
            return <DisabledView {...enhancedProps} />;
    }
}
```

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

**职责**：

- 格式化货币数值并展示
- 添加货币符号
- 应用字体样式

**关键逻辑**：

```
ReadOnlyView = (props) => {
    const {
        value,
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        precision = 2,
        useGrouping = true,
    } = props;

    // 使用 @pisell/utils 的 formatNumber 格式化
    const formatted = formatNumber(value, {precision, useGrouping});

    // 添加货币符号（无空格）
    let displayText = formatted ?? props.emptyText ?? '-';
    if (showSymbol && formatted) {
        displayText = symbolPosition === 'before'
            ? `${currencySymbol}${formatted}`
            : `${formatted}${currencySymbol}`;
    }

    return <span style = {...} > {displayText} < /span>;
}
```

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

**职责**：

- 渲染 InputNumber
- 使用 prefix 显示货币符号
- 管理状态和校验
- 负数校验

**关键逻辑**：

```
EditView = (props) => {
    const {
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        allowNegative = true,
        ...numberProps
    } = props;

    // 使用 useNumberInputState 管理状态
    const {value, isValid, errors, handleChange, handleBlur, handleFocus} =
        useNumberInputState({
            ...props,
            // 扩展负数校验
            validator: (v) => {
                if (!allowNegative && v !== null && v < 0) {
                    return '金额不能为负数';
                }
                return props.validator?.(v) ?? true;
            },
        });

    // 货币符号作为 prefix/addonAfter
    const prefixNode = showSymbol && symbolPosition === 'before' ? currencySymbol : undefined;
    const suffixNode = showSymbol && symbolPosition === 'after' ? currencySymbol : undefined;

    return (
        <InputNumber
            value = {value}
    onChange = {handleChange}
    onBlur = {handleBlur}
    onFocus = {handleFocus}
    prefix = {prefixNode}
    addonAfter = {suffixNode}
    precision = {props.precision ?? 2}
    {...
        numberProps
    }
    />
)
    ;
}
```

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

**职责**：

- 渲染禁用的 InputNumber
- 显示货币符号

**实现**：复用编辑态的逻辑，但设置 disabled=true

#### 5. 工具函数

**currencyFormatter.ts**：

```
/**
 * 格式化货币数值
 */
export function formatCurrency(
    value: number | null,
    options: {
        currencySymbol: string;
        symbolPosition: 'before' | 'after';
        showSymbol: boolean;
        precision: number;
        useGrouping: boolean;
    }
): string | null {
    if (value === null) return null;

    // 1. 基础格式化（使用 @pisell/utils 的 formatNumber）
    const formatted = formatNumber(value, {
        precision: options.precision,
        useGrouping: options.useGrouping,
    });

    if (!formatted) return null;

    // 2. 添加货币符号（无空格）
    if (!options.showSymbol) {
        return formatted;
    }

    return options.symbolPosition === 'before'
        ? `${options.currencySymbol}${formatted}`
        : `${formatted}${options.currencySymbol}`;
}
```

---

## 组件实现方案

### 技术选型

**基础依赖**：

- React 18+
- Ant Design 5.x (InputNumber 组件)
- @pisell/utils (formatNumber, useNumberInputState, getDisplayState)
- PisellNumber (在开发过程中引用其类型和逻辑)

**实现方式**：

- 独立组件实现（不是直接包装）
- 三态视图分离（ReadOnly/Edit/Disabled）
- 复用 PisellNumber 的工具函数和类型

### 实现步骤

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

```
import type {PisellNumberProps} from '../pisellNumber';

/**
 * PisellCurrency 组件 Props
 */
export interface PisellCurrencyProps extends PisellNumberProps {
    currencySymbol?: string;
    symbolPosition?: 'before' | 'after';
    showSymbol?: boolean;
    currencyCode?: string;
    allowNegative?: boolean;
}

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

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

**currencyFormatter.ts**：

```
import {formatNumber} from '@pisell/utils';

export function formatCurrency(
    value: number | null,
    options: CurrencyFormatOptions
): string | null {
    if (value === null || value === undefined) {
        return null;
    }

    const formatted = formatNumber(value, {
        precision: options.precision,
        useGrouping: options.useGrouping,
    });

    if (!formatted) return null;
    if (!options.showSymbol) return formatted;

    // 符号和数值之间无空格
    return options.symbolPosition === 'before'
        ? `${options.currencySymbol}${formatted}`
        : `${formatted}${options.currencySymbol}`;
}
```

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

**ReadOnlyView.tsx**：

```
import React from 'react';
import {formatCurrency} from '../utils/currencyFormatter';
import type {PisellCurrencyProps} from '../types';

const ReadOnlyView: React.FC<PisellCurrencyProps> = (props) => {
    const {
        value,
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        precision = 2,
        useGrouping = true,
        emptyText = '-',
        fontSize,
        fontWeight,
        color,
        textAlign = 'left',
        className,
        style,
        'aria-label': ariaLabel,
    } = props;

    // 格式化货币
    const displayValue = formatCurrency(value, {
        currencySymbol,
        symbolPosition,
        showSymbol,
        precision,
        useGrouping,
    });

    const text = displayValue ?? emptyText;

    const combinedStyle: React.CSSProperties = {
        fontSize,
        fontWeight,
        color,
        textAlign,
        ...style,
    };

    return (
        <span
            className = {`pisell-currency pisell-currency-read ${className || ''}`
}
    style = {combinedStyle}
    aria - label = {ariaLabel}
        >
        {text}
        < /span>
)
    ;
};

export default ReadOnlyView;
```

**EditView.tsx**：

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

const EditView: React.FC<PisellCurrencyProps> = (props) => {
    const {
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        allowNegative = true,
        precision = 2,
        useGrouping = true,
        min,
        max,
        ...restProps
    } = props;

    // 扩展校验逻辑：负数校验
    const enhancedValidator = useMemo(() => {
        return (value: number | null): boolean | string => {
            // 负数校验
            if (!allowNegative && value !== null && value < 0) {
                return '金额不能为负数';
            }

            // 调用原始校验函数
            if (props.validator) {
                return props.validator(value);
            }

            return true;
        };
    }, [allowNegative, props.validator]);

    // 计算 min（考虑 allowNegative）
    const computedMin = useMemo(() => {
        if (min !== undefined) return min;
        if (!allowNegative) return 0;
        return undefined;
    }, [min, allowNegative]);

    // 使用状态管理 hook
    const {value, isValid, errors, handleChange, handleBlur, handleFocus} =
        useNumberInputState({
            ...restProps,
            validator: enhancedValidator,
            min: computedMin,
            max,
            precision,
        });

    // 货币符号显示在 prefix（前置）或 addonAfter（后置）
    const prefixNode = showSymbol && symbolPosition === 'before' ? currencySymbol : undefined;
    const addonAfterNode = showSymbol && symbolPosition === 'after' ? currencySymbol : undefined;

    return (
        <div className = {`pisell-currency pisell-currency-edit ${props.className || ''}`
}>
    <InputNumber
        value = {value ?? undefined
}
    onChange = {handleChange}
    onBlur = {handleBlur}
    onFocus = {handleFocus}
    onPressEnter = {()
=>
    props.onPressEnter?.(value ?? null)
}
    min = {computedMin}
    max = {max}
    step = {props.step}
    precision = {precision}
    prefix = {prefixNode}
    addonAfter = {addonAfterNode}
    placeholder = {props.placeholder}
    disabled = {props.disabled}
    autoFocus = {props.autoFocus}
    size = {props.size}
    keyboard = {props.keyboard}
    controls = {props.controls}
    status = {isValid ? undefined : 'error'}
    style = {props.style}
    aria - label = {props['aria-label']}
    aria - describedby = {props['aria-describedby']}
    id = {props.id}
    name = {props.name}
    />
    {
        !isValid && errors.length > 0 && (
            <div className = "pisell-currency-error" > {errors[0]} < /div>
        )
    }
    </div>
)
    ;
};

export default EditView;
```

**DisabledView.tsx**：

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

const DisabledView: React.FC<PisellCurrencyProps> = (props) => {
    const {
        value,
        currencySymbol = '¥',
        symbolPosition = 'before',
        showSymbol = true,
        precision = 2,
        ...restProps
    } = props;

    const prefixNode = showSymbol && symbolPosition === 'before' ? currencySymbol : undefined;
    const addonAfterNode = showSymbol && symbolPosition === 'after' ? currencySymbol : undefined;

    return (
        <div className = {`pisell-currency pisell-currency-disabled ${props.className || ''}`
}>
    <InputNumber
        disabled
    value = {value}
    min = {props.min}
    max = {props.max}
    step = {props.step}
    precision = {precision}
    prefix = {prefixNode}
    addonAfter = {addonAfterNode}
    placeholder = {props.placeholder}
    size = {props.size}
    controls = {props.controls}
    style = {props.style}
    id = {props.id}
    name = {props.name}
    />
    < /div>
)
    ;
};

export default DisabledView;
```

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

**PisellCurrency.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 {PisellCurrencyProps} from './types';
import './PisellCurrency.less';

/**
 * PisellCurrency 货币组件
 *
 * 基于 PisellNumber 派生，专门处理货币数据
 * 默认启用千分位和2位小数，符合财务规范
 */
export const PisellCurrency: React.FC<PisellCurrencyProps> = (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(PisellCurrency, (prevProps, nextProps) => {
    return (
        prevProps.value === nextProps.value &&
        prevProps.mode === nextProps.mode &&
        prevProps.disabled === nextProps.disabled &&
        prevProps.currencySymbol === nextProps.currencySymbol
    );
});
```

---

## 组件组成与引用关系

### 组件结构图

```
┌─────────────────────────────────────────┐
│      PisellCurrency (主组件)            │
│  - 状态计算 (getDisplayState)          │
│  - 路由子组件                           │
│  - 货币默认值处理                       │
└──────────┬──────────────────────────────┘
           │
           ├──> ReadOnlyView (只读态)
           │    - formatCurrency (格式化)
           │    - 货币符号添加
           │    - 样式配置
           │
           ├──> EditView (编辑态)
           │    - InputNumber (Ant Design)
           │    - useNumberInputState (状态管理)
           │    - prefix/addonAfter (货币符号)
           │    - 负数校验
           │    - 错误提示
           │
           └──> DisabledView (禁用态)
                - InputNumber (disabled)
                - 货币符号显示
```

### 依赖关系

**外部依赖**：

- React 18+
- Ant Design 5.x (InputNumber)
- @pisell/utils (formatNumber, useNumberInputState, getDisplayState)

**内部依赖**：

- types.ts → 类型定义（继承 PisellNumberProps）
- utils/currencyFormatter.ts → 货币格式化
- components/* → 子组件

**引用 PisellNumber**：

- 类型继承：`extends PisellNumberProps`
- 复用工具：formatNumber, useNumberInputState
- 不直接渲染 PisellNumber 组件

---

## 与 PisellNumber 的关系

### 继承的能力

1. **类型继承**：`PisellCurrencyProps extends PisellNumberProps`
2. **工具复用**：formatNumber, useNumberInputState, getDisplayState
3. **架构复用**：三态视图模式
4. **状态管理**：受控/非受控模式

### 扩展的能力

1. **货币符号**：currencySymbol, symbolPosition, showSymbol
2. **默认值覆盖**：precision=2, useGrouping=true
3. **负数控制**：allowNegative
4. **货币格式化**：formatCurrency 函数
5. **货币标识**：currencyCode

---

## 开发注意事项

### 1. 类型安全

- 继承 PisellNumberProps，复用所有基础类型
- 货币特有属性有明确的类型定义
- 导出所有公共类型

### 2. 默认值处理

- precision 默认 2
- useGrouping 默认 true
- currencySymbol 默认 '¥'
- symbolPosition 默认 'before'
- allowNegative 默认 true

### 3. 格式化逻辑

- 符号和数值之间**无空格**：`¥100` ✅，`¥ 100` ❌
- 使用 @pisell/utils 的 formatNumber
- 只读态和编辑态格式化逻辑一致

### 4. 负数校验

- allowNegative=false 时，拒绝负数输入
- 自动设置 min=0（如果未指定 min）
- 在 validator 中添加负数校验逻辑

### 5. 货币符号位置

- 前置：使用 `prefix` 属性（输入框内部）
- 后置：使用 `addonAfter` 属性（输入框外部）

### 6. 性能优化

- 主组件使用 React.memo
- EditView 中的 validator 使用 useMemo 缓存
- 格式化函数复用 @pisell/utils

---

## 开发时间评估

| 任务        | 预计时间     | 备注                     |
|-----------|----------|------------------------|
| 类型定义      | 0.5h     | 继承 PisellNumberProps   |
| 工具函数      | 1h       | currencyFormatter      |
| 子组件开发     | 2h       | ReadOnly/Edit/Disabled |
| 主组件开发     | 0.5h     | 状态路由                   |
| 样式编写      | 0.5h     | Less 样式                |
| 单元测试      | 2h       | 覆盖核心逻辑                 |
| Storybook | 1h       | 编写示例                   |
| 文档完善      | 1h       | 使用文档                   |
| **总计**    | **8.5h** | **约 1 个工作日**           |

---

## 货币符号显示方式对比

### 只读态

```
// 前置：¥1,234.56
currencySymbol = '¥', symbolPosition = 'before'
→ 显示为文本：¥1, 234.56

// 后置：1,234.56€
currencySymbol = '€', symbolPosition = 'after'
→ 显示为文本：1, 234.56
€
```

### 编辑态

```
// 前置：使用 prefix
<InputNumber prefix = "¥"
value = {1234.56}
/>
→ 输入框内左侧显示
¥

// 后置：使用 addonAfter
< InputNumber
addonAfter = "€"
value = {1234.56}
/>
→ 输入框外右侧显示
€
```

---

## 常见货币配置

```
// 人民币
{
    currencySymbol: '¥', symbolPosition
:
    'before', currencyCode
:
    'CNY'
}

// 美元
{
    currencySymbol: '$', symbolPosition
:
    'before', currencyCode
:
    'USD'
}

// 欧元
{
    currencySymbol: '€', symbolPosition
:
    'after', currencyCode
:
    'EUR'
}

// 英镑
{
    currencySymbol: '£', symbolPosition
:
    'before', currencyCode
:
    'GBP'
}
```
