hookehuyr

feat(plan): 新增提取期自定义输入功能

- 新增 PeriodInput 组件,支持用户自定义输入提取期(1-100整数)
- SelectPickerGlobal 支持自定义选项入口,允许用户选择"自定义输入"
- SavingsTemplate 集成自定义输入功能,支持多阶段提取期自定义
- 自定义值临时保存到本次会话选项列表,跨阶段复用
- 验证规则:整数年期(1-100年)、快捷选项(终身、一笔过)
- 使用 watch 监听输入值变化,防止小程序 @input 事件丢失

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
......@@ -36,6 +36,7 @@ declare module 'vue' {
OfficeViewer: typeof import('./src/components/documents/OfficeViewer.vue')['default']
PaymentPeriodRadio: typeof import('./src/components/plan/PlanFields/PaymentPeriodRadio.vue')['default']
PdfPreview: typeof import('./src/components/documents/PdfPreview.vue')['default']
PeriodInput: typeof import('./src/components/plan/PlanFields/PeriodInput.vue')['default']
PlanFormContainer: typeof import('./src/components/plan/PlanFormContainer.vue')['default']
PlanPopupNew: typeof import('./src/components/plan/PlanPopupNew.vue')['default']
ProductCard: typeof import('./src/components/cards/ProductCard.vue')['default']
......
<template>
<view>
<!-- 标签 -->
<view v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
<text v-if="required" class="text-red-500 mr-1">*</text>
<text>{{ label }}</text>
</view>
<!-- 触发区域 -->
<view
class="flex justify-between items-center border border-gray-200 rounded-lg p-3 bg-gray-50"
@tap="openInput"
>
<text :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ displayValue || placeholder }}
</text>
<IconFont name="right" size="14" color="#9CA3AF" />
</view>
<!-- 自定义输入弹窗 -->
<nut-popup
v-model:visible="showInputModal"
position="bottom"
:overlay="true"
:close-on-click-overlay="false"
:style="{ padding: '0', borderRadius: '24rpx', overflow: 'hidden' }"
:overlay-style="{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }"
>
<view class="flex flex-col bg-white">
<!-- 标题栏 -->
<view class="flex justify-between items-center p-4 border-b border-gray-100">
<text class="text-base font-semibold text-gray-900">{{ inputLabel || '请输入提取期' }}</text>
<view class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-100" @tap="onCancel">
<text class="text-xl text-gray-500 leading-none">×</text>
</view>
</view>
<!-- 输入区域 -->
<view class="p-4">
<!-- 数字输入 + 单位 -->
<view class="flex items-center border border-gray-300 rounded-lg px-4 py-3 mb-3 bg-gray-50">
<input
v-model="inputValue"
class="flex-1 text-base text-gray-900 bg-transparent border-none outline-none"
type="digit"
:placeholder="inputPlaceholder"
@input="onInputChange"
/>
<text class="text-base text-gray-500 ml-2"></text>
</view>
<!-- 验证提示 -->
<view class="mb-4 min-h-[20rpx]" :class="isValid && inputValue ? 'text-green-500' : 'text-gray-500'">
<text v-if="!inputValue" class="text-xs">{{ validationHint }}</text>
<text v-else-if="isValid" class="text-xs"> 格式正确</text>
<text v-else class="text-xs text-red-500"> {{ validationError }}</text>
</view>
<!-- 快捷选项 -->
<view class="flex flex-wrap items-center gap-2">
<text class="text-sm text-gray-500">快捷选项:</text>
<view
v-for="option in quickOptions"
:key="option"
class="px-4 py-2 rounded-lg border text-sm transition-colors"
:class="inputValue === option ? 'bg-blue-50 border-blue-500 text-blue-600' : 'bg-white border-gray-300 text-gray-700'"
@tap="selectQuickOption(option)"
>
<text>{{ option }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="flex gap-3 p-4 border-t border-gray-100">
<view
class="flex-1 h-11 flex items-center justify-center rounded-lg text-base bg-gray-100 text-gray-700"
@tap="onCancel"
>
<text>取消</text>
</view>
<view
class="flex-1 h-11 flex items-center justify-center rounded-lg text-base transition-colors"
:class="canConfirm ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-400'"
@tap="onConfirm"
>
<text>确定</text>
</view>
</view>
</view>
</nut-popup>
</view>
</template>
<script setup>
/**
* 提取期自定义输入组件
*
* @description 用于输入自定义的提取期,支持整数年期(1-100年)和快捷选项
* - 整数约束:只接受整数年期(1-100)
* - 快捷选项:终身、一笔过
* - 实时验证:输入时即时反馈格式是否正确
* - 可扩展:预留 customValidators 接口支持未来格式扩展
*
* @module components/plan/PlanFields/PeriodInput
* @author Claude Code
* @version 1.0.0
*
* @example
* <PeriodInput
* v-model:visible="showPeriodInput"
* v-model="periodValue"
* label="提取期"
* inputLabel="请输入提取期"
* :validation-rules="{ min: 1, max: 100 }"
* @confirm="handlePeriodConfirm"
* />
*/
import { ref, computed, watch } from 'vue'
import IconFont from '@/components/icons/IconFont.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 弹窗显示状态(v-model:visible)
* @type {boolean}
*/
visible: {
type: Boolean,
default: false
},
/**
* 绑定的值(v-model)
* @type {string}
*/
modelValue: {
type: String,
default: ''
},
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请选择或输入提取期'
},
/**
* 弹窗内输入提示文本
* @type {string}
*/
inputLabel: {
type: String,
default: '请输入提取期'
},
/**
* 输入框占位符
* @type {string}
*/
inputPlaceholder: {
type: String,
default: '请输入年数'
},
/**
* 验证规则
* @type {Object}
* @property {number} min - 最小年期(默认1)
* @property {number} max - 最大年期(默认100)
* @property {string[]} allowed_formats - 允许的非年期格式(如['终身', '一笔过'])
* @property {Function[]} custom_validators - 自定义验证函数数组(预留扩展)
*/
validationRules: {
type: Object,
default: () => ({
min: 1,
max: 100,
allowed_formats: ['终身', '一笔过'],
custom_validators: []
})
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新弹窗显示状态(v-model:visible)
* @event update:visible
* @param {boolean} value - 弹窗显示状态
*/
'update:visible',
/**
* 更新绑定值(v-model)
* @event update:modelValue
* @param {string} value - 选中的提取期值
*/
'update:modelValue',
/**
* 确认输入事件
* @event confirm
* @param {string} value - 确认的提取期值
*/
'confirm',
/**
* 取消输入事件
* @event cancel
*/
'cancel'
])
/**
* 弹窗显示状态
*/
const showInputModal = ref(false)
/**
* 输入框的值(原始输入)
*/
const inputValue = ref('')
/**
* 当前验证状态
*/
const isValid = ref(false)
/**
* 验证错误信息
*/
const validationError = ref('')
/**
* 验证提示信息(无输入时显示)
*/
const validationHint = ref('')
/**
* 快捷选项列表
*/
const quickOptions = computed(() => {
return props.validationRules?.allowed_formats || ['终身', '一笔过']
})
/**
* 显示的值
*/
const displayValue = computed(() => {
return props.modelValue || ''
})
/**
* 是否可以确认
*/
const canConfirm = computed(() => {
return isValid.value && inputValue.value
})
/**
* 监听 visible prop 变化
*/
watch(() => props.visible, (newVal) => {
showInputModal.value = newVal
if (newVal) {
// 打开弹窗时,初始化输入值
initInputValue()
}
})
/**
* 监听弹窗状态变化,同步到父组件
*/
watch(showInputModal, (newVal) => {
if (!newVal) {
emit('update:visible', false)
}
})
/**
* 监听输入值变化,触发验证
*/
watch(inputValue, () => {
// 每次输入值变化都触发验证(防止 @input 事件丢失)
validateInput()
})
/**
* 初始化输入值
*/
const initInputValue = () => {
if (props.modelValue) {
// 如果是快捷选项,直接使用
if (quickOptions.value.includes(props.modelValue)) {
inputValue.value = props.modelValue
} else {
// 如果是年期格式,提取数字
const match = props.modelValue.match(/^(\d+)年$/)
if (match) {
inputValue.value = match[1] // 只存储数字
} else {
inputValue.value = props.modelValue
}
}
} else {
inputValue.value = ''
}
validateInput()
}
/**
* 打开输入弹窗
*/
const openInput = () => {
emit('update:visible', true)
}
/**
* 输入变化处理
*/
const onInputChange = () => {
validateInput()
}
/**
* 选择快捷选项
*/
const selectQuickOption = (option) => {
inputValue.value = option
validateInput()
}
/**
* 验证输入
* @returns {boolean} 是否有效
*/
const validateInput = () => {
const rules = props.validationRules || {}
const value = inputValue.value.trim()
// 空值
if (!value) {
isValid.value = false
validationError.value = ''
validationHint.value = `请输入${rules.min || 1}-${rules.max || 100}之间的整数,或选择快捷选项`
return false
}
// 快捷选项(终身、一笔过等)
if (quickOptions.value.includes(value)) {
isValid.value = true
validationError.value = ''
validationHint.value = ''
return true
}
// 数字年期验证
const num = Number(value)
// 检查是否为有效数字
if (Number.isNaN(num)) {
isValid.value = false
validationError.value = '请输入数字'
validationHint.value = ''
return false
}
// 检查是否为整数(核心要求)
if (!Number.isInteger(num)) {
isValid.value = false
validationError.value = '年期必须是整数'
validationHint.value = ''
return false
}
// 检查范围
const min = rules.min ?? 1
const max = rules.max ?? 100
if (num < min) {
isValid.value = false
validationError.value = `年期不能小于${min}年`
validationHint.value = ''
return false
}
if (num > max) {
isValid.value = false
validationError.value = `年期不能大于${max}年`
validationHint.value = ''
return false
}
// 自定义验证器(预留扩展)
if (rules.custom_validators && rules.custom_validators.length > 0) {
for (const validator of rules.custom_validators) {
const result = validator(value)
if (result.valid === false) {
isValid.value = false
validationError.value = result.error || '格式不正确'
validationHint.value = ''
return false
}
}
}
// 验证通过
isValid.value = true
validationError.value = ''
validationHint.value = ''
return true
}
/**
* 确认输入
*/
const onConfirm = () => {
if (!canConfirm.value) return
let finalValue = inputValue.value
// 如果是纯数字,添加"年"单位
const num = Number(inputValue.value)
if (!Number.isNaN(num) && Number.isInteger(num)) {
finalValue = `${num}年`
}
emit('update:modelValue', finalValue)
emit('confirm', finalValue)
showInputModal.value = false
}
/**
* 取消输入
*/
const onCancel = () => {
inputValue.value = ''
isValid.value = false
showInputModal.value = false
emit('cancel')
}
</script>
<style lang="less" scoped>
/* 组件样式(如有需要可在此补充 TailwindCSS 无法覆盖的样式) */
</style>
......@@ -39,17 +39,30 @@
*
* @description 使用 NutUI Picker 实现下拉选择功能
* - key 和 value 相同(如"整付(0-75 岁)")
* - 适用于缴费年期等场景
* - 适用于缴费年期、提取期等场景
* - 使用 GlobalPopupManager 管理弹窗层级
* - 支持自定义输入选项(可选功能)
* @author Claude Code
* @version 2.0.0 - 支持全局弹窗管理器
* @version 2.1.0 - 新增自定义输入支持
* @example
* // 基础用法
* <SelectPickerGlobal
* v-model="paymentPeriod"
* label="缴费年期"
* placeholder="请选择缴费年期"
* :options="['整付(0-75 岁)', '5 年(0-70 岁)']"
* />
*
* @example
* // 支持自定义输入
* <SelectPickerGlobal
* v-model="withdrawalPeriod"
* label="提取期"
* placeholder="请选择提取期"
* :options="['1年', '5年', '10年', '终身']"
* :allow-custom="true"
* @custom-select="openCustomInput"
* />
*/
import { ref, computed, onMounted } from 'vue'
import IconFont from '@/components/icons/IconFont.vue'
......@@ -121,6 +134,35 @@ const props = defineProps({
options: {
type: Array,
required: true
},
/**
* 是否允许自定义输入
* @type {boolean}
* @description 开启后,选项列表末尾会添加"自定义输入"选项
*/
allowCustom: {
type: Boolean,
default: false
},
/**
* 自定义选项的显示文本
* @type {string}
*/
customLabel: {
type: String,
default: '📝 自定义输入...'
},
/**
* 分隔符文本
* @type {string}
* @description 自定义选项与标准选项之间的分隔线
*/
dividerLabel: {
type: String,
default: '──────────'
}
})
......@@ -143,7 +185,12 @@ const emit = defineEmits([
* 弹窗关闭事件
* @event close
*/
'close'
'close',
/**
* 用户选择自定义输入选项
* @event custom-select
*/
'custom-select'
])
/**
......@@ -167,15 +214,34 @@ const openPicker = () => {
/**
* 转换为 Picker 格式
* @description 将选项数组转换为 Picker 需要的格式
* 如果允许自定义,会在末尾添加分隔符和自定义选项
* @example
* // options = ['整付(0-75 岁)', '5 年(0-70 岁)']
* // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...]
*/
const pickerColumns = computed(() => {
return props.options.map(option => ({
const standardOptions = props.options.map(option => ({
text: option,
value: option // key 和 value 相同
}))
// 如果允许自定义,添加分隔符和自定义选项
if (props.allowCustom) {
return [
...standardOptions,
{
text: props.dividerLabel,
value: '__divider__',
disabled: true // 分隔符不可选
},
{
text: props.customLabel,
value: '__custom__'
}
]
}
return standardOptions
})
/**
......@@ -197,6 +263,27 @@ const displayValue = computed(() => {
*/
const onConfirm = ({ selectedOptions }) => {
const value = selectedOptions[0]?.value
// 处理自定义选项
if (value === '__custom__') {
// 触发自定义选择事件,由父组件处理
emit('custom-select')
// 停用弹窗
if (popupId.value) {
deactivatePopup(popupId.value)
}
showPicker.value = false
emit('close')
return
}
// 跳过分隔符
if (value === '__divider__') {
return
}
// 标准选项处理
if (value !== undefined) {
emit('update:modelValue', value)
}
......
......@@ -92,7 +92,9 @@
label="提取期"
placeholder="请选择提取期"
:required="true"
:options="multiStagePeriodOptions"
:options="dynamicPeriodOptions"
:allow-custom="isCustomPeriodEnabled"
@custom-select="openPeriodInput(index)"
class="mb-3"
/>
......@@ -142,6 +144,17 @@
<p>⚠️ 模板配置未找到</p>
<p class="text-sm mt-2">请检查产品配置或联系开发人员</p>
</div>
<!-- 自定义提取期输入弹窗 -->
<PeriodInput
v-model:visible="showPeriodInput"
v-model="currentPeriodValue"
inputLabel="请输入提取期"
inputPlaceholder="请输入年数"
:validation-rules="periodValidationRules"
@confirm="onPeriodInputConfirm"
@cancel="onPeriodInputCancel"
/>
</template>
<script setup>
......@@ -167,6 +180,7 @@ import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue'
import PlanFieldRadio from '../PlanFields/RadioGroup.vue'
import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue'
import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue'
import PeriodInput from '../PlanFields/PeriodInput.vue'
import { useFieldDependencies } from '@/composables/useFieldDependencies'
/**
......@@ -387,6 +401,45 @@ const canRemoveStage = computed(() => {
})
/**
* 自定义提取期输入状态
*/
const showPeriodInput = ref(false) // 自定义输入弹窗显示状态
const currentPeriodValue = ref('') // 当前输入的提取期值
const currentStageIndex = ref(-1) // 当前正在编辑的阶段索引
const customPeriodValues = ref([]) // 用户自定义的提取期值列表(临时保存)
/**
* 自定义提取期是否启用
*/
const isCustomPeriodEnabled = computed(() => {
return multiStageConfig.value.custom_period?.enabled || false
})
/**
* 自定义提取期验证规则
*/
const periodValidationRules = computed(() => {
const config = multiStageConfig.value.custom_period?.validation || {}
return {
min: config.min_years ?? 1,
max: config.max_years ?? 100,
allowed_formats: config.allowed_formats || ['终身', '一笔过'],
custom_validators: config.custom_validators || []
}
})
/**
* 动态提取期选项(预设选项 + 用户自定义选项)
*/
const dynamicPeriodOptions = computed(() => {
const baseOptions = multiStageConfig.value.withdrawal_periods ||
props.config?.withdrawal_plan?.withdrawal_periods ||
[]
// 合并预设选项和用户自定义选项
return [...baseOptions, ...customPeriodValues.value]
})
/**
* 创建空的阶段数据
* @returns {Object} 空阶段对象
*/
......@@ -445,6 +498,50 @@ const removeStage = (index) => {
}
/**
* 打开自定义提取期输入弹窗
* @param {number} stageIndex - 阶段索引
*/
const openPeriodInput = (stageIndex) => {
currentStageIndex.value = stageIndex
const stage = stages.value[stageIndex]
currentPeriodValue.value = stage?.withdrawal_period || ''
showPeriodInput.value = true
}
/**
* 自定义提取期输入确认
* @param {string} value - 确认的提取期值
*/
const onPeriodInputConfirm = (value) => {
// 添加到自定义值列表(去重:不在预设选项和已存在的自定义列表中)
const baseOptions = multiStageConfig.value.withdrawal_periods ||
props.config?.withdrawal_plan?.withdrawal_periods ||
[]
if (!baseOptions.includes(value) && !customPeriodValues.value.includes(value)) {
customPeriodValues.value.push(value)
}
// 更新当前阶段的值
if (currentStageIndex.value >= 0 && currentStageIndex.value < stages.value.length) {
stages.value[currentStageIndex.value].withdrawal_period = value
}
// 关闭弹窗并重置状态
showPeriodInput.value = false
currentStageIndex.value = -1
currentPeriodValue.value = ''
}
/**
* 自定义提取期输入取消
*/
const onPeriodInputCancel = () => {
showPeriodInput.value = false
currentStageIndex.value = -1
currentPeriodValue.value = ''
}
/**
* 同步阶段数据到表单
* @description 将 stages 数组同步到 form.withdrawal_stages,以便父组件获取
* 同时清理 undefined 值为 null,确保提交数据格式正确
......
......@@ -123,6 +123,7 @@ const savingsFormSchema = {
* 多阶段提取计划配置
* @description 用于"宏挚传承保障计划(多阶段)"等支持多阶段提取的产品
* @updated 2026-02-25 - 新增多阶段提取功能
* @updated 2026-02-28 - 新增自定义提取期输入功能
*/
const multiStageWithdrawalConfig = {
enabled: true,
......@@ -133,7 +134,20 @@ const multiStageWithdrawalConfig = {
'10年', '15年', '20年', '终身',
'一笔过' // 新增:一次性提取选项
],
percentage_optional: true // 递增百分比可选
percentage_optional: true, // 递增百分比可选
// 自定义提取期配置(新增)
custom_period: {
enabled: true, // 是否允许自定义输入
custom_label: '📝 自定义输入...', // 自定义选项显示文本
validation: {
min_years: 1, // 最小年期(整数)
max_years: 100, // 最大年期(整数)
allowed_formats: ['终身', '一笔过'], // 允许的非年期格式
// 预留:未来可扩展更多格式
custom_validators: [] // 自定义验证函数数组
}
}
}
export const PLAN_TEMPLATES = {
......