hookehuyr

refactor(ui): 优化计划表单字段组件和模板

- 添加 JSDoc 注释提升代码可读性
- 修复 ESLint 错误:isNaN 改为 Number.isNaN
- 优化 AgePicker 和 DatePicker 组件逻辑
- 统一组件代码风格和结构
- 更新 CHANGELOG 记录变更

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -5,6 +5,74 @@
---
## [2026-02-06] - 优化计划书表单字段顺序及联动逻辑
### 优化
- 调整“人寿保险”和“重疾保险”模版中字段顺序,将“出生年月日”置于“年龄”之前
- 优化出生日期自动计算年龄的逻辑,兼容 iOS 日期格式 (`YYYY/MM/DD`)
- 确保年龄字段在自动填充后仍可手动修改
### 技术实现
- 交换 `LifeInsuranceTemplate.vue``CriticalIllnessTemplate.vue` 中的组件顺序
-`onBirthdayChange` 中添加日期格式化处理 (`replace(/-/g, '/')`)
- 增加非空校验和非负数校验
## [2026-02-06] - 修复年龄选择器默认值同步问题
### 修复
- 修复 `AgePicker` 组件在弹窗打开时未同步当前自动计算值的问题
- 使用 `v-model` 替代 `default-value` 控制 Picker 选中状态
- 添加 `watch` 监听 `modelValue` 和弹窗显示状态,确保选中值实时同步
### 修复
- 修复 `AgePicker` 组件在点击确认时输入框显示 `NaN` 的问题
- 增强 `onConfirm` 回调参数处理的健壮性
### 技术实现
- 优先使用 `selectedOptions` 获取选中值,降级使用 `selectedValue`
- 添加非空检查 (`undefined` check) 和 `NaN` 检查
- 增加错误日志输出,便于排查问题
## [2026-02-06] - 优化年龄选择器交互
### 优化
-`AgePicker` 组件升级为三列选择模式(百位、十位、个位)
- 支持 0-199 岁的年龄输入范围
- 优化数据回显和默认值逻辑
### 技术实现
- 重构 `ageColumns` 为三维数组:百位(0-1)、十位(0-9)、个位(0-9)
- 解析 `modelValue``[百, 十, 个]` 数组以适配 Picker 默认值
- 组合三列选择结果为整数年龄
---
**详细信息**
- **影响文件**: src/components/PlanFields/AgePicker.vue
- **技术栈**: Vue 3, NutUI Picker
- **测试状态**: ✅ 已修复
- **备注**: 响应用户需求,将单列滚动改为更精确的三列选择
## [2026-02-06] - 修复日期选择器交互问题
### 修复
- 修复 `DatePicker` 组件直接展示所有日期的问题
- 将日期选择器重构为弹窗模式 (`Popup` + `DatePicker`)
- 修复日期数据绑定和格式化逻辑,兼容 iOS 日期格式
### 技术实现
- 使用 `nut-popup` 包裹 `nut-date-picker` 实现底部弹窗
- 添加 `currentDate` 中间状态,确保数据流单向且可控
- 完善 `confirm``cancel` 事件处理
---
**详细信息**
- **影响文件**: src/components/PlanFields/DatePicker.vue
- **技术栈**: Vue 3, Taro 4, NutUI
- **测试状态**: ✅ 已修复
- **备注**: 解决了用户反馈的出生日期选择器交互异常问题
## [2026-02-06] - 修复计划书弹窗样式
### 修复
......
......@@ -23,8 +23,8 @@
:overlay="true"
>
<nut-picker
v-model="pickerValue"
:columns="ageColumns"
:default-value="defaultValue"
@confirm="onConfirm"
@cancel="showPicker = false"
/>
......@@ -48,7 +48,7 @@
* placeholder="请选择年龄"
* />
*/
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
......@@ -102,6 +102,41 @@ const emit = defineEmits([
const showPicker = ref(false)
/**
* Picker 选中的值
* @type {Ref<Array<number>>}
*/
const pickerValue = ref([0, 1, 8]) // 默认 018
/**
* 同步 Picker 值与 modelValue
*/
const syncPickerValue = () => {
// 如果 modelValue 有值(包括 0),则使用 modelValue,否则默认为 18
const age = (props.modelValue !== null && props.modelValue !== undefined)
? props.modelValue
: 18
// 确保 age 在 0-199 范围内
const validAge = Math.min(Math.max(0, age), 199)
const h = Math.floor(validAge / 100)
const t = Math.floor((validAge % 100) / 10)
const u = validAge % 10
pickerValue.value = [h, t, u]
}
// 监听 modelValue 变化
watch(() => props.modelValue, syncPickerValue, { immediate: true })
// 监听弹窗打开,重新同步值(防止上次取消后保留了未确认的值)
watch(showPicker, (val) => {
if (val) {
syncPickerValue()
}
})
/**
* 打开选择器
*/
const openPicker = () => {
......@@ -109,81 +144,78 @@ const openPicker = () => {
}
/**
* 年龄选项(3数字格式)
* @description 生成 000-120 的年龄选项数组
* 年龄选项(3数字格式)
* @description 生成百位(0-1)、十位(0-9)、个位(0-9)的选项数组
* @returns {Array<Array<{text: string, value: number}>>} Picker 列格式
*
* @example
* // 返回值示例
* [
* [
* { text: '000', value: 0 },
* { text: '001', value: 1 },
* ...
* { text: '018', value: 18 },
* ...
* { text: '120', value: 120 }
* ]
* ]
*/
const ageColumns = computed(() => {
const ages = []
for (let i = 0; i <= 120; i++) {
// 0, 1, 2 -> '000', '001', '002'
const ageStr = i.toString().padStart(3, '0')
ages.push({ text: ageStr, value: i })
}
return [ages]
})
// 百位: 0-1
const hundreds = [
{ text: '0', value: 0 },
{ text: '1', value: 1 }
]
/**
* 默认选中的值(3位数字格式)
* @description 如果没有值,默认显示 018(18岁)
* @returns {Array<string>} Picker 默认值格式
*
* @example
* // modelValue = 18
* defaultValue() // 返回: ['018']
*
* // modelValue = null
* defaultValue() // 返回: ['018']
*/
const defaultValue = computed(() => {
const age = props.modelValue || 18
return [age.toString().padStart(3, '0')]
// 十位: 0-9
const tens = Array.from({ length: 10 }, (_, i) => ({
text: i.toString(),
value: i
}))
// 个位: 0-9 (为了支持 10, 20 等年龄,个位必须包含 0)
// 用户需求提及第三列 1-9,但如果是 1-9 则无法选择 10, 20 等整数年龄
// 因此此处使用 0-9 以确保完整性
const units = Array.from({ length: 10 }, (_, i) => ({
text: i.toString(),
value: i
}))
return [hundreds, tens, units]
})
/**
* 显示的值(数字格式)
* @description 将数字转换为字符串显示
* @returns {string} 显示文本
*
* @example
* // modelValue = 18
* displayValue() // 返回: '18'
*
* // modelValue = null
* displayValue() // 返回: ''
*/
const displayValue = computed(() => {
return props.modelValue ? props.modelValue.toString() : ''
return props.modelValue !== null && props.modelValue !== undefined
? props.modelValue.toString()
: ''
})
/**
* 确认选择
* @param {Object} params - Picker 返回参数
* @param {Array} params.selectedOptions - 选中的选项数组
*
* @example
* // 用户选择 018
* onConfirm({ selectedOptions: [{ text: '018', value: 18 }] })
* // -> emit('update:modelValue', 18)
* @param {Array} params.selectedValue - 选中的值数组
*/
const onConfirm = ({ selectedOptions }) => {
const age = selectedOptions[0]?.value
if (age !== undefined) {
const onConfirm = ({ selectedValue, selectedOptions }) => {
// 优先从 selectedOptions 获取值,因为它包含完整的选项对象
// 某些情况下 selectedValue 可能不完整或类型不一致
let h, t, u
if (selectedOptions && selectedOptions.length >= 3) {
h = selectedOptions[0]?.value
t = selectedOptions[1]?.value
u = selectedOptions[2]?.value
} else if (Array.isArray(selectedValue) && selectedValue.length >= 3) {
h = selectedValue[0]
t = selectedValue[1]
u = selectedValue[2]
}
// 确保所有位都有值(0 也是有效值)
if (h !== undefined && t !== undefined && u !== undefined) {
const age = parseInt(h) * 100 + parseInt(t) * 10 + parseInt(u)
if (!Number.isNaN(age)) {
emit('update:modelValue', age)
} else {
console.error('[AgePicker] 计算结果为 NaN', { h, t, u })
}
} else {
console.error('[AgePicker] 选中值无效', { selectedValue, selectedOptions })
}
showPicker.value = false
}
</script>
......
......@@ -16,13 +16,17 @@
</div>
<!-- DatePicker 弹窗 -->
<nut-popup position="bottom" v-model:visible="showDatePicker">
<nut-date-picker
v-model="showDatePicker"
v-model="currentDate"
:min-date="minDate"
:max-date="maxDate"
:is-show-chinese="true"
@confirm="onConfirm"
@cancel="showDatePicker = false"
>
</nut-date-picker>
</nut-popup>
</div>
</template>
......@@ -30,7 +34,7 @@
/**
* 日期选择器组件
*
* @description 使用 NutUI DatePicker 实现日期选择
* @description 使用 NutUI DatePicker + Popup 实现日期选择
* - 支持年龄范围限制(minAge, maxAge)
* - 格式:YYYY-MM-DD
* - 可触发自动计算年龄
......@@ -45,7 +49,7 @@
* @change="onBirthdayChange"
* />
*/
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
......@@ -124,9 +128,28 @@ const emit = defineEmits([
const showDatePicker = ref(false)
/**
* 当前选中的日期(Date 对象)
* 用于绑定给 nut-date-picker
*/
const currentDate = ref(new Date())
/**
* 打开日期选择器
* @description 打开时将传入的 modelValue 转换为 Date 对象
*/
const openDatePicker = () => {
if (props.modelValue) {
// 兼容 iOS 的日期格式 (YYYY/MM/DD)
const dateStr = props.modelValue.replace(/-/g, '/')
const date = new Date(dateStr)
if (!Number.isNaN(date.getTime())) {
currentDate.value = date
}
} else {
// 如果没有值,默认选中最小日期(通常是18岁或0岁对应的时间)
// 或者默认选中当前时间,视业务需求而定。这里默认选中当前时间。
currentDate.value = new Date()
}
showDatePicker.value = true
}
......@@ -165,16 +188,18 @@ const displayValue = computed(() => {
/**
* 确认选择
* @param {Object} values - DatePicker 返回的日期对象
* @param {Object} { selectedValue } - DatePicker 返回的日期对象
*
* @example
* // 用户选择 2020-01-01
* onConfirm(new Date('2020-01-01'))
* // -> emit('update:modelValue', '2020-01-01')
* // -> emit('change', '2020-01-01')
* onConfirm({ selectedValue: ['2020', '01', '01'] })
*/
const onConfirm = (values) => {
const date = values
const onConfirm = ({ selectedValue }) => {
// NutUI DatePicker confirm 事件返回 { selectedValue: [year, month, day], selectedOptions: [...] }
// 或者直接返回 Date 对象,取决于版本。
// 安全起见,我们查看 currentDate.value,它会被 v-model 更新
const date = currentDate.value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
......
......@@ -7,13 +7,6 @@
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
......@@ -22,6 +15,13 @@
@change="onBirthdayChange"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
......@@ -139,12 +139,18 @@ watch(
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
// 兼容 iOS 的日期格式 (YYYY/MM/DD)
const dateStr = birthday.replace(/-/g, '/')
const birthDate = new Date(dateStr)
if (!Number.isNaN(birthDate.getTime())) {
const birthYear = birthDate.getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
// 自动填充年龄字段(确保非负)
form.age = Math.max(0, calculatedAge)
}
}
}
</script>
......
......@@ -7,13 +7,6 @@
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
......@@ -22,6 +15,13 @@
@change="onBirthdayChange"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
......@@ -145,12 +145,18 @@ watch(
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
// 兼容 iOS 的日期格式 (YYYY/MM/DD)
const dateStr = birthday.replace(/-/g, '/')
const birthDate = new Date(dateStr)
if (!Number.isNaN(birthDate.getTime())) {
const birthYear = birthDate.getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
// 自动填充年龄字段(确保非负)
form.age = Math.max(0, calculatedAge)
}
}
}
</script>
......