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

﻿# PisellPhone 组件设计文档

> **组件定位**：PisellPhone 是 Pisell 组件库中的电话号码输入/展示组件，提供电话号码格式化、国家码选择、格式校验和拨号功能。组件基于
> Ant Design Input 组件开发，支持多国电话号码格式。

## 1 组件概述

### 1.1 组件定位

PisellPhone 是一个专注于电话号码处理的输入组件，具有以下特点：

- **格式化显示**：自动格式化电话号码（如 138 0000 0000）
- **国家码支持**：支持国家码选择（如 +86、+1、+44）
- **格式校验**：自动校验电话号码格式是否正确
- **拨号功能**：只读态支持点击唤起拨号（移动端）
- **状态灵活切换**：支持只读、编辑、禁用三种状态
- **代码复用**：复用 PisellSingleLineText 的公共逻辑

### 1.2 使用场景

- 用户注册/登录手机号输入
- 联系方式信息录入
- 客户管理系统电话字段
- 订单联系电话
- 紧急联系人电话

### 1.3 设计稿

Figma 设计稿：https://www.figma.com/design/pM8Ho6d7kCMv9vIBvFHUlj/Pisell-2.0-Component-Library?node-id=3308-5746&m=dev

### 1.4 与 SingleLineText 的关系

- **独立组件**：PisellPhone 是独立组件，不是继承关系
- **复用逻辑**：复用 `useTextInputState`、`validateValue` 等公共 hooks 和 utils
- **扩展功能**：在单行文本基础上增加格式化、国家码、拨号等特性

## 2 组件状态设计

### 2.1 三种状态模式

PisellPhone 支持三种状态，与 SingleLineText 保持一致：

| 状态      | 视觉表现                 | 交互行为                       | 透明度  | 使用场景        |
|---------|----------------------|----------------------------|------|-------------|
| **只读态** | 格式化显示电话号码，可点击拨号（移动端） | 不可编辑，点击唤起拨号（tel:协议）        | 100% | 信息展示、联系方式查看 |
| **编辑态** | 显示输入框+国家码选择器，自动格式化   | 可输入数字，实时格式化，支持校验提示         | 100% | 表单输入、电话号码编辑 |
| **禁用态** | 输入框灰化显示              | 不可编辑，无 hover/focus 状态，不可拨号 | 40%  | 表单禁用、权限限制   |

### 2.2 状态切换逻辑

```
// 状态逻辑与 SingleLineText 一致
interface StateControl {
  mode: 'read' | 'edit';
  disabled?: boolean;
}

function getDisplayState(mode: string, disabled: boolean) {
  if (disabled) return 'disabled';
  return mode;
}
```

## 3 核心功能设计

### 3.1 电话号码格式化

**格式化规则**：

```
// 中国大陆手机号格式化：138 0000 0000
function formatChinaMobile(phone: string): string {
  const cleaned = phone.replace(/\D/g, ''); // 移除非数字字符

  if (cleaned.length <= 3) return cleaned;
  if (cleaned.length <= 7) {
    return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
  }
  return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 7)} ${cleaned.slice(7, 11)}`;
}

// 中国固话格式化：010-12345678
function formatChinaLandline(phone: string): string {
  const cleaned = phone.replace(/\D/g, '');

  // 3位区号
  if (cleaned.length <= 3) return cleaned;
  if (cleaned.length <= 11) {
    return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
  }

  // 4位区号
  if (cleaned.length <= 4) return cleaned;
  return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
}

// 国际号码格式化：+86 138 0000 0000
function formatInternational(phone: string, countryCode: string): string {
  const cleaned = phone.replace(/\D/g, '');
  const formatted = formatByCountry(cleaned, countryCode);
  return `${countryCode} ${formatted}`;
}
```

**支持的格式**：

- 中国大陆手机号：`138 0000 0000`（11位）
- 中国固话：`010-12345678`（区号+号码）
- 国际号码：`+86 138 0000 0000`（国家码+号码）
- 美国号码：`+1 (555) 123-4567`
- 其他国家：根据国家码应用不同格式

### 3.2 国家码选择

**国家码配置**：

```
interface CountryCode {
  code: string; // 国家码，如 '+86'
  name: string; // 国家名称，如 '中国'
  flag: string; // 国旗 emoji 或图标
  format: string; // 号码格式，如 'XXX XXXX XXXX'
  maxLength: number; // 最大长度
  pattern: RegExp; // 校验正则
}

// 预设的国家码列表（常见国家）
const PRESET_COUNTRY_CODES: CountryCode[] = [
  // 亚洲
  { code: '+86', name: '中国', flag: '🇨🇳', format: 'XXX XXXX XXXX', maxLength: 11, pattern: /^1[3-9]\d{9}$/ },
  { code: '+852', name: '香港', flag: '🇭🇰', format: 'XXXX XXXX', maxLength: 8, pattern: /^\d{8}$/ },
  // ... 更多国家
];
```

**国家码选择器**：

- 下拉选择器（Dropdown + Select）
- 搜索功能（输入国家名称或代码）
- 常用国家置顶
- 显示国旗图标

### 3.3 拨号功能

**移动端拨号**：

```
// 只读态点击拨号
function handlePhoneClick(phone: string, countryCode: string) {
  // 检测是否为移动设备
  const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

  if (isMobile) {
    // 清理号码，保留数字和+号
    const cleaned = phone.replace(/[^\d+]/g, '');
    const fullNumber = countryCode + cleaned;

    // 使用 tel: 协议唤起拨号
    window.location.href = `tel:${fullNumber}`;
  }
}
```

**拨号按钮配置**：

- `enableDial`：是否启用拨号功能（默认 true，移动端）
- `showDialIcon`：是否显示拨号图标
- `onDial`：拨号回调事件

### 3.4 号码脱敏

**脱敏显示**：

```
// 中间号码脱敏：138****0000
function maskPhone(phone: string, maskPattern: string = 'middle'): string {
  const cleaned = phone.replace(/\D/g, '');

  switch (maskPattern) {
    case 'middle':
      // 中间4位脱敏
      return cleaned.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
    case 'last':
      // 后4位脱敏
      return cleaned.replace(/(\d{7})\d{4}/, '$1****');
    default:
      return cleaned;
  }
}

<PisellPhone
  mode="read"
  value="13800000000"
  enableMasking
  maskPattern="middle"
/>
// 显示：138****0000
```

## 4 组件 Props 设计

### 4.1 完整 Props 定义

```
interface PisellPhoneProps {
  // ========== 核心属性（继承自 SingleLineText）==========
  value?: string; // 电话号码（不含国家码）
  defaultValue?: string;
  mode?: 'read' | 'edit';
  disabled?: boolean;

  // ========== 电话号码特有配置 ==========
  countryCode?: string;
  defaultCountryCode?: string;
  showCountrySelector?: boolean;
  availableCountryCodes?: string[];
  commonCountryCodes?: string[];
  countryCodes?: CountryCode[];
  onCountryCodeChange?: (code: string) => void;
  autoFormat?: boolean;
  formatRule?: 'mobile' | 'landline' | 'auto';
  separator?: string;
  enableDial?: boolean;
  showDialIcon?: boolean;
  onDial?: (phone: string) => void;
  enableMasking?: boolean;
  maskPattern?: 'middle' | 'last' | 'custom';
  customMask?: (phone: string) => string;
  showFlag?: boolean;
  fontSize?: string | number;
  fontWeight?: string | number;
  color?: string;
  placeholder?: string;
  maxLength?: number;
  allowClear?: boolean;
  autoFocus?: boolean;
  required?: boolean;
  validateFormat?: boolean;
  customValidator?: (phone: string, countryCode: string) => boolean | string;
  validateTrigger?: 'onChange' | 'onBlur' | 'both';
  errorMessage?: string;
  onChange?: (value: string) => void;
  onBlur?: (value: string) => void;
  onFocus?: () => void;
  onValidate?: (isValid: boolean, errors: string[]) => void;
  className?: string;
  style?: React.CSSProperties;
  'aria-label'?: string;
  'aria-describedby'?: string;
}
```

### 4.2 与 SingleLineText 的差异

| 配置项                             | SingleLineText | Phone | 说明                  |
|---------------------------------|----------------|-------|---------------------|
| countryCode/showCountrySelector | ❌              | ✅     | 国家码选择               |
| autoFormat/formatRule           | ❌              | ✅     | 自动格式化               |
| enableDial/onDial               | ❌              | ✅     | 拨号功能                |
| enableMasking/maskPattern       | ❌              | ✅     | 脱敏显示                |
| validateFormat                  | ❌              | ✅     | 电话号码格式校验            |
| prefix/suffix/addon             | ✅              | ❌     | 电话号码不需要前后缀（国家码单独处理） |

## 5 技术实现要点

### 5.1 基于 Ant Design Input + Select

组件由 Input 和 Select 组合而成：

```
import { Input, Select, Space } from 'antd';
import { PhoneOutlined } from '@ant-design/icons';

// 编辑态渲染
function EditView(props: PisellPhoneProps) {
  const { value, handleChange, isValid, errors } = useTextInputState(props);
  const [countryCode, setCountryCode] = useState(props.defaultCountryCode || '+86');

  const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const inputValue = e.target.value;
    const cleaned = inputValue.replace(/\D/g, '');
    if (props.autoFormat !== false) {
      const formatted = formatPhone(cleaned, countryCode);
      handleChange(formatted);
    } else {
      handleChange(cleaned);
    }
  };

  return (
    <div className="pisell-phone-edit">
      <Space.Compact style={{ width: '100%' }}>
        {props.showCountrySelector !== false && (
          <Select value={countryCode} onChange={...} ...>
            {(props.countryCodes || COUNTRY_CODES).map(country => (
              <Select.Option key={country.code} value={country.code} ... />
            ))}
          </Select>
        )}
        <Input value={value} onChange={handlePhoneChange} ... />
      </Space.Compact>
      {!isValid && errors.length > 0 && <div className="pisell-phone-error">{errors[0]}</div>}
    </div>
  );
}
```

### 5.2 只读态拨号实现

```
import { Typography, Button } from 'antd';
import { PhoneOutlined } from '@ant-design/icons';

function ReadOnlyView(props: PisellPhoneProps) {
  const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
  const countryCode = props.countryCode || '+86';
  const displayValue = props.enableMasking
    ? maskPhone(props.value || '', props.maskPattern)
    : formatPhone(props.value || '', countryCode);
  const handleDial = () => {
    if (props.enableDial !== false && isMobile) {
      const cleaned = (props.value || '').replace(/\D/g, '');
      const fullNumber = countryCode + cleaned;
      props.onDial?.(fullNumber);
      window.location.href = `tel:${fullNumber}`;
    }
  };
  const canDial = props.enableDial !== false && isMobile;
  return (
    <div className="pisell-phone-read">
      {props.showFlag !== false && <span className="pisell-phone-flag">{getCountryFlag(countryCode)}</span>}
      <Typography.Text ... onClick={canDial ? handleDial : undefined}>{displayValue}</Typography.Text>
      {props.showDialIcon && canDial && <Button type="link" icon={<PhoneOutlined />} onClick={handleDial} />}
    </div>
  );
}
```

### 5.3 电话号码校验

```
// 电话号码校验函数
function validatePhone(
  phone: string,
  countryCode: string,
  props: PisellPhoneProps
): { isValid: boolean; errors: string[] } {
  const errors: string[] = [];
  const cleaned = phone.replace(/\D/g, '');

  if (props.required && !cleaned) {
    errors.push('请输入电话号码');
    return { isValid: false, errors };
  }
  if (props.validateFormat !== false && cleaned) {
    const countryConfig = COUNTRY_CODES.find(c => c.code === countryCode);
    if (countryConfig) {
      if (cleaned.length !== countryConfig.maxLength) errors.push(`电话号码应为 ${countryConfig.maxLength} 位`);
      if (!countryConfig.pattern.test(cleaned)) errors.push('电话号码格式不正确');
    }
  }
  if (props.customValidator) {
    const result = props.customValidator(cleaned, countryCode);
    if (result !== true) errors.push(typeof result === 'string' ? result : '校验失败');
  }
  return { isValid: errors.length === 0, errors };
}
```

### 5.4 复用 SingleLineText 逻辑

```
// 复用公共逻辑
import { useTextInputState } from '../pisellSingleLineText/hooks/useTextInputState';
import { getDisplayState } from '../pisellSingleLineText/utils/stateHelper';

function PisellPhone(props: PisellPhoneProps) {
  const displayState = getDisplayState(props.mode, props.disabled);
  const textState = useTextInputState({
    ...props,
    validator: (value) => {
      const result = validatePhone(value, props.countryCode || '+86', props);
      return result.isValid ? true : result.errors[0];
    }
  });
  // ... 组件实现
}
```

## 6 工具函数

### 6.1 格式化相关

```
// utils/phoneFormatter.ts

/**
 * 根据国家码格式化电话号码
 */
export function formatPhone(phone: string, countryCode: string): string {
  const cleaned = phone.replace(/\D/g, '');
  switch (countryCode) {
    case '+86': return formatChinaMobile(cleaned);
    case '+1': return formatUSPhone(cleaned);
    case '+44': return formatUKPhone(cleaned);
    default: return cleaned;
  }
}

/**
 * 检测号码类型（手机/固话）
 */
export function detectPhoneType(phone: string): 'mobile' | 'landline' | 'unknown' {
  const cleaned = phone.replace(/\D/g, '');
  if (/^1[3-9]\d{9}$/.test(cleaned)) return 'mobile';
  if (/^0\d{2,3}\d{7,8}$/.test(cleaned)) return 'landline';
  return 'unknown';
}
```

### 6.2 国家码相关

```
// utils/countryCode.ts

/**
 * 根据国家码获取国旗 emoji
 */
export function getCountryFlag(countryCode: string): string {
  const flagMap: Record<string, string> = {
    '+86': '🇨🇳', '+1': '🇺🇸', '+44': '🇬🇧', '+81': '🇯🇵', '+82': '🇰🇷',
  };
  return flagMap[countryCode] || '🌐';
}

/**
 * 根据国家码获取最大长度
 */
export function getMaxLength(countryCode: string): number {
  const country = COUNTRY_CODES.find(c => c.code === countryCode);
  return country?.maxLength || 15;
}
```

## 7 开发规范

### 7.1 文件结构

```
pisellPhone/
├── index.tsx
├── PisellPhone.tsx
├── PisellPhone.less
├── PisellPhone.test.tsx
├── types.ts
├── utils/
│   ├── index.ts
│   ├── phoneFormatter.ts
│   ├── phoneValidator.ts
│   └── countryCode.ts
├── components/
│   ├── ReadOnlyView.tsx
│   ├── EditView.tsx
│   ├── CountryCodeSelector.tsx
│   └── DisabledView.tsx
├── constants/
│   └── countryCodes.ts
└── docs/
    ├── pisellPhone.md
    ├── pisellPhone.$tab-design.md
    ├── pisellPhone.$tab-dev.md
    └── pisellPhone.$tab-test.md
```

**注意**：部分公共逻辑复用 `pisellSingleLineText` 的 hooks。

### 7.2 代码质量要求

1. **TypeScript 类型**：所有 Props 和国家码配置必须有明确的类型定义
2. **注释规范**：格式化、校验函数需详细注释
3. **测试覆盖**：单元测试覆盖率不低于 85%，重点测试格式化和校验
4. **国际化支持**：国家码和提示文案支持多语言

### 7.3 设计原则

1. **基于 Ant Design**：复用 Input 和 Select 组件
2. **逻辑复用**：复用 SingleLineText 的状态管理
3. **可扩展性**：支持自定义国家码列表和格式化规则
4. **用户体验**：自动格式化、智能校验

## 8 使用示例

### 8.1 基础用法

```
// 中国手机号输入
<PisellPhone
  mode="edit"
  countryCode="+86"
  placeholder="请输入手机号"
/>

// 只读态（可拨号）
<PisellPhone
  mode="read"
  value="13800000000"
  countryCode="+86"
  enableDial
/>
```

### 8.2 国家码选择

```
// 显示所有预设国家
<PisellPhone mode="edit" showCountrySelector defaultCountryCode="+86" onCountryCodeChange={(code) => console.log(code)} />

// 只显示指定国家
<PisellPhone mode="edit" availableCountryCodes={['+86', '+852', '+1', '+44']} defaultCountryCode="+86" />

// 设置常用国家（置顶显示）
<PisellPhone mode="edit" commonCountryCodes={['+86', '+1']} defaultCountryCode="+86" />

// 完全自定义国家码
<PisellPhone
  mode="edit"
  countryCodes={[
    { code: '+86', name: '中国', flag: '🇨🇳', maxLength: 11, pattern: /^1[3-9]\d{9}$/ },
    { code: '+1', name: '美国', flag: '🇺🇸', maxLength: 10, pattern: /^\d{10}$/ }
  ]}
/>
```

### 8.3 脱敏显示

```
<PisellPhone
  mode="read"
  value="13800000000"
  enableMasking
  maskPattern="middle"
/>
// 显示：138****0000
```

### 8.4 自定义校验

```
<PisellPhone
  mode="edit"
  required
  customValidator={(phone, countryCode) => {
    if (countryCode === '+86' && !phone.startsWith('1')) {
      return '手机号必须以1开头';
    }
    return true;
  }}
/>
```

## 9 总结

PisellPhone 是一个专注于电话号码处理的输入组件，具备以下核心能力：

- ✅ 三种状态模式（只读、编辑、禁用）
- ✅ 自动格式化（多国格式支持）
- ✅ 国家码选择（常用国家，支持搜索）
- ✅ 格式校验（根据国家码自动校验）
- ✅ 拨号功能（移动端自动检测）
- ✅ 脱敏显示（隐私保护）
- ✅ 代码复用，复用 SingleLineText 公共逻辑

本组件与 PisellSingleLineText 共享公共逻辑（部分 hooks/utils），同时提供电话号码特有的格式化、校验和拨号功能。
