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>
Showing
3 changed files
with
727 additions
and
0 deletions
src/config/plan-fields.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 计划书字段配置 | ||
| 3 | + * | ||
| 4 | + * @description 统一管理所有计划书字段的配置信息,包括字段类型、验证规则、API 映射等 | ||
| 5 | + * @module config/plan-fields | ||
| 6 | + * @author Claude Code | ||
| 7 | + * @created 2026-02-14 | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 字段类型枚举 | ||
| 12 | + * @enum {string} | ||
| 13 | + */ | ||
| 14 | +export const FIELD_TYPES = { | ||
| 15 | + TEXT: 'text', | ||
| 16 | + NUMBER: 'number', | ||
| 17 | + AMOUNT: 'amount', | ||
| 18 | + PERCENTAGE: 'percentage', | ||
| 19 | + SELECT: 'select', | ||
| 20 | + RADIO: 'radio', | ||
| 21 | + DATE: 'date', | ||
| 22 | + NAME: 'name' | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/** | ||
| 26 | + * 数据转换类型枚举 | ||
| 27 | + * @enum {string} | ||
| 28 | + */ | ||
| 29 | +export const TRANSFORM_TYPES = { | ||
| 30 | + FEN_TO_YUAN: 'fen_to_yuan', // 分转元 | ||
| 31 | + YUAN_TO_FEN: 'yuan_to_fen', // 元转分 | ||
| 32 | + NONE: 'none' // 无需转换 | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +/** | ||
| 36 | + * 计划书字段定义 | ||
| 37 | + * @type {Object<string, FieldDefinition>} | ||
| 38 | + * @property {Object} customer_name - 申请人姓名 | ||
| 39 | + * @property {Object} gender - 性别 | ||
| 40 | + * @property {Object} birthday - 出生日期 | ||
| 41 | + * @property {Object} smoker - 是否吸烟 | ||
| 42 | + * @property {Object} coverage - 保额 | ||
| 43 | + * @property {Object} payment_period - 缴费年期 | ||
| 44 | + * @property {Object} withdrawal_enabled - 是否启用提取 | ||
| 45 | + * @property {Object} withdrawal_mode - 提取模式 | ||
| 46 | + * @property {Object} withdrawal_start_age - 开始提取年龄 | ||
| 47 | + * @property {Object} withdrawal_period - 提取年期 | ||
| 48 | + * @property {Object} withdrawal_method - 提取方式 | ||
| 49 | + * @property {Object} annual_withdrawal_amount - 年提取金额 | ||
| 50 | + * @property {Object} annual_increase_percentage - 年递增比例 | ||
| 51 | + * @property {Object} total_amount - 总保费 | ||
| 52 | + */ | ||
| 53 | +export const PLAN_FIELD_DEFINITIONS = { | ||
| 54 | + /** | ||
| 55 | + * 申请人姓名 | ||
| 56 | + */ | ||
| 57 | + customer_name: { | ||
| 58 | + label: '申请人', | ||
| 59 | + type: FIELD_TYPES.TEXT, | ||
| 60 | + required: true, | ||
| 61 | + api_field: 'customer_name', | ||
| 62 | + placeholder: '请输入申请人姓名', | ||
| 63 | + component: 'PlanFieldName', | ||
| 64 | + validation: { | ||
| 65 | + required: (value) => value?.trim()?.length >= 2 | ||
| 66 | + } | ||
| 67 | + }, | ||
| 68 | + | ||
| 69 | + /** | ||
| 70 | + * 性别 | ||
| 71 | + */ | ||
| 72 | + gender: { | ||
| 73 | + label: '性别', | ||
| 74 | + type: FIELD_TYPES.RADIO, | ||
| 75 | + required: true, | ||
| 76 | + api_field: 'customer_gender', | ||
| 77 | + component: 'PlanFieldRadio', | ||
| 78 | + options: [ | ||
| 79 | + { label: '男', value: 'male' }, | ||
| 80 | + { label: '女', value: 'female' } | ||
| 81 | + ], | ||
| 82 | + default: 'male' | ||
| 83 | + }, | ||
| 84 | + | ||
| 85 | + /** | ||
| 86 | + * 出生日期 | ||
| 87 | + */ | ||
| 88 | + birthday: { | ||
| 89 | + label: '出生年月日', | ||
| 90 | + type: FIELD_TYPES.DATE, | ||
| 91 | + required: true, | ||
| 92 | + api_field: 'customer_birthday', | ||
| 93 | + component: 'PlanFieldDatePicker', | ||
| 94 | + placeholder: '请选择出生年月日' | ||
| 95 | + }, | ||
| 96 | + | ||
| 97 | + /** | ||
| 98 | + * 是否吸烟 | ||
| 99 | + */ | ||
| 100 | + smoker: { | ||
| 101 | + label: '是否吸烟', | ||
| 102 | + type: FIELD_TYPES.RADIO, | ||
| 103 | + required: true, | ||
| 104 | + api_field: 'smoking_status', | ||
| 105 | + component: 'PlanFieldRadio', | ||
| 106 | + options: [ | ||
| 107 | + { label: '是', value: 'yes' }, | ||
| 108 | + { label: '否', value: 'no' } | ||
| 109 | + ], | ||
| 110 | + default: 'no' | ||
| 111 | + }, | ||
| 112 | + | ||
| 113 | + /** | ||
| 114 | + * 保额(年缴) | ||
| 115 | + */ | ||
| 116 | + coverage: { | ||
| 117 | + label: '保额', | ||
| 118 | + type: FIELD_TYPES.AMOUNT, | ||
| 119 | + required: true, | ||
| 120 | + api_field: 'annual_premium', | ||
| 121 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN, | ||
| 122 | + component: 'PlanFieldAmount', | ||
| 123 | + placeholder: '请输入保额', | ||
| 124 | + validation: { | ||
| 125 | + required: (value) => value > 0, | ||
| 126 | + min: (value, config) => value >= (config?.min_coverage || 1000), | ||
| 127 | + max: (value, config) => value <= (config?.max_coverage || 10000000) | ||
| 128 | + } | ||
| 129 | + }, | ||
| 130 | + | ||
| 131 | + /** | ||
| 132 | + * 缴费年期 | ||
| 133 | + */ | ||
| 134 | + payment_period: { | ||
| 135 | + label: '缴费年期', | ||
| 136 | + type: FIELD_TYPES.SELECT, | ||
| 137 | + required: true, | ||
| 138 | + api_field: 'payment_years', | ||
| 139 | + component: 'PlanFieldSelect', | ||
| 140 | + options_from: 'payment_periods', // 从模板配置获取选项 | ||
| 141 | + placeholder: '请选择缴费年期' | ||
| 142 | + }, | ||
| 143 | + | ||
| 144 | + /** | ||
| 145 | + * 是否启用提取 | ||
| 146 | + */ | ||
| 147 | + withdrawal_enabled: { | ||
| 148 | + label: '启用提取计划', | ||
| 149 | + type: FIELD_TYPES.RADIO, | ||
| 150 | + required: false, | ||
| 151 | + api_field: 'allow_reduce_amount', | ||
| 152 | + component: 'PlanFieldRadio', | ||
| 153 | + options: [ | ||
| 154 | + { label: '是', value: true }, | ||
| 155 | + { label: '否', value: false } | ||
| 156 | + ], | ||
| 157 | + default: false, | ||
| 158 | + affects: ['withdrawal_mode', 'withdrawal_start_age', 'withdrawal_period', 'withdrawal_method', 'annual_withdrawal_amount'] | ||
| 159 | + }, | ||
| 160 | + | ||
| 161 | + /** | ||
| 162 | + * 提取模式 | ||
| 163 | + */ | ||
| 164 | + withdrawal_mode: { | ||
| 165 | + label: '提取模式', | ||
| 166 | + type: FIELD_TYPES.SELECT, | ||
| 167 | + required: false, | ||
| 168 | + api_field: 'withdrawal_option', | ||
| 169 | + component: 'PlanFieldSelect', | ||
| 170 | + options_from: 'withdrawal_plan.withdrawal_modes', | ||
| 171 | + depends_on: 'withdrawal_enabled', | ||
| 172 | + show_when: { withdrawal_enabled: true } | ||
| 173 | + }, | ||
| 174 | + | ||
| 175 | + /** | ||
| 176 | + * 开始提取年龄 | ||
| 177 | + */ | ||
| 178 | + withdrawal_start_age: { | ||
| 179 | + label: '开始提取年龄', | ||
| 180 | + type: FIELD_TYPES.NUMBER, | ||
| 181 | + required: false, | ||
| 182 | + api_field: 'withdrawal_start_age', | ||
| 183 | + component: 'PlanFieldAgePicker', | ||
| 184 | + depends_on: 'withdrawal_enabled', | ||
| 185 | + show_when: { withdrawal_enabled: true }, | ||
| 186 | + default_from: 'age_range.min' | ||
| 187 | + }, | ||
| 188 | + | ||
| 189 | + /** | ||
| 190 | + * 提取年期 | ||
| 191 | + */ | ||
| 192 | + withdrawal_period: { | ||
| 193 | + label: '提取年期', | ||
| 194 | + type: FIELD_TYPES.SELECT, | ||
| 195 | + required: false, | ||
| 196 | + api_field: 'withdrawal_period', | ||
| 197 | + component: 'PlanFieldSelect', | ||
| 198 | + options_from: 'withdrawal_plan.withdrawal_periods', | ||
| 199 | + depends_on: 'withdrawal_enabled', | ||
| 200 | + show_when: { withdrawal_enabled: true } | ||
| 201 | + }, | ||
| 202 | + | ||
| 203 | + /** | ||
| 204 | + * 提取方式 | ||
| 205 | + */ | ||
| 206 | + withdrawal_method: { | ||
| 207 | + label: '提取方式', | ||
| 208 | + type: FIELD_TYPES.SELECT, | ||
| 209 | + required: false, | ||
| 210 | + api_field: 'withdrawal_method', | ||
| 211 | + component: 'PlanFieldSelect', | ||
| 212 | + options: ['现金', '抵缴保费'], | ||
| 213 | + depends_on: 'withdrawal_enabled', | ||
| 214 | + show_when: { withdrawal_enabled: true } | ||
| 215 | + }, | ||
| 216 | + | ||
| 217 | + /** | ||
| 218 | + * 年提取金额 | ||
| 219 | + */ | ||
| 220 | + annual_withdrawal_amount: { | ||
| 221 | + label: '年提取金额', | ||
| 222 | + type: FIELD_TYPES.AMOUNT, | ||
| 223 | + required: false, | ||
| 224 | + api_field: 'annual_withdrawal_amount', | ||
| 225 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN, | ||
| 226 | + component: 'PlanFieldAmount', | ||
| 227 | + depends_on: 'withdrawal_enabled', | ||
| 228 | + show_when: { withdrawal_enabled: true }, | ||
| 229 | + placeholder: '请输入年提取金额' | ||
| 230 | + }, | ||
| 231 | + | ||
| 232 | + /** | ||
| 233 | + * 年递增比例 | ||
| 234 | + */ | ||
| 235 | + annual_increase_percentage: { | ||
| 236 | + label: '年递增比例', | ||
| 237 | + type: FIELD_TYPES.PERCENTAGE, | ||
| 238 | + required: false, | ||
| 239 | + api_field: 'annual_increase_percentage', | ||
| 240 | + transform: TRANSFORM_TYPES.NONE, | ||
| 241 | + component: 'PlanFieldAmount', | ||
| 242 | + validation: { | ||
| 243 | + range: (value) => { | ||
| 244 | + const num = parseFloat(value) | ||
| 245 | + return !Number.isNaN(num) && num >= 0 && num <= 100 | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + }, | ||
| 249 | + | ||
| 250 | + /** | ||
| 251 | + * 总保费 | ||
| 252 | + */ | ||
| 253 | + total_amount: { | ||
| 254 | + label: '总保费', | ||
| 255 | + type: FIELD_TYPES.AMOUNT, | ||
| 256 | + required: false, | ||
| 257 | + api_field: 'total_premium', | ||
| 258 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN, | ||
| 259 | + component: 'PlanFieldAmount', | ||
| 260 | + placeholder: '请输入总保费' | ||
| 261 | + } | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +/** | ||
| 265 | + * 获取字段定义 | ||
| 266 | + * @param {string} fieldKey - 字段键名 | ||
| 267 | + * @returns {FieldDefinition|null} 字段定义 | ||
| 268 | + */ | ||
| 269 | +export function getFieldDefinition(fieldKey) { | ||
| 270 | + return PLAN_FIELD_DEFINITIONS[fieldKey] || null | ||
| 271 | +} | ||
| 272 | + | ||
| 273 | +/** | ||
| 274 | + * 获取字段对应的所有依赖字段 | ||
| 275 | + * @param {string} fieldKey - 字段键名 | ||
| 276 | + * @returns {string[]} 依赖字段的键名数组 | ||
| 277 | + */ | ||
| 278 | +export function getFieldDependencies(fieldKey) { | ||
| 279 | + const definition = getFieldDefinition(fieldKey) | ||
| 280 | + return definition?.affects || [] | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +/** | ||
| 284 | + * 获取字段的 API 字段名 | ||
| 285 | + * @param {string} fieldKey - 字段键名 | ||
| 286 | + * @returns {string} API 字段名 | ||
| 287 | + */ | ||
| 288 | +export function getFieldApiField(fieldKey) { | ||
| 289 | + const definition = getFieldDefinition(fieldKey) | ||
| 290 | + return definition?.api_field || fieldKey | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +/** | ||
| 294 | + * 检查字段是否需要值转换 | ||
| 295 | + * @param {string} fieldKey - 字段键名 | ||
| 296 | + * @returns {boolean} 是否需要转换 | ||
| 297 | + */ | ||
| 298 | +export function fieldNeedsTransform(fieldKey) { | ||
| 299 | + const definition = getFieldDefinition(fieldKey) | ||
| 300 | + return definition?.transform && definition.transform !== TRANSFORM_TYPES.NONE | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +/** | ||
| 304 | + * 字段定义类型 | ||
| 305 | + * @typedef {Object} FieldDefinition | ||
| 306 | + * @property {string} label - 字段显示名称 | ||
| 307 | + * @property {string} type - 字段类型(见 FIELD_TYPES) | ||
| 308 | + * @property {boolean} required - 是否必填 | ||
| 309 | + * @property {string} api_field - API 字段名 | ||
| 310 | + * @property {string} [component] - 对应组件名 | ||
| 311 | + * @property {string} [placeholder] - 占位符文本 | ||
| 312 | + * @property {Array} [options] - 选项列表(select/radio 类型) | ||
| 313 | + * @property {string} [options_from] - 选项来源(从模板配置获取) | ||
| 314 | + * @property {*} [default] - 默认值 | ||
| 315 | + * @property {string} [transform] - 值转换类型(见 TRANSFORM_TYPES) | ||
| 316 | + * @property {Object} [validation] - 验证规则 | ||
| 317 | + * @property {Function} [validation.required] - 必填验证函数 | ||
| 318 | + * @property {string[]} [affects] - 影响的字段列表 | ||
| 319 | + * @property {string} [depends_on] - 依赖的字段 | ||
| 320 | + * @property {Object} [show_when] - 显示条件 | ||
| 321 | + * @property {string} [default_from] - 默认值来源(从其他字段获取) | ||
| 322 | + */ |
| 1 | +/** | ||
| 2 | + * planFieldTransformers 单元测试 | ||
| 3 | + * @description 测试字段值转换工具函数 | ||
| 4 | + * @module utils/__tests__/planFieldTransformers.test | ||
| 5 | + */ | ||
| 6 | + | ||
| 7 | +import { describe, it, expect } from 'vitest' | ||
| 8 | +import { | ||
| 9 | + fenToYuan, | ||
| 10 | + yuanToFen, | ||
| 11 | + formatAge, | ||
| 12 | + noneTransform, | ||
| 13 | + transformFieldValue, | ||
| 14 | + batchTransformFields, | ||
| 15 | + reverseTransformFields | ||
| 16 | +} from '../planFieldTransformers' | ||
| 17 | +import { TRANSFORM_TYPES } from '@/config/plan-fields' | ||
| 18 | + | ||
| 19 | +describe('fenToYuan', () => { | ||
| 20 | + it('should convert fen to yuan correctly', () => { | ||
| 21 | + expect(fenToYuan(10000)).toBe('100.00') | ||
| 22 | + expect(fenToYuan(100)).toBe('1.00') | ||
| 23 | + expect(fenToYuan(1)).toBe('0.01') | ||
| 24 | + }) | ||
| 25 | + | ||
| 26 | + it('should handle zero', () => { | ||
| 27 | + expect(fenToYuan(0)).toBe('0.00') | ||
| 28 | + }) | ||
| 29 | + | ||
| 30 | + it('should handle null and undefined', () => { | ||
| 31 | + expect(fenToYuan(null)).toBe(null) | ||
| 32 | + expect(fenToYuan(undefined)).toBe(undefined) // 保持原值 | ||
| 33 | + }) | ||
| 34 | + | ||
| 35 | + it('should handle string numbers', () => { | ||
| 36 | + expect(fenToYuan('10000')).toBe('100.00') | ||
| 37 | + expect(fenToYuan('100')).toBe('1.00') | ||
| 38 | + }) | ||
| 39 | + | ||
| 40 | + it('should handle invalid values', () => { | ||
| 41 | + expect(fenToYuan('invalid')).toBe(null) | ||
| 42 | + expect(fenToYuan(NaN)).toBe(null) | ||
| 43 | + }) | ||
| 44 | +}) | ||
| 45 | + | ||
| 46 | +describe('yuanToFen', () => { | ||
| 47 | + it('should convert yuan to fen correctly', () => { | ||
| 48 | + expect(yuanToFen('100.00')).toBe(10000) | ||
| 49 | + expect(yuanToFen('1.00')).toBe(100) | ||
| 50 | + expect(yuanToFen('0.01')).toBe(1) | ||
| 51 | + }) | ||
| 52 | + | ||
| 53 | + it('should handle zero', () => { | ||
| 54 | + expect(yuanToFen('0.00')).toBe(0) | ||
| 55 | + expect(yuanToFen(0)).toBe(0) | ||
| 56 | + }) | ||
| 57 | + | ||
| 58 | + it('should handle null and undefined', () => { | ||
| 59 | + expect(yuanToFen(null)).toBe(null) | ||
| 60 | + expect(yuanToFen(undefined)).toBe(null) | ||
| 61 | + }) | ||
| 62 | + | ||
| 63 | + it('should handle string numbers', () => { | ||
| 64 | + expect(yuanToFen('100.00')).toBe(10000) | ||
| 65 | + expect(yuanToFen('100')).toBe(10000) | ||
| 66 | + }) | ||
| 67 | +}) | ||
| 68 | + | ||
| 69 | +describe('formatAge', () => { | ||
| 70 | + it('should format age correctly', () => { | ||
| 71 | + expect(formatAge(25)).toBe('25岁') | ||
| 72 | + expect(formatAge(0)).toBe('0岁') | ||
| 73 | + }) | ||
| 74 | + | ||
| 75 | + it('should handle null and undefined', () => { | ||
| 76 | + expect(formatAge(null)).toBe(null) | ||
| 77 | + expect(formatAge(undefined)).toBe(null) | ||
| 78 | + }) | ||
| 79 | +}) | ||
| 80 | + | ||
| 81 | +describe('transformFieldValue', () => { | ||
| 82 | + it('should transform fen to yuan', () => { | ||
| 83 | + expect(transformFieldValue(10000, TRANSFORM_TYPES.FEN_TO_YUAN)).toBe('100.00') | ||
| 84 | + }) | ||
| 85 | + | ||
| 86 | + it('should transform yuan to fen', () => { | ||
| 87 | + expect(transformFieldValue('100.00', TRANSFORM_TYPES.YUAN_TO_FEN)).toBe(10000) | ||
| 88 | + }) | ||
| 89 | + | ||
| 90 | + it('should pass through for none transform', () => { | ||
| 91 | + expect(transformFieldValue('test', TRANSFORM_TYPES.NONE)).toBe('test') | ||
| 92 | + }) | ||
| 93 | + | ||
| 94 | + it('should handle null values', () => { | ||
| 95 | + expect(transformFieldValue(null, TRANSFORM_TYPES.FEN_TO_YUAN)).toBe(null) | ||
| 96 | + }) | ||
| 97 | +}) | ||
| 98 | + | ||
| 99 | +describe('batchTransformFields', () => { | ||
| 100 | + it('should transform multiple fields according to definitions', () => { | ||
| 101 | + const formData = { | ||
| 102 | + coverage: 10000, | ||
| 103 | + annual_withdrawal_amount: 5000, | ||
| 104 | + name: 'Test' | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + const fieldDefinitions = { | ||
| 108 | + coverage: { | ||
| 109 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 110 | + }, | ||
| 111 | + annual_withdrawal_amount: { | ||
| 112 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 113 | + }, | ||
| 114 | + name: { | ||
| 115 | + // 无 transform 属性 | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + const result = batchTransformFields(formData, fieldDefinitions) | ||
| 120 | + | ||
| 121 | + expect(result.coverage).toBe('100.00') | ||
| 122 | + expect(result.annual_withdrawal_amount).toBe('50.00') | ||
| 123 | + expect(result.name).toBe('Test') // 保持原值 | ||
| 124 | + }) | ||
| 125 | + | ||
| 126 | + it('should skip undefined values', () => { | ||
| 127 | + const formData = { | ||
| 128 | + coverage: undefined, | ||
| 129 | + name: 'Test' | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + const fieldDefinitions = { | ||
| 133 | + coverage: { | ||
| 134 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 135 | + }, | ||
| 136 | + name: {} | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + const result = batchTransformFields(formData, fieldDefinitions) | ||
| 140 | + | ||
| 141 | + // coverage 是 undefined,转换后应该仍然是 undefined | ||
| 142 | + expect(result.coverage).toBeUndefined() | ||
| 143 | + // name 没有变化,应该保持原值 | ||
| 144 | + expect(result.name).toBe('Test') | ||
| 145 | + }) | ||
| 146 | +}) | ||
| 147 | + | ||
| 148 | +describe('reverseTransformFields', () => { | ||
| 149 | + it('should convert API data back to form format', () => { | ||
| 150 | + const apiData = { | ||
| 151 | + annual_premium: '100.00', | ||
| 152 | + annual_withdrawal_amount: '50.00', | ||
| 153 | + name: 'Test' | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + const fieldDefinitions = { | ||
| 157 | + annual_premium: { | ||
| 158 | + api_field: 'annual_premium', | ||
| 159 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 160 | + }, | ||
| 161 | + annual_withdrawal_amount: { | ||
| 162 | + api_field: 'annual_withdrawal_amount', | ||
| 163 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 164 | + }, | ||
| 165 | + name: { | ||
| 166 | + api_field: 'name' | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + const result = reverseTransformFields(apiData, fieldDefinitions) | ||
| 171 | + | ||
| 172 | + // formKey 是 annual_premium,所以结果键是 annual_premium | ||
| 173 | + // 反向转换:yuan -> fen,返回整数(分) | ||
| 174 | + expect(result.annual_premium).toBe(10000) | ||
| 175 | + expect(result.annual_withdrawal_amount).toBe(5000) | ||
| 176 | + expect(result.name).toBe('Test') | ||
| 177 | + }) | ||
| 178 | + | ||
| 179 | + it('should handle missing fields', () => { | ||
| 180 | + const apiData = { | ||
| 181 | + annual_premium: '100.00' | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + const fieldDefinitions = { | ||
| 185 | + annual_premium: { | ||
| 186 | + api_field: 'annual_premium', | ||
| 187 | + transform: TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 188 | + }, | ||
| 189 | + name: { | ||
| 190 | + api_field: 'name' | ||
| 191 | + } | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + const result = reverseTransformFields(apiData, fieldDefinitions) | ||
| 195 | + | ||
| 196 | + // annual_premium 反向转换:yuan -> fen,返回整数(分) | ||
| 197 | + expect(result.annual_premium).toBe(10000) | ||
| 198 | + // name 在 apiData 中不存在,所以 result.name 是 undefined | ||
| 199 | + expect(result.name).toBeUndefined() | ||
| 200 | + }) | ||
| 201 | +}) |
src/utils/planFieldTransformers.js
0 → 100644
| 1 | +/** | ||
| 2 | + * 计划书字段值转换工具 | ||
| 3 | + * | ||
| 4 | + * @description 提供各种数据格式的转换函数,用于表单数据提交前的格式化 | ||
| 5 | + * @module utils/planFieldTransformers | ||
| 6 | + * @author Claude Code | ||
| 7 | + * @created 2026-02-14 | ||
| 8 | + */ | ||
| 9 | + | ||
| 10 | +import { TRANSFORM_TYPES } from '@/config/plan-fields' | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * 分转元 | ||
| 14 | + * @description 将分(整数)转换为元(浮点数,保留2位小数) | ||
| 15 | + * @param {number|string} value - 分值(如 10000) | ||
| 16 | + * @returns {string|null} 元值(如 "100.00") | ||
| 17 | + * | ||
| 18 | + * @example | ||
| 19 | + * fenToYuan(10000) // "100.00" | ||
| 20 | + * fenToYuan(0) // "0.00" | ||
| 21 | + * fenToYuan(null) // null | ||
| 22 | + */ | ||
| 23 | +export function fenToYuan(value) { | ||
| 24 | + // 空字符串返回 null | ||
| 25 | + if (value === null || value === '') { | ||
| 26 | + return null | ||
| 27 | + } | ||
| 28 | + // undefined 保持原值(不转换) | ||
| 29 | + if (value === undefined) { | ||
| 30 | + return undefined | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + const numValue = parseFloat(value) | ||
| 34 | + if (Number.isNaN(numValue)) { | ||
| 35 | + return null | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + return (numValue / 100).toFixed(2) | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** | ||
| 42 | + * 元转分 | ||
| 43 | + * @description 将元(浮点数)转换为分(整数) | ||
| 44 | + * @param {number|string} value - 元值(如 "100.00") | ||
| 45 | + * @returns {number|null} 分值(如 10000) | ||
| 46 | + * | ||
| 47 | + * @example | ||
| 48 | + * yuanToFen("100.00") // 10000 | ||
| 49 | + * yuanToFen(0) // 0 | ||
| 50 | + * yuanToFen(null) // null | ||
| 51 | + */ | ||
| 52 | +export function yuanToFen(value) { | ||
| 53 | + if (value === null || value === undefined || value === '') { | ||
| 54 | + return null | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + const numValue = parseFloat(value) | ||
| 58 | + if (Number.isNaN(numValue)) { | ||
| 59 | + return null | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + return Math.round(numValue * 100) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/** | ||
| 66 | + * 年龄格式化 | ||
| 67 | + * @description 将数字年龄格式化为 "XX岁" 字符串 | ||
| 68 | + * @param {number} value - 年龄数字 | ||
| 69 | + * @returns {string|null} 格式化后的年龄 | ||
| 70 | + * | ||
| 71 | + * @example | ||
| 72 | + * formatAge(25) // "25岁" | ||
| 73 | + * formatAge(null) // null | ||
| 74 | + */ | ||
| 75 | +export function formatAge(value) { | ||
| 76 | + if (value === null || value === undefined) { | ||
| 77 | + return null | ||
| 78 | + } | ||
| 79 | + return `${value}岁` | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +/** | ||
| 83 | + * 无需转换 | ||
| 84 | + * @description 直接返回原值 | ||
| 85 | + * @param {*} value - 任意值 | ||
| 86 | + * @returns {*} 返回原值 | ||
| 87 | + */ | ||
| 88 | +export function noneTransform(value) { | ||
| 89 | + return value | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +/** | ||
| 93 | + * 转换器映射表 | ||
| 94 | + * @type {Object<string, Function>} | ||
| 95 | + */ | ||
| 96 | +export const FIELD_TRANSFORMERS = { | ||
| 97 | + [TRANSFORM_TYPES.FEN_TO_YUAN]: fenToYuan, | ||
| 98 | + [TRANSFORM_TYPES.YUAN_TO_FEN]: yuanToFen, | ||
| 99 | + [TRANSFORM_TYPES.NONE]: noneTransform | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/** | ||
| 103 | + * 执行字段值转换 | ||
| 104 | + * @param {*} value - 原始值 | ||
| 105 | + * @param {string} transformType - 转换类型 | ||
| 106 | + * @returns {*} 转换后的值 | ||
| 107 | + * | ||
| 108 | + * @example | ||
| 109 | + * transformFieldValue(10000, 'fen_to_yuan') // "100.00" | ||
| 110 | + * transformFieldValue("100.00", 'none') // "100.00" | ||
| 111 | + */ | ||
| 112 | +export function transformFieldValue(value, transformType) { | ||
| 113 | + if (!transformType || transformType === TRANSFORM_TYPES.NONE) { | ||
| 114 | + return value | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + const transformer = FIELD_TRANSFORMERS[transformType] | ||
| 118 | + | ||
| 119 | + if (!transformer) { | ||
| 120 | + console.warn(`[planFieldTransformers] 未知的转换类型: ${transformType}`) | ||
| 121 | + return value | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + return transformer(value) | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +/** | ||
| 128 | + * 批量转换字段值 | ||
| 129 | + * @param {Object} formData - 表单数据 | ||
| 130 | + * @param {Object} fieldDefinitions - 字段定义映射 | ||
| 131 | + * @returns {Object} 转换后的数据 | ||
| 132 | + * | ||
| 133 | + * @example | ||
| 134 | + * batchTransformFields( | ||
| 135 | + * { coverage: 10000, name: 'Test' }, | ||
| 136 | + * { coverage: { transform: 'fen_to_yuan' } } | ||
| 137 | + * ) // { coverage: '100.00', name: 'Test' } | ||
| 138 | + */ | ||
| 139 | +export function batchTransformFields(formData, fieldDefinitions) { | ||
| 140 | + const result = { ...formData } | ||
| 141 | + | ||
| 142 | + for (const [key, value] of Object.entries(result)) { | ||
| 143 | + const definition = fieldDefinitions[key] | ||
| 144 | + | ||
| 145 | + if (!definition || !definition.transform) { | ||
| 146 | + continue | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + result[key] = transformFieldValue(value, definition.transform) | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + return result | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +/** | ||
| 156 | + * 反向转换(从 API 响应转换回表单格式) | ||
| 157 | + * @param {Object} data - API 返回的数据 | ||
| 158 | + * @param {Object} fieldDefinitions - 字段定义映射 | ||
| 159 | + * @returns {Object} 转换后的表单数据 | ||
| 160 | + * | ||
| 161 | + * @example | ||
| 162 | + * reverseTransformFields( | ||
| 163 | + * { annual_premium: '100.00', name: 'Test' }, | ||
| 164 | + * { annual_premium: { api_field: 'annual_premium', transform: 'fen_to_yuan' } } | ||
| 165 | + * ) // { annual_premium: 10000, name: 'Test' } | ||
| 166 | + */ | ||
| 167 | +export function reverseTransformFields(data, fieldDefinitions) { | ||
| 168 | + const result = {} | ||
| 169 | + | ||
| 170 | + for (const [formKey, definition] of Object.entries(fieldDefinitions)) { | ||
| 171 | + // 跳过没有 api_field 的字段 | ||
| 172 | + if (!definition.api_field) { | ||
| 173 | + continue | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + const apiValue = data[definition.api_field] | ||
| 177 | + if (apiValue === undefined || apiValue === null) { | ||
| 178 | + continue | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + // 没有转换类型,直接使用原值 | ||
| 182 | + if (!definition.transform || definition.transform === TRANSFORM_TYPES.NONE) { | ||
| 183 | + result[formKey] = apiValue | ||
| 184 | + continue | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + // 获取反向转换器 | ||
| 188 | + let reverseTransform = definition.transform | ||
| 189 | + | ||
| 190 | + if (reverseTransform === TRANSFORM_TYPES.FEN_TO_YUAN) { | ||
| 191 | + reverseTransform = TRANSFORM_TYPES.YUAN_TO_FEN | ||
| 192 | + } else if (reverseTransform === TRANSFORM_TYPES.YUAN_TO_FEN) { | ||
| 193 | + reverseTransform = TRANSFORM_TYPES.FEN_TO_YUAN | ||
| 194 | + } else { | ||
| 195 | + // 未知转换类型,使用原值 | ||
| 196 | + result[formKey] = apiValue | ||
| 197 | + continue | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + result[formKey] = transformFieldValue(apiValue, reverseTransform) | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + return result | ||
| 204 | +} |
-
Please register or login to post a comment