# 表单引擎 - UI 组件规范（自动化测试版）

> **版本**: v1.0  
> **创建日期**: 2025-12-25  
> **状态**: ✅ 基于实际代码实现  
> **用途**: 前端开发 + 自动化测试

---

## 📌 文档说明

### 目标读者

1. **前端开发工程师** - 了解组件 API 和使用方法
2. **自动化测试工程师** - 编写组件级别测试用例
3. **QA 工程师** - 手动测试组件功能

### 文档特点

- ✅ 基于**实际代码实现**，非臆想
- ✅ 包含**详细的组件 API**
- ✅ 包含**测试选择器**（CSS Selector / Test ID）
- ✅ 详细的**交互步骤**和**预期结果**
- ✅ 状态管理和数据流说明
- ⚠️ 标注与 PRD 的差异

---

## 📐 架构定位

表单引擎是**引擎层**模块，不提供完整的页面，只提供可复用的**UI组件**。

```
┌─────────────────────────────────────────────────────────────────┐
│                        业务应用层 (Pages)                        │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────────────┐       ┌─────────────────────┐          │
│  │      表单管理        │       │      审批中心        │          │
│  │   /forms            │       │   /approvals        │          │
│  │                     │       │                     │          │
│  │  • 表单设计器页面    │       │  • 发起申请页面      │          │
│  │  • 版本管理页面      │       │  • 我的申请页面      │          │
│  │  • 模板库页面        │       │  • 审批任务页面      │          │
│  └─────────────────────┘       └─────────────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                          │                     │
                          ▼                     ▼
┌─────────────────────────────────────────────────────────────────┐
│                   引擎层 (Components/Services)                   │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────────────┐       ┌─────────────────────┐          │
│  │      表单引擎 ⭐      │       │      审批引擎        │          │
│  │   form-engine       │       │   approval-engine   │          │
│  │                     │       │                     │          │
│  │  • FormRenderer     │       │  • DingProcessDesigner│         │
│  │  • FieldRenderers   │       │  • ProcessViewer     │          │
│  │  • FormValidator    │       │  • TaskHandler       │          │
│  └─────────────────────┘       └─────────────────────┘          │
└─────────────────────────────────────────────────────────────────┘
```

### 职责边界

|| 层级 | 模块 | UI 职责 |
||------|------|---------|
|| **业务应用** | 表单管理 | 提供完整的表单设计器**页面**（含左中右三栏布局） |
|| **业务应用** | 审批中心 | 提供完整的表单填写**页面**（发起申请） |
|| **引擎层** | 表单引擎 | 提供 `FormRenderer` **组件**（动态渲染表单） |
|| **引擎层** | 审批引擎 | 提供 `DingProcessDesigner` **组件**（流程设计器） |

> **⚠️ 重要**：本文档只定义**组件规范**，不定义页面布局。页面由业务应用层负责。

---

## 📦 组件清单

|| 组件名 | 路径 | 用途 | 复用性 |
||--------|------|------|--------|
|| **FormRenderer** ⭐ | `components/forms/FormRenderer.tsx` | 根据 JSON Schema 动态渲染表单 | ⭐⭐⭐ 高 |
|| FormFieldRenderer | `components/forms/FormFieldRenderer.tsx` | 单个字段渲染器 | 高 |
|| FieldInput | `components/forms/fields/FieldInput.tsx` | 文本输入字段 | 中 |
|| FieldSelect | `components/forms/fields/FieldSelect.tsx` | 下拉选择字段 | 中 |
|| FieldDate | `components/forms/fields/FieldDate.tsx` | 日期选择字段 | 中 |
|| FieldNumber | `components/forms/fields/FieldNumber.tsx` | 数字输入字段 | 中 |
|| FieldTextarea | `components/forms/fields/FieldTextarea.tsx` | 多行文本字段 | 中 |
|| FieldFile | `components/forms/fields/FieldFile.tsx` | 文件上传字段 | 中 |
|| FieldRadio | `components/forms/fields/FieldRadio.tsx` | 单选按钮字段 | 中 |
|| FieldCheckbox | `components/forms/fields/FieldCheckbox.tsx` | 复选框字段 | 中 |
|| FieldSubTable | `components/forms/fields/FieldSubTable.tsx` | 子表单（明细表）字段 | 中 |

---

## 📄 1. 核心组件：FormRenderer

> **文件**: `frontend/src/components/forms/FormRenderer.tsx`  
> **大小**: ~626 行  
> **依赖**: React, Lucide React, Shadcn UI

### 1.1 组件职责

`FormRenderer` 是表单引擎的核心组件，负责：

- ✅ 根据 `JSONSchema` **动态渲染**表单结构
- ✅ 根据 `UISchema` 控制字段**布局和样式**
- ✅ 处理表单数据的**双向绑定**
- ✅ 执行**字段级验证**（必填、格式、范围等）
- ✅ 支持**多部门提交人**选择（当用户有多个部门时）
- ✅ 支持**草稿保存**和**正式提交**
- ✅ 支持**字段权限控制**（editableFields, requiredFields, hiddenFields）
- ✅ 支持**国际化**（locale）

### 1.2 组件 API

```typescript
interface FormRendererProps {
  // 数据源
  schema: JSONSchema;                   // 表单结构定义（必填）
  uiSchema?: Record<string, UISchema>;  // UI 布局配置（可选）
  formData?: Record<string, any>;       // 表单数据（受控）
  
  // 渲染模式（规划中 - PRD F4.7）
  mode?: 'edit' | 'readonly';           // 默认 'edit'，readonly 用于只读详情展示
  
  // 字段权限控制（用于审批流程场景）
  editableFields?: string[];            // 可编辑字段列表（其他字段只读）
  requiredFields?: string[];            // 额外的必填字段列表（叠加到 schema.required）
  hiddenFields?: string[];              // 隐藏字段列表（不渲染）
  
  // 事件回调
  onChange?: (data: Record<string, any>) => void;  // 数据变化回调
  onSubmit?: (data: Record<string, any>, submitterDepartmentId?: string) => void | Promise<void>;  // 提交回调
  onSave?: (data: Record<string, any>) => void | Promise<void>;  // 保存草稿回调
  
  // 表单控制
  disabled?: boolean;                   // 是否禁用表单（全局）
  showSaveButton?: boolean;             // 是否显示保存按钮（默认 true）
  showSubmitButton?: boolean;           // 是否显示提交按钮（默认 true）
  submitButtonText?: string;            // 提交按钮文本（默认 "提交"）
  saveButtonText?: string;              // 保存按钮文本（默认 "保存草稿"）
  
  // 提交人信息（审批场景）
  submitter?: SubmitterInfo;            // 提交者信息（如果提供则显示用户信息区域）
  showSubmitterInfo?: boolean;          // 是否显示提交者信息（默认 true，当提供 submitter 时）
  
  // 国际化
  locale?: string;                      // 语言设置（默认 'zh-CN'）
}

interface SubmitterInfo {
  id: string;                           // 用户 ID
  name: string;                         // 用户姓名
  avatar?: string;                      // 头像 URL
  departments: Department[];            // 所属部门列表
}

interface Department {
  id: string;                           // 部门 ID
  name: string;                         // 部门名称
}

interface JSONSchema {
  type: string;                         // 字段类型
  title?: string;                       // 字段标题
  description?: string;                 // 字段描述
  properties?: Record<string, JSONSchema>;  // 对象类型的属性
  items?: JSONSchema;                   // 数组类型的元素
  required?: string[];                  // 必填字段列表
  enum?: any[];                         // 枚举值
  enumNames?: string[];                 // 枚举显示名称
  default?: any;                        // 默认值
  format?: string;                      // 格式约束（email | date | time 等）
  minimum?: number;                     // 数字最小值
  maximum?: number;                     // 数字最大值
  minLength?: number;                   // 字符串最小长度
  maxLength?: number;                   // 字符串最大长度
  pattern?: string;                     // 正则表达式验证
  minItems?: number;                    // 数组最小项数
  maxItems?: number;                    // 数组最大项数
  // 扩展字段
  'x-type'?: string;                    // 自定义类型（group | container）
  'x-layout'?: { ratios: number[]; gap?: number };  // 布局配置
  'x-column'?: number;                  // 列索引
  'x-column-order'?: string[];          // 列顺序
  'x-icon'?: string;                    // 分组图标
  'x-placeholder'?: string;             // 占位符
  [key: string]: any;
}

interface UISchema {
  'ui:widget'?: string;                 // Widget 类型
  'ui:options'?: Record<string, any>;   // Widget 选项
  'ui:placeholder'?: string;            // 占位符
  'ui:help'?: string;                   // 帮助文本
  'ui:disabled'?: boolean;              // 是否禁用
  'ui:readonly'?: boolean;              // 是否只读
  'ui:order'?: string[];                // 字段显示顺序
  [key: string]: any;
}
```

### 1.3 视觉布局

#### 整体结构

```
┌─────────────────────────────────────────────────────────────────┐
│  👤 提交人信息区域（如果提供 submitter）                          │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ [头像] 张三                         所属部门: [研发部 ▼]    │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  📝 表单字段区域                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ 报销类型 *: [差旅费 ▼]                                      │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ 报销金额 *: [______] 元                                     │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ 报销事由 *: [________________________]                      │  │
│  │             [________________________]                      │  │
│  ├───────────────────────────────────────────────────────────┤  │
│  │ 附件:       [选择文件] 或拖拽上传                            │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ⚠️ 验证错误提示区域（如果有）                                    │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ ⚠️ 报销金额：必须填写                                        │  │
│  │ ⚠️ 报销事由：至少10个字符                                    │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  🔘 操作按钮区域                                                 │
│  [保存草稿]  [提交]                                             │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
```

### 1.4 提交人信息区域

**显示条件**: `submitter !== undefined && showSubmitterInfo !== false`

**测试选择器**:
```typescript
// 提交人信息卡片
'.bg-gradient-to-r.from-blue-50'
'[data-testid="submitter-info"]' // 推荐添加

// 用户头像
'img[alt*="avatar"]'

// 用户名称
'.text-base.font-semibold'

// 部门选择器（多部门时显示）
'[data-testid="department-selector"]'
'select:has(option:contains("研发部"))'
```

**单部门情况**:
```html
<div class="bg-gradient-to-r from-blue-50 to-white rounded-xl border-2 border-blue-200 p-6 mb-6">
  <div class="flex items-center gap-4">
    <!-- 头像 -->
    <div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-medium text-lg">
      {submitter.name[0]}
    </div>
    
    <!-- 信息 -->
    <div class="flex-1">
      <div class="flex items-center gap-3">
        <span class="text-base font-semibold text-gray-900">
          {submitter.name}
        </span>
        <span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
          提交人
        </span>
      </div>
      <div class="text-sm text-gray-600 mt-1 flex items-center gap-2">
        <Building2 class="w-3.5 h-3.5 text-gray-400" />
        所属部门: {submitter.departments[0].name}
      </div>
    </div>
  </div>
</div>
```

**多部门情况**（submitter.departments.length > 1）:
```html
<div class="bg-gradient-to-r from-blue-50 to-white rounded-xl border-2 border-blue-200 p-6 mb-6">
  <div class="flex items-center gap-4">
    <!-- 头像 -->
    <div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-medium text-lg">
      {submitter.name[0]}
    </div>
    
    <!-- 信息 -->
    <div class="flex-1">
      <div class="flex items-center gap-3 mb-3">
        <span class="text-base font-semibold text-gray-900">
          {submitter.name}
        </span>
        <span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
          提交人
        </span>
      </div>
      
      <!-- 部门选择器 -->
      <div class="flex items-center gap-3">
        <label class="text-sm text-gray-600 flex items-center gap-1.5">
          <Building2 class="w-3.5 h-3.5 text-gray-400" />
          选择提交部门:
        </label>
        <Select
          value={selectedDepartment}
          onValueChange={setSelectedDepartment}
        >
          <SelectTrigger className="w-48 h-8 text-sm">
            <SelectValue placeholder="选择部门" />
          </SelectTrigger>
          <SelectContent>
            {submitter.departments.map((dept) => (
              <SelectItem key={dept.id} value={dept.id}>
                {dept.name}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      
      <!-- 提示信息 -->
      <div class="mt-2 text-xs text-gray-500 flex items-center gap-1">
        <Info class="w-3 h-3" />
        您属于多个部门，请选择以哪个部门的身份提交此申请
      </div>
    </div>
  </div>
</div>
```

**交互行为**:
```typescript
// 状态管理
const [selectedDepartment, setSelectedDepartment] = useState<string>(
  submitter?.departments[0]?.id || ''
);

// 提交时传递部门 ID
const handleSubmit = () => {
  if (onSubmit) {
    onSubmit(formData, selectedDepartment);
  }
};
```

### 1.5 字段权限控制

**应用场景**: 审批流程中，不同节点对字段有不同的权限

#### 1.5.1 字段权限配置

```typescript
<FormRenderer
  schema={schema}
  formData={data}
  // 字段权限配置
  editableFields={['approvalComment', 'paymentAccount']}  // 只有这两个字段可编辑
  requiredFields={['paymentAccount']}                     // 付款账号必填
  hiddenFields={['internalNote']}                         // 隐藏内部备注
/>
```

#### 1.5.2 权限控制逻辑

**测试选择器**:
```typescript
// 只读字段（被限制编辑）
'input[readonly]'
'.pointer-events-none.opacity-60' // 禁用样式

// 可编辑字段
'input:not([readonly])'
'textarea:not([readonly])'
```

**实现逻辑**:
```typescript
const getFieldPermission = (fieldName: string) => {
  // 1. 检查是否隐藏
  if (hiddenFields?.includes(fieldName)) {
    return { visible: false, editable: false, required: false };
  }
  
  // 2. 检查是否全局禁用
  if (disabled) {
    return { visible: true, editable: false, required: false };
  }
  
  // 3. 检查是否在可编辑列表中
  const editable = editableFields 
    ? editableFields.includes(fieldName) 
    : true; // 默认可编辑
  
  // 4. 检查是否额外必填
  const required = requiredFields?.includes(fieldName) || 
                   schema.required?.includes(fieldName) || 
                   false;
  
  return { visible: true, editable, required };
};
```

**渲染示例**:
```typescript
// 在字段渲染时应用权限
const permission = getFieldPermission(fieldName);

if (!permission.visible) {
  return null; // 隐藏字段
}

return (
  <FormFieldRenderer
    field={field}
    value={formData[fieldName]}
    onChange={handleFieldChange}
    disabled={!permission.editable}  // 应用可编辑性
    required={permission.required}   // 应用必填性
  />
);
```

### 1.6 字段验证

#### 1.6.1 验证规则

```typescript
const validateField = (fieldName: string, value: any, fieldSchema: JSONSchema) => {
  const errors: string[] = [];
  
  // 必填验证
  if (fieldSchema.required?.includes(fieldName) || requiredFields?.includes(fieldName)) {
    if (value === undefined || value === null || value === '') {
      errors.push('必须填写');
    }
  }
  
  // 字符串长度验证
  if (fieldSchema.minLength && typeof value === 'string' && value.length < fieldSchema.minLength) {
    errors.push(`至少${fieldSchema.minLength}个字符`);
  }
  if (fieldSchema.maxLength && typeof value === 'string' && value.length > fieldSchema.maxLength) {
    errors.push(`最多${fieldSchema.maxLength}个字符`);
  }
  
  // 数字范围验证
  if (fieldSchema.minimum !== undefined && typeof value === 'number' && value < fieldSchema.minimum) {
    errors.push(`不能小于${fieldSchema.minimum}`);
  }
  if (fieldSchema.maximum !== undefined && typeof value === 'number' && value > fieldSchema.maximum) {
    errors.push(`不能大于${fieldSchema.maximum}`);
  }
  
  // 格式验证
  if (fieldSchema.format === 'email' && value && !isValidEmail(value)) {
    errors.push('邮箱格式不正确');
  }
  
  // 正则验证
  if (fieldSchema.pattern && value && !new RegExp(fieldSchema.pattern).test(value)) {
    errors.push('格式不符合要求');
  }
  
  return errors;
};
```

#### 1.6.2 验证错误显示

**测试选择器**:
```typescript
// 错误提示区域
'.bg-red-50.border-red-200'
'[data-testid="validation-errors"]'

// 单个错误项
'.text-sm.text-red-600'
```

**渲染结构**:
```html
{validationErrors.length > 0 && (
  <Alert variant="destructive" className="mb-6">
    <AlertCircle className="h-4 w-4" />
    <AlertDescription>
      <div className="space-y-1">
        {validationErrors.map((error, index) => (
          <div key={index} className="text-sm">
            • {error.field}: {error.message}
          </div>
        ))}
      </div>
    </AlertDescription>
  </Alert>
)}
```

### 1.7 操作按钮

#### 1.7.1 保存草稿按钮

**显示条件**: `showSaveButton !== false && onSave !== undefined`

**测试选择器**:
```typescript
'button:has-text("保存草稿")'
'button:has(> svg.lucide-save)' // 包含保存图标的按钮
'[data-testid="save-draft-button"]' // 推荐添加
```

**样式**:
```typescript
<Button
  variant="outline"
  onClick={handleSave}
  disabled={saving || disabled}
  className="flex items-center gap-2"
>
  {saving ? (
    <Loader2 className="w-4 h-4 animate-spin" />
  ) : (
    <Save className="w-4 h-4" />
  )}
  {saveButtonText || '保存草稿'}
</Button>
```

**交互行为**:
```typescript
const handleSave = async () => {
  if (!onSave) return;
  
  setSaving(true);
  try {
    await onSave(formData);
    toast.success('草稿已保存');
  } catch (error) {
    toast.error('保存失败');
  } finally {
    setSaving(false);
  }
};
```

#### 1.7.2 提交按钮

**显示条件**: `showSubmitButton !== false`

**测试选择器**:
```typescript
'button:has-text("提交")'
'button:has(> svg.lucide-send)'
'[data-testid="submit-button"]' // 推荐添加
```

**样式**:
```typescript
<Button
  onClick={handleSubmit}
  disabled={submitting || disabled}
  className="flex items-center gap-2"
>
  {submitting ? (
    <Loader2 className="w-4 h-4 animate-spin" />
  ) : (
    <Send className="w-4 h-4" />
  )}
  {submitButtonText || '提交'}
</Button>
```

**交互行为**:
```typescript
const handleSubmit = async () => {
  // 1. 验证表单
  const errors = validateForm();
  if (errors.length > 0) {
    setValidationErrors(errors);
    toast.error('请检查表单填写');
    return;
  }
  
  // 2. 检查多部门情况
  if (submitter && submitter.departments.length > 1 && !selectedDepartment) {
    toast.error('请选择提交部门');
    return;
  }
  
  // 3. 调用提交回调
  if (!onSubmit) return;
  
  setSubmitting(true);
  try {
    await onSubmit(formData, selectedDepartment);
    toast.success('提交成功');
  } catch (error) {
    toast.error('提交失败');
  } finally {
    setSubmitting(false);
  }
};
```

### 1.8 状态管理

**内部状态**:
```typescript
const [formData, setFormData] = useState<Record<string, any>>(props.formData || {});
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [selectedDepartment, setSelectedDepartment] = useState<string>('');
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
```

**数据同步**:
```typescript
// 当 props.formData 变化时，更新内部状态
useEffect(() => {
  if (props.formData) {
    setFormData(props.formData);
  }
}, [props.formData]);

// 当内部数据变化时，触发 onChange 回调
const handleFieldChange = (fieldName: string, value: any) => {
  const newData = { ...formData, [fieldName]: value };
  setFormData(newData);
  
  if (onChange) {
    onChange(newData);
  }
  
  // 清除该字段的验证错误
  setValidationErrors(prev => prev.filter(e => e.field !== fieldName));
};
```

### 1.9 审批流程预览区域 🆕

**显示条件**: 表单需要审批 (`requiresApproval === true`) 且有流程配置

**布局位置**: 在表单字段区域下方，操作按钮上方

**测试选择器**:
```typescript
// 流程预览卡片
'.bg-white.rounded-xl.shadow-sm'
'[data-testid="approval-process-preview"]' // 推荐添加

// 卡片标题
'h3:has-text("审批流程预览")'

// 流程时间线
'[data-testid="process-timeline"]'

// 发起人节点
'.bg-\\[\\#565656\\].rounded-full' // 发起人图标

// 审批节点
'.w-8.h-8.rounded-full.bg-white.border-gray-300' // pending 状态节点图标
```

**视觉布局**:
```
┌─────────────────────────────────────────────────────────────────┐
│  📝 表单字段区域                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ [表单字段...]                                              │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  🔄 审批流程预览区域 (新增)                                      │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │ 📋 审批流程预览                                            │  │
│  │ ┌─────────────────────────────────────────────────────┐  │  │
│  │ │  ● 张三 (发起人)                                      │  │  │
│  │ │  │                                                    │  │  │
│  │ │  ○ 审批人 - 发起人上级                                 │  │  │
│  │ │  │                                                    │  │  │
│  │ │  ○ 财务审核 - 指定角色                                 │  │  │
│  │ │  │                                                    │  │  │
│  │ │  ◉ 流程结束                                           │  │  │
│  │ └─────────────────────────────────────────────────────┘  │  │
│  │ 💡 提交后将按上述流程进行审批                              │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  🔘 操作按钮区域                                                 │
│  [保存草稿]  [提交]                                             │
└─────────────────────────────────────────────────────────────────┘
```

**HTML 结构**:
```html
<!-- 审批流程预览卡片 -->
{requiresApproval && processModel && processModel.nodes.length > 0 && (
  <div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
    <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
      <Workflow className="w-5 h-5 text-blue-500" />
      审批流程预览
    </h3>
    <div className="border border-gray-100 rounded-lg p-4 bg-gray-50/50">
      <LarkProcessPreview
        model={processModel}
        showInitiator={true}
        initiatorName={submitter?.name || '当前用户'}
        showEndNode={true}
        compact={false}
      />
    </div>
    <p className="text-xs text-gray-500 mt-3 text-center">
      💡 提交后将按上述流程进行审批
    </p>
  </div>
)}
```

**数据来源**:
```typescript
// 从 getDesignData API 获取流程配置
const designData = await getDesignData(formDefinitionId);

// 审批流程模型
const processModel = designData.processVersion?.model;

// 检查是否需要审批
const requiresApproval = formDefinition.requiresApproval;
```

**复用组件**:
- **`LarkProcessPreview`** - 飞书风格流程预览组件（时间线样式）
- 路径: `@features/approval/designer/LarkProcessPreview`
- 参数:
  - `model`: 流程模型（ProcessModel）
  - `showInitiator`: 是否显示发起人节点（true）
  - `initiatorName`: 发起人名称（使用当前用户名称）
  - `showEndNode`: 是否显示结束节点（true）
  - `compact`: 是否紧凑模式（false）

**交互行为**:
- 纯展示，无交互
- 所有节点状态为 `pending`（待处理）
- 发起人显示当前用户名称
- 审批节点显示审批人类型（如"发起人上级"、"指定角色"）

**条件显示**:
- ✅ **表单填写完整**：显示完整的审批流程预览（时间线样式）
- ⚠️ **表单未填完**：显示占位提示，引导用户填写必填字段
  ```html
  <!-- 未填完时的占位提示 -->
  {!isFormValid && (
    <div className="border border-gray-100 rounded-lg p-8 bg-gray-50/50 text-center">
      <AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
      <p className="text-gray-600 text-sm">
        {t.forms.instance.processPreviewIncomplete}
      </p>
    </div>
  )}
  ```

**表单验证逻辑**:
```typescript
// 检查表单是否填写完整（复用 JSON Schema 验证逻辑）
useEffect(() => {
  if (!formVersion?.schema) {
    setIsFormValid(false);
    return;
  }

  const schema = formVersion.schema as any;
  const { properties = {}, required = [] } = schema;
  
  // 1. 检查所有必填字段是否都有值
  const requiredFieldsValid = required.every((fieldName: string) => {
    const value = formData[fieldName];
    // 检查值是否存在且不为空
    if (value === undefined || value === null) return false;
    if (typeof value === 'string' && value.trim() === '') return false;
    if (Array.isArray(value) && value.length === 0) return false;
    return true;
  });
  
  if (!requiredFieldsValid) {
    setIsFormValid(false);
    return;
  }
  
  // 2. 验证字段格式和约束（基于 JSON Schema）
  let hasFormatError = false;
  
  Object.keys(properties).forEach((fieldName) => {
    const fieldSchema = properties[fieldName];
    const fieldValue = formData[fieldName];
    
    // 跳过空值
    if (fieldValue === undefined || fieldValue === null || fieldValue === '') return;
    
    // 字符串类型验证
    if (fieldSchema.type === 'string' && typeof fieldValue === 'string') {
      // minLength, maxLength, pattern, format (email)
      if (fieldSchema.minLength && fieldValue.length < fieldSchema.minLength) hasFormatError = true;
      if (fieldSchema.maxLength && fieldValue.length > fieldSchema.maxLength) hasFormatError = true;
      if (fieldSchema.pattern && !new RegExp(fieldSchema.pattern).test(fieldValue)) hasFormatError = true;
      if (fieldSchema.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fieldValue)) hasFormatError = true;
    }
    
    // 数字类型验证
    if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
      const numValue = Number(fieldValue);
      // minimum, maximum
      if (fieldSchema.minimum !== undefined && numValue < fieldSchema.minimum) hasFormatError = true;
      if (fieldSchema.maximum !== undefined && numValue > fieldSchema.maximum) hasFormatError = true;
    }
    
    // 数组类型验证（子表单内部必填字段）
    if (fieldSchema.type === 'array' && Array.isArray(fieldValue)) {
      const itemSchema = fieldSchema.items as any;
      if (itemSchema?.type === 'object' && itemSchema.required) {
        fieldValue.forEach((row: any) => {
          itemSchema.required.forEach((itemFieldName: string) => {
            const itemValue = row?.[itemFieldName];
            if (!itemValue || (typeof itemValue === 'string' && !itemValue.trim())) {
              hasFormatError = true;
            }
          });
        });
      }
    }
  });
  
  setIsFormValid(!hasFormatError);
}, [formData, formVersion]);
```

**验证规则详解**：
1. **必填字段（required）**：
   - 字符串：不能为空字符串
   - 数组：不能为空数组
   - 其他：不能为 undefined 或 null

2. **字符串约束**：
   - `minLength` / `maxLength`：最小/最大长度
   - `pattern`：正则表达式验证
   - `format: 'email'`：邮箱格式验证

3. **数字约束**：
   - `minimum` / `maximum`：最小/最大值

4. **子表单（array）**：
   - 验证每一行的必填字段
   - 递归检查内部对象的 required 字段

**空状态处理**:
```html
<!-- 无审批流程配置 -->
{requiresApproval && (!processModel || processModel.nodes.length === 0) && (
  <div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center mb-6">
    <AlertCircle className="w-8 h-8 text-amber-500 mx-auto mb-2" />
    <p className="text-sm text-amber-700">
      此表单需要审批，但尚未配置审批流程
    </p>
  </div>
)}
```

**测试用例**:
```typescript
describe('审批流程预览', () => {
  test('需要审批的表单且填写完整应显示完整流程预览', () => {
    const processModel = {
      nodes: [
        { id: '1', type: 'START', name: '开始' },
        { id: '2', type: 'USER_TASK', name: '主管审批', config: { approverType: 'INITIATOR_MANAGER' } },
        { id: '3', type: 'END', name: '结束' },
      ],
      edges: [],
    };
    
    const formData = {
      requiredField1: '已填写',
      requiredField2: '已填写',
    };
    
    render(
      <FormSubmitPage
        schema={mockSchema}
        formData={formData}
        processVersion={{ model: processModel }}
      />
    );
    
    expect(screen.getByText('审批流程')).toBeInTheDocument();
    expect(screen.getByText('当前用户')).toBeInTheDocument();
    expect(screen.getByText(/发起人上级/i)).toBeInTheDocument();
    expect(screen.getByText(/提交后将按上述流程进行审批/i)).toBeInTheDocument();
  });
  
  test('表单未填完应显示占位提示', () => {
    const processModel = {
      nodes: [
        { id: '1', type: 'START', name: '开始' },
        { id: '2', type: 'USER_TASK', name: '主管审批' },
        { id: '3', type: 'END', name: '结束' },
      ],
      edges: [],
    };
    
    const formData = {
      requiredField1: '', // 必填字段未填
    };
    
    render(
      <FormSubmitPage
        schema={mockSchema}
        formData={formData}
        processVersion={{ model: processModel }}
      />
    );
    
    expect(screen.getByText('审批流程')).toBeInTheDocument();
    expect(screen.getByText(/请填写必填信息以查看完整的审批流程/i)).toBeInTheDocument();
    expect(screen.queryByText('当前用户')).not.toBeInTheDocument();
  });
  
  test('不需要审批的表单不应显示流程预览', () => {
    render(
      <FormSubmitPage
        schema={mockSchema}
        processVersion={null}
      />
    );
    
    expect(screen.queryByText('审批流程')).not.toBeInTheDocument();
  });
  
  test('需要审批但无流程配置应显示警告', () => {
    render(
      <FormSubmitPage
        schema={mockSchema}
        requiresApproval={true}
        processVersion={{ model: { nodes: [], edges: [] } }}
      />
    );
    
    expect(screen.queryByText('审批流程')).not.toBeInTheDocument();
  });
});
```

### 1.10 测试用例建议

```typescript
// testing/components/forms/FormRenderer.spec.tsx

describe('FormRenderer 组件测试', () => {
  const mockSchema: JSONSchema = {
    type: 'object',
    properties: {
      name: { type: 'string', title: '姓名' },
      age: { type: 'number', title: '年龄', minimum: 0, maximum: 150 },
      email: { type: 'string', title: '邮箱', format: 'email' },
    },
    required: ['name'],
  };
  
  test('应该根据 schema 渲染表单字段', () => {
    render(<FormRenderer schema={mockSchema} />);
    
    expect(screen.getByLabelText('姓名')).toBeInTheDocument();
    expect(screen.getByLabelText('年龄')).toBeInTheDocument();
    expect(screen.getByLabelText('邮箱')).toBeInTheDocument();
  });
  
  test('应该显示必填标记', () => {
    render(<FormRenderer schema={mockSchema} />);
    
    const nameLabel = screen.getByLabelText('姓名');
    expect(nameLabel.parentElement).toContainHTML('*'); // 必填星号
  });
  
  test('输入字段值应触发 onChange 回调', async () => {
    const onChange = jest.fn();
    render(<FormRenderer schema={mockSchema} onChange={onChange} />);
    
    const nameInput = screen.getByLabelText('姓名');
    await userEvent.type(nameInput, '张三');
    
    expect(onChange).toHaveBeenCalled();
    expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toEqual({
      name: '张三',
    });
  });
  
  test('多部门提交人：应该显示部门选择器', () => {
    const submitter: SubmitterInfo = {
      id: 'user-1',
      name: '张三',
      departments: [
        { id: 'dept-1', name: '研发部' },
        { id: 'dept-2', name: '产品部' },
      ],
    };
    
    render(<FormRenderer schema={mockSchema} submitter={submitter} />);
    
    expect(screen.getByText('选择提交部门')).toBeInTheDocument();
    expect(screen.getByRole('combobox')).toBeInTheDocument();
  });
  
  test('字段权限：editableFields 应限制字段可编辑性', () => {
    render(
      <FormRenderer
        schema={mockSchema}
        editableFields={['email']}  // 只有 email 可编辑
      />
    );
    
    const nameInput = screen.getByLabelText('姓名');
    const emailInput = screen.getByLabelText('邮箱');
    
    expect(nameInput).toHaveAttribute('readonly'); // 只读
    expect(emailInput).not.toHaveAttribute('readonly'); // 可编辑
  });
  
  test('字段权限：hiddenFields 应隐藏字段', () => {
    render(
      <FormRenderer
        schema={mockSchema}
        hiddenFields={['age']}
      />
    );
    
    expect(screen.getByLabelText('姓名')).toBeInTheDocument();
    expect(screen.queryByLabelText('年龄')).not.toBeInTheDocument(); // 隐藏
    expect(screen.getByLabelText('邮箱')).toBeInTheDocument();
  });
  
  test('提交验证：必填字段未填写应显示错误', async () => {
    const onSubmit = jest.fn();
    render(<FormRenderer schema={mockSchema} onSubmit={onSubmit} />);
    
    const submitButton = screen.getByRole('button', { name: /提交/i });
    await userEvent.click(submitButton);
    
    expect(screen.getByText(/姓名.*必须填写/i)).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });
  
  test('提交验证：格式错误应显示错误', async () => {
    const onSubmit = jest.fn();
    render(
      <FormRenderer
        schema={mockSchema}
        formData={{ name: '张三', email: 'invalid-email' }}
        onSubmit={onSubmit}
      />
    );
    
    const submitButton = screen.getByRole('button', { name: /提交/i });
    await userEvent.click(submitButton);
    
    expect(screen.getByText(/邮箱.*格式不正确/i)).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });
  
  test('提交成功：验证通过后应调用 onSubmit', async () => {
    const onSubmit = jest.fn();
    render(
      <FormRenderer
        schema={mockSchema}
        formData={{ name: '张三', age: 25, email: 'test@example.com' }}
        onSubmit={onSubmit}
      />
    );
    
    const submitButton = screen.getByRole('button', { name: /提交/i });
    await userEvent.click(submitButton);
    
    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith(
        { name: '张三', age: 25, email: 'test@example.com' },
        undefined // 没有部门选择
      );
    });
  });
  
  test('保存草稿：应调用 onSave 回调', async () => {
    const onSave = jest.fn();
    render(
      <FormRenderer
        schema={mockSchema}
        formData={{ name: '张三' }}
        onSave={onSave}
      />
    );
    
    const saveButton = screen.getByRole('button', { name: /保存草稿/i });
    await userEvent.click(saveButton);
    
    await waitFor(() => {
      expect(onSave).toHaveBeenCalledWith({ name: '张三' });
    });
  });
});
```

### 1.11 与 PRD 的差异

| 项目 | PRD 要求 | 实际实现 | 差异说明 |
|------|---------|---------|---------|
| 字段类型数量 | 20+ | ✅ 18种 | 缺少地址选择、关联选择 |
| 动态渲染 | ✅ 需要 | ✅ 已实现 | 基于 JSON Schema |
| 字段验证 | 8种 | ✅ 6种 | 缺少自定义正则、异步验证 |
| 只读模式 | ✅ 需要（PRD F4.7） | ⚠️ 规划中 | 当前通过 `disabled` 实现，缺少专用 `mode` prop |
| 字段权限控制 | ✅ 需要 | ✅ 已实现 | editableFields, requiredFields, hiddenFields |
| 多部门提交人 | ✅ 需要 | ✅ 已实现 | 自动检测并显示部门选择器 |
| 审批流程预览 | 🆕 新增 | ✅ 已实现 | 表单提交前预览审批流程 |
| 条件显示 | ✅ 需要 | ❌ 未实现 | 无法根据其他字段值动态显示/隐藏 |
| 计算字段 | ✅ 需要 | ❌ 未实现 | 无法根据公式自动计算字段值 |
| 草稿保存 | ✅ 需要 | ✅ 已实现 | 通过 `onSave` 回调 |
| 国际化支持 | ✅ 需要 | ✅ 已实现 | 通过 `locale` prop |

---

## 📄 2. 其他组件（简略）

### 2.1 FormFieldRenderer（字段渲染器）

**用途**: 根据字段类型渲染对应的输入组件

**主要功能**:
- 根据 `schema.type` 和 `ui:widget` 选择合适的字段组件
- 处理字段标签、必填标记、帮助文本
- 传递验证错误到字段组件

### 2.2 字段组件（Field Components）

| 组件 | 用途 | 特殊功能 |
|------|------|---------|
| FieldInput | 文本输入 | 支持 password、email 等类型 |
| FieldTextarea | 多行文本 | 可配置行数 |
| FieldNumber | 数字输入 | 支持最小/最大值 |
| FieldSelect | 下拉选择 | 支持枚举值 |
| FieldDate | 日期选择 | 支持 date、time、datetime |
| FieldFile | 文件上传 | 支持多文件、拖拽上传 |
| FieldRadio | 单选按钮 | 枚举值渲染为单选组 |
| FieldCheckbox | 复选框 | 可多选 |
| FieldSubTable | 子表单 | 动态添加/删除行 |

---

## ✅ 文档完成总结

### 已完成的组件规范

1. ✅ **FormRenderer** - 表单渲染器（核心组件，626行）
2. ✅ **FormFieldRenderer** - 字段渲染器（简略）
3. ✅ **9个字段组件** - Input, Textarea, Number, Select, Date, File, Radio, Checkbox, SubTable（简略）

### 文档特点

- ✅ 基于**实际代码实现**（626行组件代码）
- ✅ 包含**详细的组件 API**
- ✅ 包含**测试选择器**
- ✅ 提供**完整的交互步骤**
- ✅ 详细的**字段权限控制**说明
- ✅ 详细的**多部门提交人**功能说明
- ✅ 附带**组件级别测试用例**（11个测试场景）
- ✅ 标注**与 PRD 的差异**（10项对比）

---

**文档创建日期**: 2025-12-25  
**维护者**: FFOA 开发团队  
**版本**: v1.0  
**状态**: ✅ 完成
