feat(plan): 新增提取期自定义输入功能
- 新增 PeriodInput 组件,支持用户自定义输入提取期(1-100整数) - SelectPickerGlobal 支持自定义选项入口,允许用户选择"自定义输入" - SavingsTemplate 集成自定义输入功能,支持多阶段提取期自定义 - 自定义值临时保存到本次会话选项列表,跨阶段复用 - 验证规则:整数年期(1-100年)、快捷选项(终身、一笔过) - 使用 watch 监听输入值变化,防止小程序 @input 事件丢失 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Showing
5 changed files
with
677 additions
and
6 deletions
| ... | @@ -36,6 +36,7 @@ declare module 'vue' { | ... | @@ -36,6 +36,7 @@ declare module 'vue' { |
| 36 | OfficeViewer: typeof import('./src/components/documents/OfficeViewer.vue')['default'] | 36 | OfficeViewer: typeof import('./src/components/documents/OfficeViewer.vue')['default'] |
| 37 | PaymentPeriodRadio: typeof import('./src/components/plan/PlanFields/PaymentPeriodRadio.vue')['default'] | 37 | PaymentPeriodRadio: typeof import('./src/components/plan/PlanFields/PaymentPeriodRadio.vue')['default'] |
| 38 | PdfPreview: typeof import('./src/components/documents/PdfPreview.vue')['default'] | 38 | PdfPreview: typeof import('./src/components/documents/PdfPreview.vue')['default'] |
| 39 | + PeriodInput: typeof import('./src/components/plan/PlanFields/PeriodInput.vue')['default'] | ||
| 39 | PlanFormContainer: typeof import('./src/components/plan/PlanFormContainer.vue')['default'] | 40 | PlanFormContainer: typeof import('./src/components/plan/PlanFormContainer.vue')['default'] |
| 40 | PlanPopupNew: typeof import('./src/components/plan/PlanPopupNew.vue')['default'] | 41 | PlanPopupNew: typeof import('./src/components/plan/PlanPopupNew.vue')['default'] |
| 41 | ProductCard: typeof import('./src/components/cards/ProductCard.vue')['default'] | 42 | ProductCard: typeof import('./src/components/cards/ProductCard.vue')['default'] | ... | ... |
| 1 | +<template> | ||
| 2 | + <view> | ||
| 3 | + <!-- 标签 --> | ||
| 4 | + <view v-if="label" class="text-sm text-gray-600 mb-2 flex items-center"> | ||
| 5 | + <text v-if="required" class="text-red-500 mr-1">*</text> | ||
| 6 | + <text>{{ label }}</text> | ||
| 7 | + </view> | ||
| 8 | + | ||
| 9 | + <!-- 触发区域 --> | ||
| 10 | + <view | ||
| 11 | + class="flex justify-between items-center border border-gray-200 rounded-lg p-3 bg-gray-50" | ||
| 12 | + @tap="openInput" | ||
| 13 | + > | ||
| 14 | + <text :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm"> | ||
| 15 | + {{ displayValue || placeholder }} | ||
| 16 | + </text> | ||
| 17 | + <IconFont name="right" size="14" color="#9CA3AF" /> | ||
| 18 | + </view> | ||
| 19 | + | ||
| 20 | + <!-- 自定义输入弹窗 --> | ||
| 21 | + <nut-popup | ||
| 22 | + v-model:visible="showInputModal" | ||
| 23 | + position="bottom" | ||
| 24 | + :overlay="true" | ||
| 25 | + :close-on-click-overlay="false" | ||
| 26 | + :style="{ padding: '0', borderRadius: '24rpx', overflow: 'hidden' }" | ||
| 27 | + :overlay-style="{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }" | ||
| 28 | + > | ||
| 29 | + <view class="flex flex-col bg-white"> | ||
| 30 | + <!-- 标题栏 --> | ||
| 31 | + <view class="flex justify-between items-center p-4 border-b border-gray-100"> | ||
| 32 | + <text class="text-base font-semibold text-gray-900">{{ inputLabel || '请输入提取期' }}</text> | ||
| 33 | + <view class="w-8 h-8 flex items-center justify-center rounded-full bg-gray-100" @tap="onCancel"> | ||
| 34 | + <text class="text-xl text-gray-500 leading-none">×</text> | ||
| 35 | + </view> | ||
| 36 | + </view> | ||
| 37 | + | ||
| 38 | + <!-- 输入区域 --> | ||
| 39 | + <view class="p-4"> | ||
| 40 | + <!-- 数字输入 + 单位 --> | ||
| 41 | + <view class="flex items-center border border-gray-300 rounded-lg px-4 py-3 mb-3 bg-gray-50"> | ||
| 42 | + <input | ||
| 43 | + v-model="inputValue" | ||
| 44 | + class="flex-1 text-base text-gray-900 bg-transparent border-none outline-none" | ||
| 45 | + type="digit" | ||
| 46 | + :placeholder="inputPlaceholder" | ||
| 47 | + @input="onInputChange" | ||
| 48 | + /> | ||
| 49 | + <text class="text-base text-gray-500 ml-2">年</text> | ||
| 50 | + </view> | ||
| 51 | + | ||
| 52 | + <!-- 验证提示 --> | ||
| 53 | + <view class="mb-4 min-h-[20rpx]" :class="isValid && inputValue ? 'text-green-500' : 'text-gray-500'"> | ||
| 54 | + <text v-if="!inputValue" class="text-xs">{{ validationHint }}</text> | ||
| 55 | + <text v-else-if="isValid" class="text-xs">✓ 格式正确</text> | ||
| 56 | + <text v-else class="text-xs text-red-500">✗ {{ validationError }}</text> | ||
| 57 | + </view> | ||
| 58 | + | ||
| 59 | + <!-- 快捷选项 --> | ||
| 60 | + <view class="flex flex-wrap items-center gap-2"> | ||
| 61 | + <text class="text-sm text-gray-500">快捷选项:</text> | ||
| 62 | + <view | ||
| 63 | + v-for="option in quickOptions" | ||
| 64 | + :key="option" | ||
| 65 | + class="px-4 py-2 rounded-lg border text-sm transition-colors" | ||
| 66 | + :class="inputValue === option ? 'bg-blue-50 border-blue-500 text-blue-600' : 'bg-white border-gray-300 text-gray-700'" | ||
| 67 | + @tap="selectQuickOption(option)" | ||
| 68 | + > | ||
| 69 | + <text>{{ option }}</text> | ||
| 70 | + </view> | ||
| 71 | + </view> | ||
| 72 | + </view> | ||
| 73 | + | ||
| 74 | + <!-- 操作按钮 --> | ||
| 75 | + <view class="flex gap-3 p-4 border-t border-gray-100"> | ||
| 76 | + <view | ||
| 77 | + class="flex-1 h-11 flex items-center justify-center rounded-lg text-base bg-gray-100 text-gray-700" | ||
| 78 | + @tap="onCancel" | ||
| 79 | + > | ||
| 80 | + <text>取消</text> | ||
| 81 | + </view> | ||
| 82 | + <view | ||
| 83 | + class="flex-1 h-11 flex items-center justify-center rounded-lg text-base transition-colors" | ||
| 84 | + :class="canConfirm ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-400'" | ||
| 85 | + @tap="onConfirm" | ||
| 86 | + > | ||
| 87 | + <text>确定</text> | ||
| 88 | + </view> | ||
| 89 | + </view> | ||
| 90 | + </view> | ||
| 91 | + </nut-popup> | ||
| 92 | + </view> | ||
| 93 | +</template> | ||
| 94 | + | ||
| 95 | +<script setup> | ||
| 96 | +/** | ||
| 97 | + * 提取期自定义输入组件 | ||
| 98 | + * | ||
| 99 | + * @description 用于输入自定义的提取期,支持整数年期(1-100年)和快捷选项 | ||
| 100 | + * - 整数约束:只接受整数年期(1-100) | ||
| 101 | + * - 快捷选项:终身、一笔过 | ||
| 102 | + * - 实时验证:输入时即时反馈格式是否正确 | ||
| 103 | + * - 可扩展:预留 customValidators 接口支持未来格式扩展 | ||
| 104 | + * | ||
| 105 | + * @module components/plan/PlanFields/PeriodInput | ||
| 106 | + * @author Claude Code | ||
| 107 | + * @version 1.0.0 | ||
| 108 | + * | ||
| 109 | + * @example | ||
| 110 | + * <PeriodInput | ||
| 111 | + * v-model:visible="showPeriodInput" | ||
| 112 | + * v-model="periodValue" | ||
| 113 | + * label="提取期" | ||
| 114 | + * inputLabel="请输入提取期" | ||
| 115 | + * :validation-rules="{ min: 1, max: 100 }" | ||
| 116 | + * @confirm="handlePeriodConfirm" | ||
| 117 | + * /> | ||
| 118 | + */ | ||
| 119 | +import { ref, computed, watch } from 'vue' | ||
| 120 | +import IconFont from '@/components/icons/IconFont.vue' | ||
| 121 | + | ||
| 122 | +/** | ||
| 123 | + * 组件属性 | ||
| 124 | + */ | ||
| 125 | +const props = defineProps({ | ||
| 126 | + /** | ||
| 127 | + * 弹窗显示状态(v-model:visible) | ||
| 128 | + * @type {boolean} | ||
| 129 | + */ | ||
| 130 | + visible: { | ||
| 131 | + type: Boolean, | ||
| 132 | + default: false | ||
| 133 | + }, | ||
| 134 | + | ||
| 135 | + /** | ||
| 136 | + * 绑定的值(v-model) | ||
| 137 | + * @type {string} | ||
| 138 | + */ | ||
| 139 | + modelValue: { | ||
| 140 | + type: String, | ||
| 141 | + default: '' | ||
| 142 | + }, | ||
| 143 | + | ||
| 144 | + /** | ||
| 145 | + * 标签文本 | ||
| 146 | + * @type {string} | ||
| 147 | + */ | ||
| 148 | + label: { | ||
| 149 | + type: String, | ||
| 150 | + default: '' | ||
| 151 | + }, | ||
| 152 | + | ||
| 153 | + /** | ||
| 154 | + * 是否必填 | ||
| 155 | + * @type {boolean} | ||
| 156 | + */ | ||
| 157 | + required: { | ||
| 158 | + type: Boolean, | ||
| 159 | + default: false | ||
| 160 | + }, | ||
| 161 | + | ||
| 162 | + /** | ||
| 163 | + * 占位符文本 | ||
| 164 | + * @type {string} | ||
| 165 | + */ | ||
| 166 | + placeholder: { | ||
| 167 | + type: String, | ||
| 168 | + default: '请选择或输入提取期' | ||
| 169 | + }, | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * 弹窗内输入提示文本 | ||
| 173 | + * @type {string} | ||
| 174 | + */ | ||
| 175 | + inputLabel: { | ||
| 176 | + type: String, | ||
| 177 | + default: '请输入提取期' | ||
| 178 | + }, | ||
| 179 | + | ||
| 180 | + /** | ||
| 181 | + * 输入框占位符 | ||
| 182 | + * @type {string} | ||
| 183 | + */ | ||
| 184 | + inputPlaceholder: { | ||
| 185 | + type: String, | ||
| 186 | + default: '请输入年数' | ||
| 187 | + }, | ||
| 188 | + | ||
| 189 | + /** | ||
| 190 | + * 验证规则 | ||
| 191 | + * @type {Object} | ||
| 192 | + * @property {number} min - 最小年期(默认1) | ||
| 193 | + * @property {number} max - 最大年期(默认100) | ||
| 194 | + * @property {string[]} allowed_formats - 允许的非年期格式(如['终身', '一笔过']) | ||
| 195 | + * @property {Function[]} custom_validators - 自定义验证函数数组(预留扩展) | ||
| 196 | + */ | ||
| 197 | + validationRules: { | ||
| 198 | + type: Object, | ||
| 199 | + default: () => ({ | ||
| 200 | + min: 1, | ||
| 201 | + max: 100, | ||
| 202 | + allowed_formats: ['终身', '一笔过'], | ||
| 203 | + custom_validators: [] | ||
| 204 | + }) | ||
| 205 | + } | ||
| 206 | +}) | ||
| 207 | + | ||
| 208 | +/** | ||
| 209 | + * 组件事件 | ||
| 210 | + */ | ||
| 211 | +const emit = defineEmits([ | ||
| 212 | + /** | ||
| 213 | + * 更新弹窗显示状态(v-model:visible) | ||
| 214 | + * @event update:visible | ||
| 215 | + * @param {boolean} value - 弹窗显示状态 | ||
| 216 | + */ | ||
| 217 | + 'update:visible', | ||
| 218 | + | ||
| 219 | + /** | ||
| 220 | + * 更新绑定值(v-model) | ||
| 221 | + * @event update:modelValue | ||
| 222 | + * @param {string} value - 选中的提取期值 | ||
| 223 | + */ | ||
| 224 | + 'update:modelValue', | ||
| 225 | + | ||
| 226 | + /** | ||
| 227 | + * 确认输入事件 | ||
| 228 | + * @event confirm | ||
| 229 | + * @param {string} value - 确认的提取期值 | ||
| 230 | + */ | ||
| 231 | + 'confirm', | ||
| 232 | + | ||
| 233 | + /** | ||
| 234 | + * 取消输入事件 | ||
| 235 | + * @event cancel | ||
| 236 | + */ | ||
| 237 | + 'cancel' | ||
| 238 | +]) | ||
| 239 | + | ||
| 240 | +/** | ||
| 241 | + * 弹窗显示状态 | ||
| 242 | + */ | ||
| 243 | +const showInputModal = ref(false) | ||
| 244 | + | ||
| 245 | +/** | ||
| 246 | + * 输入框的值(原始输入) | ||
| 247 | + */ | ||
| 248 | +const inputValue = ref('') | ||
| 249 | + | ||
| 250 | +/** | ||
| 251 | + * 当前验证状态 | ||
| 252 | + */ | ||
| 253 | +const isValid = ref(false) | ||
| 254 | + | ||
| 255 | +/** | ||
| 256 | + * 验证错误信息 | ||
| 257 | + */ | ||
| 258 | +const validationError = ref('') | ||
| 259 | + | ||
| 260 | +/** | ||
| 261 | + * 验证提示信息(无输入时显示) | ||
| 262 | + */ | ||
| 263 | +const validationHint = ref('') | ||
| 264 | + | ||
| 265 | +/** | ||
| 266 | + * 快捷选项列表 | ||
| 267 | + */ | ||
| 268 | +const quickOptions = computed(() => { | ||
| 269 | + return props.validationRules?.allowed_formats || ['终身', '一笔过'] | ||
| 270 | +}) | ||
| 271 | + | ||
| 272 | +/** | ||
| 273 | + * 显示的值 | ||
| 274 | + */ | ||
| 275 | +const displayValue = computed(() => { | ||
| 276 | + return props.modelValue || '' | ||
| 277 | +}) | ||
| 278 | + | ||
| 279 | +/** | ||
| 280 | + * 是否可以确认 | ||
| 281 | + */ | ||
| 282 | +const canConfirm = computed(() => { | ||
| 283 | + return isValid.value && inputValue.value | ||
| 284 | +}) | ||
| 285 | + | ||
| 286 | +/** | ||
| 287 | + * 监听 visible prop 变化 | ||
| 288 | + */ | ||
| 289 | +watch(() => props.visible, (newVal) => { | ||
| 290 | + showInputModal.value = newVal | ||
| 291 | + if (newVal) { | ||
| 292 | + // 打开弹窗时,初始化输入值 | ||
| 293 | + initInputValue() | ||
| 294 | + } | ||
| 295 | +}) | ||
| 296 | + | ||
| 297 | +/** | ||
| 298 | + * 监听弹窗状态变化,同步到父组件 | ||
| 299 | + */ | ||
| 300 | +watch(showInputModal, (newVal) => { | ||
| 301 | + if (!newVal) { | ||
| 302 | + emit('update:visible', false) | ||
| 303 | + } | ||
| 304 | +}) | ||
| 305 | + | ||
| 306 | +/** | ||
| 307 | + * 监听输入值变化,触发验证 | ||
| 308 | + */ | ||
| 309 | +watch(inputValue, () => { | ||
| 310 | + // 每次输入值变化都触发验证(防止 @input 事件丢失) | ||
| 311 | + validateInput() | ||
| 312 | +}) | ||
| 313 | + | ||
| 314 | +/** | ||
| 315 | + * 初始化输入值 | ||
| 316 | + */ | ||
| 317 | +const initInputValue = () => { | ||
| 318 | + if (props.modelValue) { | ||
| 319 | + // 如果是快捷选项,直接使用 | ||
| 320 | + if (quickOptions.value.includes(props.modelValue)) { | ||
| 321 | + inputValue.value = props.modelValue | ||
| 322 | + } else { | ||
| 323 | + // 如果是年期格式,提取数字 | ||
| 324 | + const match = props.modelValue.match(/^(\d+)年$/) | ||
| 325 | + if (match) { | ||
| 326 | + inputValue.value = match[1] // 只存储数字 | ||
| 327 | + } else { | ||
| 328 | + inputValue.value = props.modelValue | ||
| 329 | + } | ||
| 330 | + } | ||
| 331 | + } else { | ||
| 332 | + inputValue.value = '' | ||
| 333 | + } | ||
| 334 | + validateInput() | ||
| 335 | +} | ||
| 336 | + | ||
| 337 | +/** | ||
| 338 | + * 打开输入弹窗 | ||
| 339 | + */ | ||
| 340 | +const openInput = () => { | ||
| 341 | + emit('update:visible', true) | ||
| 342 | +} | ||
| 343 | + | ||
| 344 | +/** | ||
| 345 | + * 输入变化处理 | ||
| 346 | + */ | ||
| 347 | +const onInputChange = () => { | ||
| 348 | + validateInput() | ||
| 349 | +} | ||
| 350 | + | ||
| 351 | +/** | ||
| 352 | + * 选择快捷选项 | ||
| 353 | + */ | ||
| 354 | +const selectQuickOption = (option) => { | ||
| 355 | + inputValue.value = option | ||
| 356 | + validateInput() | ||
| 357 | +} | ||
| 358 | + | ||
| 359 | +/** | ||
| 360 | + * 验证输入 | ||
| 361 | + * @returns {boolean} 是否有效 | ||
| 362 | + */ | ||
| 363 | +const validateInput = () => { | ||
| 364 | + const rules = props.validationRules || {} | ||
| 365 | + const value = inputValue.value.trim() | ||
| 366 | + | ||
| 367 | + // 空值 | ||
| 368 | + if (!value) { | ||
| 369 | + isValid.value = false | ||
| 370 | + validationError.value = '' | ||
| 371 | + validationHint.value = `请输入${rules.min || 1}-${rules.max || 100}之间的整数,或选择快捷选项` | ||
| 372 | + return false | ||
| 373 | + } | ||
| 374 | + | ||
| 375 | + // 快捷选项(终身、一笔过等) | ||
| 376 | + if (quickOptions.value.includes(value)) { | ||
| 377 | + isValid.value = true | ||
| 378 | + validationError.value = '' | ||
| 379 | + validationHint.value = '' | ||
| 380 | + return true | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + // 数字年期验证 | ||
| 384 | + const num = Number(value) | ||
| 385 | + | ||
| 386 | + // 检查是否为有效数字 | ||
| 387 | + if (Number.isNaN(num)) { | ||
| 388 | + isValid.value = false | ||
| 389 | + validationError.value = '请输入数字' | ||
| 390 | + validationHint.value = '' | ||
| 391 | + return false | ||
| 392 | + } | ||
| 393 | + | ||
| 394 | + // 检查是否为整数(核心要求) | ||
| 395 | + if (!Number.isInteger(num)) { | ||
| 396 | + isValid.value = false | ||
| 397 | + validationError.value = '年期必须是整数' | ||
| 398 | + validationHint.value = '' | ||
| 399 | + return false | ||
| 400 | + } | ||
| 401 | + | ||
| 402 | + // 检查范围 | ||
| 403 | + const min = rules.min ?? 1 | ||
| 404 | + const max = rules.max ?? 100 | ||
| 405 | + | ||
| 406 | + if (num < min) { | ||
| 407 | + isValid.value = false | ||
| 408 | + validationError.value = `年期不能小于${min}年` | ||
| 409 | + validationHint.value = '' | ||
| 410 | + return false | ||
| 411 | + } | ||
| 412 | + | ||
| 413 | + if (num > max) { | ||
| 414 | + isValid.value = false | ||
| 415 | + validationError.value = `年期不能大于${max}年` | ||
| 416 | + validationHint.value = '' | ||
| 417 | + return false | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + // 自定义验证器(预留扩展) | ||
| 421 | + if (rules.custom_validators && rules.custom_validators.length > 0) { | ||
| 422 | + for (const validator of rules.custom_validators) { | ||
| 423 | + const result = validator(value) | ||
| 424 | + if (result.valid === false) { | ||
| 425 | + isValid.value = false | ||
| 426 | + validationError.value = result.error || '格式不正确' | ||
| 427 | + validationHint.value = '' | ||
| 428 | + return false | ||
| 429 | + } | ||
| 430 | + } | ||
| 431 | + } | ||
| 432 | + | ||
| 433 | + // 验证通过 | ||
| 434 | + isValid.value = true | ||
| 435 | + validationError.value = '' | ||
| 436 | + validationHint.value = '' | ||
| 437 | + return true | ||
| 438 | +} | ||
| 439 | + | ||
| 440 | +/** | ||
| 441 | + * 确认输入 | ||
| 442 | + */ | ||
| 443 | +const onConfirm = () => { | ||
| 444 | + if (!canConfirm.value) return | ||
| 445 | + | ||
| 446 | + let finalValue = inputValue.value | ||
| 447 | + | ||
| 448 | + // 如果是纯数字,添加"年"单位 | ||
| 449 | + const num = Number(inputValue.value) | ||
| 450 | + if (!Number.isNaN(num) && Number.isInteger(num)) { | ||
| 451 | + finalValue = `${num}年` | ||
| 452 | + } | ||
| 453 | + | ||
| 454 | + emit('update:modelValue', finalValue) | ||
| 455 | + emit('confirm', finalValue) | ||
| 456 | + showInputModal.value = false | ||
| 457 | +} | ||
| 458 | + | ||
| 459 | +/** | ||
| 460 | + * 取消输入 | ||
| 461 | + */ | ||
| 462 | +const onCancel = () => { | ||
| 463 | + inputValue.value = '' | ||
| 464 | + isValid.value = false | ||
| 465 | + showInputModal.value = false | ||
| 466 | + emit('cancel') | ||
| 467 | +} | ||
| 468 | +</script> | ||
| 469 | + | ||
| 470 | +<style lang="less" scoped> | ||
| 471 | +/* 组件样式(如有需要可在此补充 TailwindCSS 无法覆盖的样式) */ | ||
| 472 | +</style> |
| ... | @@ -39,17 +39,30 @@ | ... | @@ -39,17 +39,30 @@ |
| 39 | * | 39 | * |
| 40 | * @description 使用 NutUI Picker 实现下拉选择功能 | 40 | * @description 使用 NutUI Picker 实现下拉选择功能 |
| 41 | * - key 和 value 相同(如"整付(0-75 岁)") | 41 | * - key 和 value 相同(如"整付(0-75 岁)") |
| 42 | - * - 适用于缴费年期等场景 | 42 | + * - 适用于缴费年期、提取期等场景 |
| 43 | * - 使用 GlobalPopupManager 管理弹窗层级 | 43 | * - 使用 GlobalPopupManager 管理弹窗层级 |
| 44 | + * - 支持自定义输入选项(可选功能) | ||
| 44 | * @author Claude Code | 45 | * @author Claude Code |
| 45 | - * @version 2.0.0 - 支持全局弹窗管理器 | 46 | + * @version 2.1.0 - 新增自定义输入支持 |
| 46 | * @example | 47 | * @example |
| 48 | + * // 基础用法 | ||
| 47 | * <SelectPickerGlobal | 49 | * <SelectPickerGlobal |
| 48 | * v-model="paymentPeriod" | 50 | * v-model="paymentPeriod" |
| 49 | * label="缴费年期" | 51 | * label="缴费年期" |
| 50 | * placeholder="请选择缴费年期" | 52 | * placeholder="请选择缴费年期" |
| 51 | * :options="['整付(0-75 岁)', '5 年(0-70 岁)']" | 53 | * :options="['整付(0-75 岁)', '5 年(0-70 岁)']" |
| 52 | * /> | 54 | * /> |
| 55 | + * | ||
| 56 | + * @example | ||
| 57 | + * // 支持自定义输入 | ||
| 58 | + * <SelectPickerGlobal | ||
| 59 | + * v-model="withdrawalPeriod" | ||
| 60 | + * label="提取期" | ||
| 61 | + * placeholder="请选择提取期" | ||
| 62 | + * :options="['1年', '5年', '10年', '终身']" | ||
| 63 | + * :allow-custom="true" | ||
| 64 | + * @custom-select="openCustomInput" | ||
| 65 | + * /> | ||
| 53 | */ | 66 | */ |
| 54 | import { ref, computed, onMounted } from 'vue' | 67 | import { ref, computed, onMounted } from 'vue' |
| 55 | import IconFont from '@/components/icons/IconFont.vue' | 68 | import IconFont from '@/components/icons/IconFont.vue' |
| ... | @@ -121,6 +134,35 @@ const props = defineProps({ | ... | @@ -121,6 +134,35 @@ const props = defineProps({ |
| 121 | options: { | 134 | options: { |
| 122 | type: Array, | 135 | type: Array, |
| 123 | required: true | 136 | required: true |
| 137 | + }, | ||
| 138 | + | ||
| 139 | + /** | ||
| 140 | + * 是否允许自定义输入 | ||
| 141 | + * @type {boolean} | ||
| 142 | + * @description 开启后,选项列表末尾会添加"自定义输入"选项 | ||
| 143 | + */ | ||
| 144 | + allowCustom: { | ||
| 145 | + type: Boolean, | ||
| 146 | + default: false | ||
| 147 | + }, | ||
| 148 | + | ||
| 149 | + /** | ||
| 150 | + * 自定义选项的显示文本 | ||
| 151 | + * @type {string} | ||
| 152 | + */ | ||
| 153 | + customLabel: { | ||
| 154 | + type: String, | ||
| 155 | + default: '📝 自定义输入...' | ||
| 156 | + }, | ||
| 157 | + | ||
| 158 | + /** | ||
| 159 | + * 分隔符文本 | ||
| 160 | + * @type {string} | ||
| 161 | + * @description 自定义选项与标准选项之间的分隔线 | ||
| 162 | + */ | ||
| 163 | + dividerLabel: { | ||
| 164 | + type: String, | ||
| 165 | + default: '──────────' | ||
| 124 | } | 166 | } |
| 125 | }) | 167 | }) |
| 126 | 168 | ||
| ... | @@ -143,7 +185,12 @@ const emit = defineEmits([ | ... | @@ -143,7 +185,12 @@ const emit = defineEmits([ |
| 143 | * 弹窗关闭事件 | 185 | * 弹窗关闭事件 |
| 144 | * @event close | 186 | * @event close |
| 145 | */ | 187 | */ |
| 146 | - 'close' | 188 | + 'close', |
| 189 | + /** | ||
| 190 | + * 用户选择自定义输入选项 | ||
| 191 | + * @event custom-select | ||
| 192 | + */ | ||
| 193 | + 'custom-select' | ||
| 147 | ]) | 194 | ]) |
| 148 | 195 | ||
| 149 | /** | 196 | /** |
| ... | @@ -167,15 +214,34 @@ const openPicker = () => { | ... | @@ -167,15 +214,34 @@ const openPicker = () => { |
| 167 | /** | 214 | /** |
| 168 | * 转换为 Picker 格式 | 215 | * 转换为 Picker 格式 |
| 169 | * @description 将选项数组转换为 Picker 需要的格式 | 216 | * @description 将选项数组转换为 Picker 需要的格式 |
| 217 | + * 如果允许自定义,会在末尾添加分隔符和自定义选项 | ||
| 170 | * @example | 218 | * @example |
| 171 | * // options = ['整付(0-75 岁)', '5 年(0-70 岁)'] | 219 | * // options = ['整付(0-75 岁)', '5 年(0-70 岁)'] |
| 172 | * // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...] | 220 | * // pickerColumns() // 返回: [{ text: '整付(0-75 岁)', value: '整付(0-75 岁)' }, ...] |
| 173 | */ | 221 | */ |
| 174 | const pickerColumns = computed(() => { | 222 | const pickerColumns = computed(() => { |
| 175 | - return props.options.map(option => ({ | 223 | + const standardOptions = props.options.map(option => ({ |
| 176 | text: option, | 224 | text: option, |
| 177 | value: option // key 和 value 相同 | 225 | value: option // key 和 value 相同 |
| 178 | })) | 226 | })) |
| 227 | + | ||
| 228 | + // 如果允许自定义,添加分隔符和自定义选项 | ||
| 229 | + if (props.allowCustom) { | ||
| 230 | + return [ | ||
| 231 | + ...standardOptions, | ||
| 232 | + { | ||
| 233 | + text: props.dividerLabel, | ||
| 234 | + value: '__divider__', | ||
| 235 | + disabled: true // 分隔符不可选 | ||
| 236 | + }, | ||
| 237 | + { | ||
| 238 | + text: props.customLabel, | ||
| 239 | + value: '__custom__' | ||
| 240 | + } | ||
| 241 | + ] | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + return standardOptions | ||
| 179 | }) | 245 | }) |
| 180 | 246 | ||
| 181 | /** | 247 | /** |
| ... | @@ -197,6 +263,27 @@ const displayValue = computed(() => { | ... | @@ -197,6 +263,27 @@ const displayValue = computed(() => { |
| 197 | */ | 263 | */ |
| 198 | const onConfirm = ({ selectedOptions }) => { | 264 | const onConfirm = ({ selectedOptions }) => { |
| 199 | const value = selectedOptions[0]?.value | 265 | const value = selectedOptions[0]?.value |
| 266 | + | ||
| 267 | + // 处理自定义选项 | ||
| 268 | + if (value === '__custom__') { | ||
| 269 | + // 触发自定义选择事件,由父组件处理 | ||
| 270 | + emit('custom-select') | ||
| 271 | + | ||
| 272 | + // 停用弹窗 | ||
| 273 | + if (popupId.value) { | ||
| 274 | + deactivatePopup(popupId.value) | ||
| 275 | + } | ||
| 276 | + showPicker.value = false | ||
| 277 | + emit('close') | ||
| 278 | + return | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + // 跳过分隔符 | ||
| 282 | + if (value === '__divider__') { | ||
| 283 | + return | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + // 标准选项处理 | ||
| 200 | if (value !== undefined) { | 287 | if (value !== undefined) { |
| 201 | emit('update:modelValue', value) | 288 | emit('update:modelValue', value) |
| 202 | } | 289 | } | ... | ... |
| ... | @@ -92,7 +92,9 @@ | ... | @@ -92,7 +92,9 @@ |
| 92 | label="提取期" | 92 | label="提取期" |
| 93 | placeholder="请选择提取期" | 93 | placeholder="请选择提取期" |
| 94 | :required="true" | 94 | :required="true" |
| 95 | - :options="multiStagePeriodOptions" | 95 | + :options="dynamicPeriodOptions" |
| 96 | + :allow-custom="isCustomPeriodEnabled" | ||
| 97 | + @custom-select="openPeriodInput(index)" | ||
| 96 | class="mb-3" | 98 | class="mb-3" |
| 97 | /> | 99 | /> |
| 98 | 100 | ||
| ... | @@ -142,6 +144,17 @@ | ... | @@ -142,6 +144,17 @@ |
| 142 | <p>⚠️ 模板配置未找到</p> | 144 | <p>⚠️ 模板配置未找到</p> |
| 143 | <p class="text-sm mt-2">请检查产品配置或联系开发人员</p> | 145 | <p class="text-sm mt-2">请检查产品配置或联系开发人员</p> |
| 144 | </div> | 146 | </div> |
| 147 | + | ||
| 148 | + <!-- 自定义提取期输入弹窗 --> | ||
| 149 | + <PeriodInput | ||
| 150 | + v-model:visible="showPeriodInput" | ||
| 151 | + v-model="currentPeriodValue" | ||
| 152 | + inputLabel="请输入提取期" | ||
| 153 | + inputPlaceholder="请输入年数" | ||
| 154 | + :validation-rules="periodValidationRules" | ||
| 155 | + @confirm="onPeriodInputConfirm" | ||
| 156 | + @cancel="onPeriodInputCancel" | ||
| 157 | + /> | ||
| 145 | </template> | 158 | </template> |
| 146 | 159 | ||
| 147 | <script setup> | 160 | <script setup> |
| ... | @@ -167,6 +180,7 @@ import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue' | ... | @@ -167,6 +180,7 @@ import PlanFieldDatePicker from '../PlanFields/DatePickerGlobal.vue' |
| 167 | import PlanFieldRadio from '../PlanFields/RadioGroup.vue' | 180 | import PlanFieldRadio from '../PlanFields/RadioGroup.vue' |
| 168 | import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue' | 181 | import PlanFieldSelect from '../PlanFields/SelectPickerGlobal.vue' |
| 169 | import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue' | 182 | import PaymentPeriodRadio from '../PlanFields/PaymentPeriodRadio.vue' |
| 183 | +import PeriodInput from '../PlanFields/PeriodInput.vue' | ||
| 170 | import { useFieldDependencies } from '@/composables/useFieldDependencies' | 184 | import { useFieldDependencies } from '@/composables/useFieldDependencies' |
| 171 | 185 | ||
| 172 | /** | 186 | /** |
| ... | @@ -387,6 +401,45 @@ const canRemoveStage = computed(() => { | ... | @@ -387,6 +401,45 @@ const canRemoveStage = computed(() => { |
| 387 | }) | 401 | }) |
| 388 | 402 | ||
| 389 | /** | 403 | /** |
| 404 | + * 自定义提取期输入状态 | ||
| 405 | + */ | ||
| 406 | +const showPeriodInput = ref(false) // 自定义输入弹窗显示状态 | ||
| 407 | +const currentPeriodValue = ref('') // 当前输入的提取期值 | ||
| 408 | +const currentStageIndex = ref(-1) // 当前正在编辑的阶段索引 | ||
| 409 | +const customPeriodValues = ref([]) // 用户自定义的提取期值列表(临时保存) | ||
| 410 | + | ||
| 411 | +/** | ||
| 412 | + * 自定义提取期是否启用 | ||
| 413 | + */ | ||
| 414 | +const isCustomPeriodEnabled = computed(() => { | ||
| 415 | + return multiStageConfig.value.custom_period?.enabled || false | ||
| 416 | +}) | ||
| 417 | + | ||
| 418 | +/** | ||
| 419 | + * 自定义提取期验证规则 | ||
| 420 | + */ | ||
| 421 | +const periodValidationRules = computed(() => { | ||
| 422 | + const config = multiStageConfig.value.custom_period?.validation || {} | ||
| 423 | + return { | ||
| 424 | + min: config.min_years ?? 1, | ||
| 425 | + max: config.max_years ?? 100, | ||
| 426 | + allowed_formats: config.allowed_formats || ['终身', '一笔过'], | ||
| 427 | + custom_validators: config.custom_validators || [] | ||
| 428 | + } | ||
| 429 | +}) | ||
| 430 | + | ||
| 431 | +/** | ||
| 432 | + * 动态提取期选项(预设选项 + 用户自定义选项) | ||
| 433 | + */ | ||
| 434 | +const dynamicPeriodOptions = computed(() => { | ||
| 435 | + const baseOptions = multiStageConfig.value.withdrawal_periods || | ||
| 436 | + props.config?.withdrawal_plan?.withdrawal_periods || | ||
| 437 | + [] | ||
| 438 | + // 合并预设选项和用户自定义选项 | ||
| 439 | + return [...baseOptions, ...customPeriodValues.value] | ||
| 440 | +}) | ||
| 441 | + | ||
| 442 | +/** | ||
| 390 | * 创建空的阶段数据 | 443 | * 创建空的阶段数据 |
| 391 | * @returns {Object} 空阶段对象 | 444 | * @returns {Object} 空阶段对象 |
| 392 | */ | 445 | */ |
| ... | @@ -445,6 +498,50 @@ const removeStage = (index) => { | ... | @@ -445,6 +498,50 @@ const removeStage = (index) => { |
| 445 | } | 498 | } |
| 446 | 499 | ||
| 447 | /** | 500 | /** |
| 501 | + * 打开自定义提取期输入弹窗 | ||
| 502 | + * @param {number} stageIndex - 阶段索引 | ||
| 503 | + */ | ||
| 504 | +const openPeriodInput = (stageIndex) => { | ||
| 505 | + currentStageIndex.value = stageIndex | ||
| 506 | + const stage = stages.value[stageIndex] | ||
| 507 | + currentPeriodValue.value = stage?.withdrawal_period || '' | ||
| 508 | + showPeriodInput.value = true | ||
| 509 | +} | ||
| 510 | + | ||
| 511 | +/** | ||
| 512 | + * 自定义提取期输入确认 | ||
| 513 | + * @param {string} value - 确认的提取期值 | ||
| 514 | + */ | ||
| 515 | +const onPeriodInputConfirm = (value) => { | ||
| 516 | + // 添加到自定义值列表(去重:不在预设选项和已存在的自定义列表中) | ||
| 517 | + const baseOptions = multiStageConfig.value.withdrawal_periods || | ||
| 518 | + props.config?.withdrawal_plan?.withdrawal_periods || | ||
| 519 | + [] | ||
| 520 | + if (!baseOptions.includes(value) && !customPeriodValues.value.includes(value)) { | ||
| 521 | + customPeriodValues.value.push(value) | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + // 更新当前阶段的值 | ||
| 525 | + if (currentStageIndex.value >= 0 && currentStageIndex.value < stages.value.length) { | ||
| 526 | + stages.value[currentStageIndex.value].withdrawal_period = value | ||
| 527 | + } | ||
| 528 | + | ||
| 529 | + // 关闭弹窗并重置状态 | ||
| 530 | + showPeriodInput.value = false | ||
| 531 | + currentStageIndex.value = -1 | ||
| 532 | + currentPeriodValue.value = '' | ||
| 533 | +} | ||
| 534 | + | ||
| 535 | +/** | ||
| 536 | + * 自定义提取期输入取消 | ||
| 537 | + */ | ||
| 538 | +const onPeriodInputCancel = () => { | ||
| 539 | + showPeriodInput.value = false | ||
| 540 | + currentStageIndex.value = -1 | ||
| 541 | + currentPeriodValue.value = '' | ||
| 542 | +} | ||
| 543 | + | ||
| 544 | +/** | ||
| 448 | * 同步阶段数据到表单 | 545 | * 同步阶段数据到表单 |
| 449 | * @description 将 stages 数组同步到 form.withdrawal_stages,以便父组件获取 | 546 | * @description 将 stages 数组同步到 form.withdrawal_stages,以便父组件获取 |
| 450 | * 同时清理 undefined 值为 null,确保提交数据格式正确 | 547 | * 同时清理 undefined 值为 null,确保提交数据格式正确 | ... | ... |
| ... | @@ -123,6 +123,7 @@ const savingsFormSchema = { | ... | @@ -123,6 +123,7 @@ const savingsFormSchema = { |
| 123 | * 多阶段提取计划配置 | 123 | * 多阶段提取计划配置 |
| 124 | * @description 用于"宏挚传承保障计划(多阶段)"等支持多阶段提取的产品 | 124 | * @description 用于"宏挚传承保障计划(多阶段)"等支持多阶段提取的产品 |
| 125 | * @updated 2026-02-25 - 新增多阶段提取功能 | 125 | * @updated 2026-02-25 - 新增多阶段提取功能 |
| 126 | + * @updated 2026-02-28 - 新增自定义提取期输入功能 | ||
| 126 | */ | 127 | */ |
| 127 | const multiStageWithdrawalConfig = { | 128 | const multiStageWithdrawalConfig = { |
| 128 | enabled: true, | 129 | enabled: true, |
| ... | @@ -133,7 +134,20 @@ const multiStageWithdrawalConfig = { | ... | @@ -133,7 +134,20 @@ const multiStageWithdrawalConfig = { |
| 133 | '10年', '15年', '20年', '终身', | 134 | '10年', '15年', '20年', '终身', |
| 134 | '一笔过' // 新增:一次性提取选项 | 135 | '一笔过' // 新增:一次性提取选项 |
| 135 | ], | 136 | ], |
| 136 | - percentage_optional: true // 递增百分比可选 | 137 | + percentage_optional: true, // 递增百分比可选 |
| 138 | + | ||
| 139 | + // 自定义提取期配置(新增) | ||
| 140 | + custom_period: { | ||
| 141 | + enabled: true, // 是否允许自定义输入 | ||
| 142 | + custom_label: '📝 自定义输入...', // 自定义选项显示文本 | ||
| 143 | + validation: { | ||
| 144 | + min_years: 1, // 最小年期(整数) | ||
| 145 | + max_years: 100, // 最大年期(整数) | ||
| 146 | + allowed_formats: ['终身', '一笔过'], // 允许的非年期格式 | ||
| 147 | + // 预留:未来可扩展更多格式 | ||
| 148 | + custom_validators: [] // 自定义验证函数数组 | ||
| 149 | + } | ||
| 150 | + } | ||
| 137 | } | 151 | } |
| 138 | 152 | ||
| 139 | export const PLAN_TEMPLATES = { | 153 | export const PLAN_TEMPLATES = { | ... | ... |
-
Please register or login to post a comment