hookehuyr

refactor(plan): 优化计划书字段配置管理

- 提取计划书字段定义到独立配置文件 (plan-fields.js)
  - 定义字段类型枚举 (TEXT, NUMBER, AMOUNT, SELECT, RADIO, DATE, NAME)
  - 配置字段属性 (label, type, required, api_field, component)
  - 支持字段验证规则和显示条件
  - 支持字段依赖关系 (affects, depends_on)
- 新增字段值转换工具 (planFieldTransformers.js)
  - 分转元: fenToYuan (10000 → "100.00")
  - 元转分: yuanToFen ("100.00" → 10000)
  - 年龄格式化: formatAge (25 → "25岁")
  - 批量转换: batchTransformFields
  - 反向转换: reverseTransformFields
- 新增测试目录 (src/utils/__tests__)

影响文件:
- src/config/plan-fields.js (新增)
- src/utils/planFieldTransformers.js (新增)
- src/utils/__tests__/planFieldTransformers.test.js (新增)

技术栈: Vue 3, Taro 4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
/**
* 计划书字段配置
*
* @description 统一管理所有计划书字段的配置信息,包括字段类型、验证规则、API 映射等
* @module config/plan-fields
* @author Claude Code
* @created 2026-02-14
*/
/**
* 字段类型枚举
* @enum {string}
*/
export const FIELD_TYPES = {
TEXT: 'text',
NUMBER: 'number',
AMOUNT: 'amount',
PERCENTAGE: 'percentage',
SELECT: 'select',
RADIO: 'radio',
DATE: 'date',
NAME: 'name'
}
/**
* 数据转换类型枚举
* @enum {string}
*/
export const TRANSFORM_TYPES = {
FEN_TO_YUAN: 'fen_to_yuan', // 分转元
YUAN_TO_FEN: 'yuan_to_fen', // 元转分
NONE: 'none' // 无需转换
}
/**
* 计划书字段定义
* @type {Object<string, FieldDefinition>}
* @property {Object} customer_name - 申请人姓名
* @property {Object} gender - 性别
* @property {Object} birthday - 出生日期
* @property {Object} smoker - 是否吸烟
* @property {Object} coverage - 保额
* @property {Object} payment_period - 缴费年期
* @property {Object} withdrawal_enabled - 是否启用提取
* @property {Object} withdrawal_mode - 提取模式
* @property {Object} withdrawal_start_age - 开始提取年龄
* @property {Object} withdrawal_period - 提取年期
* @property {Object} withdrawal_method - 提取方式
* @property {Object} annual_withdrawal_amount - 年提取金额
* @property {Object} annual_increase_percentage - 年递增比例
* @property {Object} total_amount - 总保费
*/
export const PLAN_FIELD_DEFINITIONS = {
/**
* 申请人姓名
*/
customer_name: {
label: '申请人',
type: FIELD_TYPES.TEXT,
required: true,
api_field: 'customer_name',
placeholder: '请输入申请人姓名',
component: 'PlanFieldName',
validation: {
required: (value) => value?.trim()?.length >= 2
}
},
/**
* 性别
*/
gender: {
label: '性别',
type: FIELD_TYPES.RADIO,
required: true,
api_field: 'customer_gender',
component: 'PlanFieldRadio',
options: [
{ label: '男', value: 'male' },
{ label: '女', value: 'female' }
],
default: 'male'
},
/**
* 出生日期
*/
birthday: {
label: '出生年月日',
type: FIELD_TYPES.DATE,
required: true,
api_field: 'customer_birthday',
component: 'PlanFieldDatePicker',
placeholder: '请选择出生年月日'
},
/**
* 是否吸烟
*/
smoker: {
label: '是否吸烟',
type: FIELD_TYPES.RADIO,
required: true,
api_field: 'smoking_status',
component: 'PlanFieldRadio',
options: [
{ label: '是', value: 'yes' },
{ label: '否', value: 'no' }
],
default: 'no'
},
/**
* 保额(年缴)
*/
coverage: {
label: '保额',
type: FIELD_TYPES.AMOUNT,
required: true,
api_field: 'annual_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
placeholder: '请输入保额',
validation: {
required: (value) => value > 0,
min: (value, config) => value >= (config?.min_coverage || 1000),
max: (value, config) => value <= (config?.max_coverage || 10000000)
}
},
/**
* 缴费年期
*/
payment_period: {
label: '缴费年期',
type: FIELD_TYPES.SELECT,
required: true,
api_field: 'payment_years',
component: 'PlanFieldSelect',
options_from: 'payment_periods', // 从模板配置获取选项
placeholder: '请选择缴费年期'
},
/**
* 是否启用提取
*/
withdrawal_enabled: {
label: '启用提取计划',
type: FIELD_TYPES.RADIO,
required: false,
api_field: 'allow_reduce_amount',
component: 'PlanFieldRadio',
options: [
{ label: '是', value: true },
{ label: '否', value: false }
],
default: false,
affects: ['withdrawal_mode', 'withdrawal_start_age', 'withdrawal_period', 'withdrawal_method', 'annual_withdrawal_amount']
},
/**
* 提取模式
*/
withdrawal_mode: {
label: '提取模式',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_option',
component: 'PlanFieldSelect',
options_from: 'withdrawal_plan.withdrawal_modes',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 开始提取年龄
*/
withdrawal_start_age: {
label: '开始提取年龄',
type: FIELD_TYPES.NUMBER,
required: false,
api_field: 'withdrawal_start_age',
component: 'PlanFieldAgePicker',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true },
default_from: 'age_range.min'
},
/**
* 提取年期
*/
withdrawal_period: {
label: '提取年期',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_period',
component: 'PlanFieldSelect',
options_from: 'withdrawal_plan.withdrawal_periods',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 提取方式
*/
withdrawal_method: {
label: '提取方式',
type: FIELD_TYPES.SELECT,
required: false,
api_field: 'withdrawal_method',
component: 'PlanFieldSelect',
options: ['现金', '抵缴保费'],
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true }
},
/**
* 年提取金额
*/
annual_withdrawal_amount: {
label: '年提取金额',
type: FIELD_TYPES.AMOUNT,
required: false,
api_field: 'annual_withdrawal_amount',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
depends_on: 'withdrawal_enabled',
show_when: { withdrawal_enabled: true },
placeholder: '请输入年提取金额'
},
/**
* 年递增比例
*/
annual_increase_percentage: {
label: '年递增比例',
type: FIELD_TYPES.PERCENTAGE,
required: false,
api_field: 'annual_increase_percentage',
transform: TRANSFORM_TYPES.NONE,
component: 'PlanFieldAmount',
validation: {
range: (value) => {
const num = parseFloat(value)
return !Number.isNaN(num) && num >= 0 && num <= 100
}
}
},
/**
* 总保费
*/
total_amount: {
label: '总保费',
type: FIELD_TYPES.AMOUNT,
required: false,
api_field: 'total_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN,
component: 'PlanFieldAmount',
placeholder: '请输入总保费'
}
}
/**
* 获取字段定义
* @param {string} fieldKey - 字段键名
* @returns {FieldDefinition|null} 字段定义
*/
export function getFieldDefinition(fieldKey) {
return PLAN_FIELD_DEFINITIONS[fieldKey] || null
}
/**
* 获取字段对应的所有依赖字段
* @param {string} fieldKey - 字段键名
* @returns {string[]} 依赖字段的键名数组
*/
export function getFieldDependencies(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.affects || []
}
/**
* 获取字段的 API 字段名
* @param {string} fieldKey - 字段键名
* @returns {string} API 字段名
*/
export function getFieldApiField(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.api_field || fieldKey
}
/**
* 检查字段是否需要值转换
* @param {string} fieldKey - 字段键名
* @returns {boolean} 是否需要转换
*/
export function fieldNeedsTransform(fieldKey) {
const definition = getFieldDefinition(fieldKey)
return definition?.transform && definition.transform !== TRANSFORM_TYPES.NONE
}
/**
* 字段定义类型
* @typedef {Object} FieldDefinition
* @property {string} label - 字段显示名称
* @property {string} type - 字段类型(见 FIELD_TYPES)
* @property {boolean} required - 是否必填
* @property {string} api_field - API 字段名
* @property {string} [component] - 对应组件名
* @property {string} [placeholder] - 占位符文本
* @property {Array} [options] - 选项列表(select/radio 类型)
* @property {string} [options_from] - 选项来源(从模板配置获取)
* @property {*} [default] - 默认值
* @property {string} [transform] - 值转换类型(见 TRANSFORM_TYPES)
* @property {Object} [validation] - 验证规则
* @property {Function} [validation.required] - 必填验证函数
* @property {string[]} [affects] - 影响的字段列表
* @property {string} [depends_on] - 依赖的字段
* @property {Object} [show_when] - 显示条件
* @property {string} [default_from] - 默认值来源(从其他字段获取)
*/
/**
* planFieldTransformers 单元测试
* @description 测试字段值转换工具函数
* @module utils/__tests__/planFieldTransformers.test
*/
import { describe, it, expect } from 'vitest'
import {
fenToYuan,
yuanToFen,
formatAge,
noneTransform,
transformFieldValue,
batchTransformFields,
reverseTransformFields
} from '../planFieldTransformers'
import { TRANSFORM_TYPES } from '@/config/plan-fields'
describe('fenToYuan', () => {
it('should convert fen to yuan correctly', () => {
expect(fenToYuan(10000)).toBe('100.00')
expect(fenToYuan(100)).toBe('1.00')
expect(fenToYuan(1)).toBe('0.01')
})
it('should handle zero', () => {
expect(fenToYuan(0)).toBe('0.00')
})
it('should handle null and undefined', () => {
expect(fenToYuan(null)).toBe(null)
expect(fenToYuan(undefined)).toBe(undefined) // 保持原值
})
it('should handle string numbers', () => {
expect(fenToYuan('10000')).toBe('100.00')
expect(fenToYuan('100')).toBe('1.00')
})
it('should handle invalid values', () => {
expect(fenToYuan('invalid')).toBe(null)
expect(fenToYuan(NaN)).toBe(null)
})
})
describe('yuanToFen', () => {
it('should convert yuan to fen correctly', () => {
expect(yuanToFen('100.00')).toBe(10000)
expect(yuanToFen('1.00')).toBe(100)
expect(yuanToFen('0.01')).toBe(1)
})
it('should handle zero', () => {
expect(yuanToFen('0.00')).toBe(0)
expect(yuanToFen(0)).toBe(0)
})
it('should handle null and undefined', () => {
expect(yuanToFen(null)).toBe(null)
expect(yuanToFen(undefined)).toBe(null)
})
it('should handle string numbers', () => {
expect(yuanToFen('100.00')).toBe(10000)
expect(yuanToFen('100')).toBe(10000)
})
})
describe('formatAge', () => {
it('should format age correctly', () => {
expect(formatAge(25)).toBe('25岁')
expect(formatAge(0)).toBe('0岁')
})
it('should handle null and undefined', () => {
expect(formatAge(null)).toBe(null)
expect(formatAge(undefined)).toBe(null)
})
})
describe('transformFieldValue', () => {
it('should transform fen to yuan', () => {
expect(transformFieldValue(10000, TRANSFORM_TYPES.FEN_TO_YUAN)).toBe('100.00')
})
it('should transform yuan to fen', () => {
expect(transformFieldValue('100.00', TRANSFORM_TYPES.YUAN_TO_FEN)).toBe(10000)
})
it('should pass through for none transform', () => {
expect(transformFieldValue('test', TRANSFORM_TYPES.NONE)).toBe('test')
})
it('should handle null values', () => {
expect(transformFieldValue(null, TRANSFORM_TYPES.FEN_TO_YUAN)).toBe(null)
})
})
describe('batchTransformFields', () => {
it('should transform multiple fields according to definitions', () => {
const formData = {
coverage: 10000,
annual_withdrawal_amount: 5000,
name: 'Test'
}
const fieldDefinitions = {
coverage: {
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
annual_withdrawal_amount: {
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
name: {
// 无 transform 属性
}
}
const result = batchTransformFields(formData, fieldDefinitions)
expect(result.coverage).toBe('100.00')
expect(result.annual_withdrawal_amount).toBe('50.00')
expect(result.name).toBe('Test') // 保持原值
})
it('should skip undefined values', () => {
const formData = {
coverage: undefined,
name: 'Test'
}
const fieldDefinitions = {
coverage: {
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
name: {}
}
const result = batchTransformFields(formData, fieldDefinitions)
// coverage 是 undefined,转换后应该仍然是 undefined
expect(result.coverage).toBeUndefined()
// name 没有变化,应该保持原值
expect(result.name).toBe('Test')
})
})
describe('reverseTransformFields', () => {
it('should convert API data back to form format', () => {
const apiData = {
annual_premium: '100.00',
annual_withdrawal_amount: '50.00',
name: 'Test'
}
const fieldDefinitions = {
annual_premium: {
api_field: 'annual_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
annual_withdrawal_amount: {
api_field: 'annual_withdrawal_amount',
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
name: {
api_field: 'name'
}
}
const result = reverseTransformFields(apiData, fieldDefinitions)
// formKey 是 annual_premium,所以结果键是 annual_premium
// 反向转换:yuan -> fen,返回整数(分)
expect(result.annual_premium).toBe(10000)
expect(result.annual_withdrawal_amount).toBe(5000)
expect(result.name).toBe('Test')
})
it('should handle missing fields', () => {
const apiData = {
annual_premium: '100.00'
}
const fieldDefinitions = {
annual_premium: {
api_field: 'annual_premium',
transform: TRANSFORM_TYPES.FEN_TO_YUAN
},
name: {
api_field: 'name'
}
}
const result = reverseTransformFields(apiData, fieldDefinitions)
// annual_premium 反向转换:yuan -> fen,返回整数(分)
expect(result.annual_premium).toBe(10000)
// name 在 apiData 中不存在,所以 result.name 是 undefined
expect(result.name).toBeUndefined()
})
})
/**
* 计划书字段值转换工具
*
* @description 提供各种数据格式的转换函数,用于表单数据提交前的格式化
* @module utils/planFieldTransformers
* @author Claude Code
* @created 2026-02-14
*/
import { TRANSFORM_TYPES } from '@/config/plan-fields'
/**
* 分转元
* @description 将分(整数)转换为元(浮点数,保留2位小数)
* @param {number|string} value - 分值(如 10000)
* @returns {string|null} 元值(如 "100.00")
*
* @example
* fenToYuan(10000) // "100.00"
* fenToYuan(0) // "0.00"
* fenToYuan(null) // null
*/
export function fenToYuan(value) {
// 空字符串返回 null
if (value === null || value === '') {
return null
}
// undefined 保持原值(不转换)
if (value === undefined) {
return undefined
}
const numValue = parseFloat(value)
if (Number.isNaN(numValue)) {
return null
}
return (numValue / 100).toFixed(2)
}
/**
* 元转分
* @description 将元(浮点数)转换为分(整数)
* @param {number|string} value - 元值(如 "100.00")
* @returns {number|null} 分值(如 10000)
*
* @example
* yuanToFen("100.00") // 10000
* yuanToFen(0) // 0
* yuanToFen(null) // null
*/
export function yuanToFen(value) {
if (value === null || value === undefined || value === '') {
return null
}
const numValue = parseFloat(value)
if (Number.isNaN(numValue)) {
return null
}
return Math.round(numValue * 100)
}
/**
* 年龄格式化
* @description 将数字年龄格式化为 "XX岁" 字符串
* @param {number} value - 年龄数字
* @returns {string|null} 格式化后的年龄
*
* @example
* formatAge(25) // "25岁"
* formatAge(null) // null
*/
export function formatAge(value) {
if (value === null || value === undefined) {
return null
}
return `${value}岁`
}
/**
* 无需转换
* @description 直接返回原值
* @param {*} value - 任意值
* @returns {*} 返回原值
*/
export function noneTransform(value) {
return value
}
/**
* 转换器映射表
* @type {Object<string, Function>}
*/
export const FIELD_TRANSFORMERS = {
[TRANSFORM_TYPES.FEN_TO_YUAN]: fenToYuan,
[TRANSFORM_TYPES.YUAN_TO_FEN]: yuanToFen,
[TRANSFORM_TYPES.NONE]: noneTransform
}
/**
* 执行字段值转换
* @param {*} value - 原始值
* @param {string} transformType - 转换类型
* @returns {*} 转换后的值
*
* @example
* transformFieldValue(10000, 'fen_to_yuan') // "100.00"
* transformFieldValue("100.00", 'none') // "100.00"
*/
export function transformFieldValue(value, transformType) {
if (!transformType || transformType === TRANSFORM_TYPES.NONE) {
return value
}
const transformer = FIELD_TRANSFORMERS[transformType]
if (!transformer) {
console.warn(`[planFieldTransformers] 未知的转换类型: ${transformType}`)
return value
}
return transformer(value)
}
/**
* 批量转换字段值
* @param {Object} formData - 表单数据
* @param {Object} fieldDefinitions - 字段定义映射
* @returns {Object} 转换后的数据
*
* @example
* batchTransformFields(
* { coverage: 10000, name: 'Test' },
* { coverage: { transform: 'fen_to_yuan' } }
* ) // { coverage: '100.00', name: 'Test' }
*/
export function batchTransformFields(formData, fieldDefinitions) {
const result = { ...formData }
for (const [key, value] of Object.entries(result)) {
const definition = fieldDefinitions[key]
if (!definition || !definition.transform) {
continue
}
result[key] = transformFieldValue(value, definition.transform)
}
return result
}
/**
* 反向转换(从 API 响应转换回表单格式)
* @param {Object} data - API 返回的数据
* @param {Object} fieldDefinitions - 字段定义映射
* @returns {Object} 转换后的表单数据
*
* @example
* reverseTransformFields(
* { annual_premium: '100.00', name: 'Test' },
* { annual_premium: { api_field: 'annual_premium', transform: 'fen_to_yuan' } }
* ) // { annual_premium: 10000, name: 'Test' }
*/
export function reverseTransformFields(data, fieldDefinitions) {
const result = {}
for (const [formKey, definition] of Object.entries(fieldDefinitions)) {
// 跳过没有 api_field 的字段
if (!definition.api_field) {
continue
}
const apiValue = data[definition.api_field]
if (apiValue === undefined || apiValue === null) {
continue
}
// 没有转换类型,直接使用原值
if (!definition.transform || definition.transform === TRANSFORM_TYPES.NONE) {
result[formKey] = apiValue
continue
}
// 获取反向转换器
let reverseTransform = definition.transform
if (reverseTransform === TRANSFORM_TYPES.FEN_TO_YUAN) {
reverseTransform = TRANSFORM_TYPES.YUAN_TO_FEN
} else if (reverseTransform === TRANSFORM_TYPES.YUAN_TO_FEN) {
reverseTransform = TRANSFORM_TYPES.FEN_TO_YUAN
} else {
// 未知转换类型,使用原值
result[formKey] = apiValue
continue
}
result[formKey] = transformFieldValue(apiValue, reverseTransform)
}
return result
}