You need to sign in or sign up before continuing.
hookehuyr

refactor(ui): 清理未使用和重复的组件

- 删除未使用组件: indexNav, qrCodeSearch, time-picker-data, PosterBuilder
- 删除旧版计划组件: PlanSchemes (SchemeA, SchemeB, PlanPopup)
- 删除重复组件: 旧版 SavingsTemplate 和 AmountInput
- 统一使用 AmountKeyboard 作为金额输入组件
- 总计删除 13 个文件,约 1500-2000 行代码
......@@ -64,7 +64,16 @@
"Bash(npm run dev:weapp:*)",
"Bash(__NEW_LINE_19c6a134b9496225__ echo \"✅ 已删除不再使用的 Apifox 相关脚本\")",
"Bash(cat:*)",
"Bash(pkill:*)"
"Bash(pkill:*)",
"Bash(then echo \" ❌ 仍然存在\")",
"Bash(else echo \" ✅ 已成功删除\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"2. qrCodeSearch.vue\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"3. time-picker-data 目录\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"4. PosterBuilder 目录\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"5. PlanSchemes 目录\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"6. 根目录 SavingsTemplate.vue\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"7. PlanFields/AmountInput.vue\")",
"Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"\")"
]
},
"enableAllProjectMcpServers": true,
......
<template>
<div>
<!-- 标签 -->
<div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
<span v-if="required" class="text-red-500 mr-1">*</span>
<span>{{ label }}</span>
<span v-if="currencyText" class="text-gray-500">{{ currencyText }}</span>
</div>
<!-- 多币种模式(方案 2 - 未来扩展) -->
<div v-if="multiCurrencyEnabled" class="mb-2">
<div class="text-sm text-gray-600 mb-2">币种</div>
<div class="flex gap-2">
<button
v-for="curr in supportedCurrencies"
:key="curr.value"
:class="[
'px-4 py-2 rounded-lg text-sm border transition-colors',
selectedCurrency === curr.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200'
]"
@tap="selectCurrency(curr.value)"
>
{{ curr.label }}
</button>
</div>
</div>
<!-- 保额输入 -->
<div class="border border-gray-200 rounded-lg flex items-center overflow-hidden bg-gray-50">
<nut-input
:model-value="inputValue"
@input="onInput"
@blur="onBlur"
type="digit"
:placeholder="placeholder"
class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
:border="false"
:cursorSpacing="80"
/>
<span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">{{ currencySymbol }}</span>
</div>
</div>
</template>
<script setup>
/**
* 保额输入组件
*
* @description 支持多币种的保额输入组件
* - 单位转换:内部存储为分(整数),显示为元(带2位小数)
* - 币种支持:CNY、USD、HKD、EUR
* - 多币种模式:通过 FEATURE_FLAGS.MULTI_CURRENCY_ENABLED 控制
* @author Claude Code
* @example
* <!-- 固定币种模式 -->
* <AmountInput
* v-model="coverage"
* label="保额"
* currency="USD"
* placeholder="请输入保额"
* />
*
* @example
* <!-- 多币种模式 -->
* <AmountInput
* v-model="coverage"
* label="保额"
* :config="{ supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }"
* placeholder="请输入保额"
* />
*/
import { ref, computed, watch } from 'vue'
import { FEATURE_FLAGS, CURRENCY_SYMBOLS, CURRENCY_MAP } from '@/config/plan-templates'
/**
* 组件属性
*/
const props = defineProps({
/**
* 标签文本
* @type {string}
*/
label: {
type: String,
default: ''
},
/**
* 是否必填
* @type {boolean}
*/
required: {
type: Boolean,
default: false
},
/**
* 占位符文本
* @type {string}
*/
placeholder: {
type: String,
default: '请输入保额'
},
/**
* 绑定的值(单位:分)
* @type {number}
* @example 100000 表示 1000.00 元
*/
modelValue: {
type: Number,
default: null
},
/**
* 币种代码(固定币种模式)
* @type {string}
* @default 'CNY'
*/
currency: {
type: String,
default: 'CNY'
},
/**
* 模版配置(多币种模式)
* @type {Object}
* @property {Array<string>} supported_currencies - 支持的币种代码数组
* @property {string} default_currency - 默认币种代码
* @example { supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }
*/
config: {
type: Object,
default: () => ({})
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新值事件
* @event update:modelValue
* @param {number} value - 保额值(单位:分)
*/
'update:modelValue'
])
/**
* 判断是否启用多币种
* @type {ComputedRef<boolean>}
*/
const multiCurrencyEnabled = computed(() => FEATURE_FLAGS.MULTI_CURRENCY_ENABLED)
/**
* 当前选中的币种
* @type {Ref<string>}
*/
const selectedCurrency = ref(props.config.default_currency || props.currency || 'CNY')
/**
* 支持的币种列表(多币种模式)
* @type {ComputedRef<Array<{label: string, symbol: string, value: string}>>}
*/
const supportedCurrencies = computed(() => {
if (!multiCurrencyEnabled.value) return []
return (props.config.supported_currencies || ['CNY'])
.map(code => CURRENCY_MAP[code])
.filter(Boolean)
})
/**
* 当前币种符号
* @type {ComputedRef<string>}
* @example
* // CNY -> '¥'
* // USD -> '$'
*/
const currencySymbol = computed(() => {
if (multiCurrencyEnabled.value) {
// 多币种模式:使用用户选择的币种
const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
return curr?.symbol || '¥'
}
// 固定币种模式:使用 props.currency
return CURRENCY_SYMBOLS[props.currency] || '¥'
})
/**
* 币种文本(用于标签显示)
* @type {ComputedRef<string>}
*/
const currencyText = computed(() => {
if (multiCurrencyEnabled.value) {
const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
return curr?.label || ''
}
const CURRENCY_NAMES = {
CNY: '人民币',
USD: '美元',
HKD: '港币',
EUR: '欧元'
}
return CURRENCY_NAMES[props.currency] || ''
})
/**
* 内部显示值(元)
* @type {Ref<string>}
*/
const inputValue = ref('')
/**
* 监听 modelValue 变化,同步到内部 inputValue
* 仅当外部值与当前输入值解析结果不一致时才同步,避免输入过程中被格式化打断
*/
watch(
() => props.modelValue,
(newVal) => {
// 如果输入框有内容且用户正在输入,不覆盖显示值
if (inputValue.value && inputValue.value !== '0.00') {
// 解析当前显示值为分
const currentCents = Math.round(parseFloat(inputValue.value || '0') * 100)
// 如果外部值与当前输入值一致,说明是用户输入触发的更新,不需要重新格式化
if (newVal === currentCents) {
return
}
}
// 外部值改变(如重置、从其他地方更新),需要同步显示值
if (newVal === null || newVal === undefined) {
inputValue.value = '0.00'
} else if (newVal === 0) {
inputValue.value = '0.00'
} else {
// 分 -> 元,显示格式
const yuan = newVal / 100
// 判断是否为整数
if (Number.isInteger(yuan)) {
// 整数,不添加小数点
inputValue.value = yuan.toString()
} else {
// 有小数,保留原样
inputValue.value = yuan.toString()
}
}
},
{ immediate: true }
)
/**
* 用户输入处理
* @description 将用户输入的元转换为分存储
* @param {string|number|Object} val - 输入值
*/
const onInput = (val) => {
let value = val
// 防御性处理:如果接收到的是事件对象
if (typeof val === 'object' && val !== null) {
if (val.detail && typeof val.detail.value !== 'undefined') {
// 小程序原生事件
value = val.detail.value
} else if (val.target && typeof val.target.value !== 'undefined') {
// Web 原生事件
value = val.target.value
} else {
// 无法提取值,直接返回,避免 [object Object]
return
}
}
// 确保 value 为字符串
const valStr = String(value)
// 移除非数字和小数点(安全处理)
const cleanValue = valStr.replace(/[^\d.]/g, '')
// 如果输入为空或只有小数点,显示 0.00 并重置值为 0
if (cleanValue === '' || cleanValue === '.') {
inputValue.value = '0.00'
emit('update:modelValue', 0)
return
}
// 更新内部显示值(保持用户原始输入,不自动添加小数点)
inputValue.value = valStr
// 转换为分(整数)
const yuan = parseFloat(cleanValue)
if (!Number.isNaN(yuan)) {
emit('update:modelValue', Math.round(yuan * 100))
} else {
emit('update:modelValue', 0)
}
}
/**
* 失去焦点时格式化
*/
const onBlur = () => {
if (props.modelValue !== null && props.modelValue !== undefined) {
inputValue.value = (props.modelValue / 100).toFixed(2)
}
}
/**
* 选择币种(多币种模式)
* @param {string} value - 币种代码
*/
const selectCurrency = (value) => {
selectedCurrency.value = value
}
</script>
<style lang="less">
/* 组件样式 */
</style>
# 录入计划书方案开发规范
> **版本**: v1.0
> **更新日期**: 2026-01-31
> **用途**: 指导新增计划书方案(SchemeC、SchemeD 等)的开发
## 目录
- [组件结构规范](#组件结构规范)
- [表单字段规范](#表单字段规范)
- [样式规范](#样式规范)
- [交互规范](#交互规范)
- [代码模板](#代码模板)
- [检查清单](#检查清单)
---
## 组件结构规范
### 基础结构
所有方案组件必须遵循以下三层结构:
```vue
<template>
<div class="flex flex-col h-full bg-gray-50">
<!-- 1. 顶部标题栏 -->
<div class="flex justify-between items-center px-5 py-5 bg-white rounded-t-xl">
<span class="text-lg font-normal text-gray-900">{{ title }}</span>
<IconFont name="close" size="16" color="#9CA3AF" @click="close" />
</div>
<!-- 2. 滚动内容区 -->
<div class="flex-1 overflow-y-auto p-4">
<div class="bg-white rounded-xl p-5 shadow-sm">
<!-- 表单字段 -->
</div>
</div>
<!-- 3. 底部按钮区 -->
<div class="p-4 pt-2 pb-8 flex justify-between gap-4 bg-gray-50">
<div class="flex-1 py-3 text-center border border-blue-600 text-blue-600 rounded-lg text-base bg-white"
@click="close">
取消
</div>
<div class="flex-1 py-3 text-center bg-blue-600 text-white rounded-lg text-base" @click="submit">
提交申请
</div>
</div>
</div>
</template>
```
### Props 定义
```javascript
const props = defineProps({
title: {
type: String,
default: '申请计划书'
}
});
```
### Emits 定义
```javascript
const emit = defineEmits(['close', 'submit']);
```
---
## 表单字段规范
### 1. 标准文本输入框
**适用场景**: 客户姓名、年龄、金额等
```vue
<div class="text-sm text-gray-600 mb-2">字段标签</div>
<div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
<nut-input
v-model="form.fieldName"
type="text"
placeholder="请输入..."
class="!p-0 !bg-transparent !text-sm !text-gray-900"
:border="false"
/>
</div>
```
**关键样式**:
- 容器: `border border-gray-200 rounded-lg mb-4 overflow-hidden`
- 输入框: `!p-0 !bg-transparent !text-sm !text-gray-900`
- 数字类型: 使用 `type="digit"`
### 2. 带单位的输入框
**适用场景**: 年收入区间、期望收益率、年交保费等
```vue
<div class="text-sm text-gray-600 mb-2">字段标签</div>
<div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
<nut-input
v-model="form.fieldName"
type="digit"
placeholder="请输入..."
class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
:border="false"
/>
<span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">单位</span>
</div>
```
**关键点**:
- 输入框: `flex-1` (占据剩余空间)
- 单位文字: `shrink-0 ml-2` (防止被挤压)
- 可选: `mr-5` (右侧留白,可选)
### 3. 下拉选择框
**适用场景**: 行业选择等需要 Picker 的场景
```vue
<div class="text-sm text-gray-600 mb-2">字段标签</div>
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3 mb-4 overflow-hidden"
@click="showPicker = true"
>
<span :class="form.fieldName ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ form.fieldName || '请选择...' }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- Picker Popup -->
<nut-popup position="bottom" v-model:visible="showPicker">
<nut-picker
:columns="pickerColumns"
title="选择标题"
@confirm="confirmPicker"
@cancel="showPicker = false"
/>
</nut-popup>
```
**关键点**:
- 容器保留 `p-3` 内边距(因为有可点击内容)
- 右侧箭头图标: `IconFont name="right"`
- 占位文字颜色: `text-gray-400`
- 已选文字颜色: `text-gray-900`
### 4. 单选字段
**适用场景**: 性别、交费期间等
```vue
<div class="text-sm text-gray-600 mb-2">字段标签</div>
<nut-radio-group v-model="form.fieldName" direction="horizontal" class="mb-4">
<nut-radio
v-for="option in options"
:key="option.value"
:label="option.value"
class="mr-8"
>
{{ option.label }}
</nut-radio>
</nut-radio-group>
```
**关键点**:
- `direction="horizontal"` - 水平排列
- `class="mb-4"` - 底部间距
- `class="mr-8"` - 选项间距(两个选项)
- `class="mr-6"` - 选项间距(三个及以上选项)
### 5. 多选字段
**适用场景**: 家庭结构、保险需求等
```vue
<div class="text-sm text-gray-600 mb-3">字段标签(多选)</div>
<div class="flex flex-wrap gap-3 mb-5">
<div
v-for="option in options"
:key="option.value"
class="px-4 py-2 rounded-lg text-sm cursor-pointer transition-colors border"
:class="form.fieldName.includes(option.value) ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200'"
@click="toggleSelection('fieldName', option.value)"
>
{{ option.label }}
</div>
</div>
```
**关键点**:
- 标签使用 `mb-3`,容器使用 `mb-5`
- `flex flex-wrap gap-3` - 自动换行
- `cursor-pointer transition-colors` - 交互效果
- 选中状态: `bg-blue-600 text-white border-blue-600`
- 未选中状态: `bg-gray-50 text-gray-600 border-gray-200`
**多选切换逻辑**:
```javascript
const toggleSelection = (field, value) => {
const index = form[field].indexOf(value);
if (index === -1) {
form[field].push(value); // 添加选中
} else {
form[field].splice(index, 1); // 取消选中
}
};
```
### 6. 只读展示字段
**适用场景**: 币种、计划、保险期间等固定值展示
```vue
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">字段标签</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">显示值</span>
</div>
</div>
```
**关键点**:
- `flex justify-between items-start` - 标签和内容两端对齐
- 标签: `text-sm text-gray-600 mt-1.5` (顶部对齐)
- 内容: `bg-blue-50 rounded-md px-3 py-1.5`
- 文字: `text-sm text-blue-600`
---
## 样式规范
### 颜色系统
| 用途 | 颜色值 | Tailwind 类 |
|------|--------|-------------|
| **主色** | #2563EB | `blue-600` |
| **标签文字** | #4B5563 | `gray-600` |
| **输入文字** | #111827 | `gray-900` |
| **占位文字** | #9CA3AF | `gray-400` |
| **边框** | #E5E7EB | `gray-200` |
| **背景** | #F9FAFB | `gray-50` |
| **图标** | #9CA3AF | `gray-400` |
### 间距系统
| 位置 | 间距 | Tailwind 类 |
|------|------|-------------|
| **标签与输入框** | 8px | `mb-2` |
| **输入框底部** | 16px | `mb-4` |
| **多选按钮底部** | 20px | `mb-5` |
| **单选选项间距** | 32px | `mr-8` |
| **多选按钮间距** | 12px | `gap-3` |
| **顶部标题栏** | 20px | `px-5 py-5` |
| **内容卡片** | 20px | `p-5` |
### 圆角系统
| 元素 | 圆角 | Tailwind 类 |
|------|------|-------------|
| **卡片** | 12px | `rounded-xl` |
| **输入框/按钮** | 8px | `rounded-lg` |
| **标签** | 6px | `rounded-md` |
### 文字大小
| 用途 | 大小 | Tailwind 类 |
|------|------|-------------|
| **标题** | 18px | `text-lg` |
| **正文** | 14px | `text-sm` |
| **按钮** | 16px | `text-base` |
---
## 交互规范
### 全局样式覆盖
所有方案组件必须包含以下样式:
```vue
<style lang="less" scoped>
/* Override NutUI input styles to match design */
:deep(.nut-input) {
padding: 0;
background: transparent;
border-radius: inherit;
}
</style>
```
### 图标使用
**关闭图标**:
```vue
<IconFont name="close" size="16" color="#9CA3AF" @click="close" />
```
**右侧箭头**:
```vue
<IconFont name="right" size="14" color="#9CA3AF" />
```
**提示图标**:
```vue
<IconFont name="tips" size="14" color="#9CA3AF" />
```
### 数据结构
#### Reactive 表单对象
```javascript
const form = reactive({
// 文本字段
textField: '',
// 数字字段
numberField: '',
// 单选字段
radioField: 'defaultValue',
// 多选字段
multiSelectField: [],
// 带单位字段
amountField: ''
});
```
#### 选项数据结构
**单选选项**:
```javascript
const options = [
{ label: '显示文本', value: 'value1' },
{ label: '显示文本', value: 'value2' }
];
```
**多选选项**:
```javascript
const options = [
{ label: '显示文本', value: 'value1' },
{ label: '显示文本', value: 'value2' }
];
```
**Picker 数据**:
```javascript
const pickerColumns = [
{ text: '显示文本', value: 'value1' },
{ text: '显示文本', value: 'value2' }
];
```
---
## 代码模板
### 完整组件模板
```vue
<template>
<div class="flex flex-col h-full bg-gray-50">
<!-- Header -->
<div class="flex justify-between items-center px-5 py-5 bg-white rounded-t-xl">
<span class="text-lg font-normal text-gray-900">{{ title }}</span>
<IconFont name="close" size="16" color="#9CA3AF" @click="close" />
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4">
<div class="bg-white rounded-xl p-5 shadow-sm">
<!-- 表单字段区域 -->
</div>
</div>
<!-- Footer -->
<div class="p-4 pt-2 pb-8 flex justify-between gap-4 bg-gray-50">
<div
class="flex-1 py-3 text-center border border-blue-600 text-blue-600 rounded-lg text-base bg-white"
@click="close"
>
取消
</div>
<div
class="flex-1 py-3 text-center bg-blue-600 text-white rounded-lg text-base"
@click="submit"
>
提交申请
</div>
</div>
</div>
</template>
<script setup>
/**
* @description 录入计划书 - 方案X 内容组件
* @emits close - 关闭弹窗事件
* @emits submit - 提交事件,携带表单数据
*/
import { reactive } from 'vue';
const props = defineProps({
title: {
type: String,
default: '申请计划书'
}
});
const emit = defineEmits(['close', 'submit']);
const form = reactive({
// 定义表单字段
fieldName: ''
});
const close = () => {
emit('close');
};
const submit = () => {
console.log('Submit form:', form);
emit('submit', form);
};
</script>
<style lang="less" scoped>
/* Override NutUI input styles to match design */
:deep(.nut-input) {
padding: 0;
background: transparent;
border-radius: inherit;
}
</style>
```
---
## 检查清单
### 开发前检查
- [ ] 阅读本文档
- [ ] 确认字段类型(输入/单选/多选/下拉)
- [ ] 准备选项数据
- [ ] 确认是否有 Picker 弹窗
### 代码规范检查
- [ ] 使用 `<script setup>` 语法
- [ ] Props/Emits 定义完整
- [ ] 表单对象使用 `reactive`
- [ ] 多选逻辑使用 `toggleSelection` 函数
- [ ] 包含 JSDoc 注释
### 样式检查
- [ ] 所有输入框容器有 `overflow-hidden`
- [ ] 输入框使用 `!p-0 !bg-transparent`
- [ ] 圆角使用 `rounded-lg`
- [ ] 边框颜色使用 `border-gray-200`
- [ ] 带单位输入框使用 `flex-1``shrink-0`
### 组件检查
- [ ] 关闭按钮使用 `IconFont`
- [ ] 单选使用 `nut-radio-group`
- [ ] 多选使用自定义按钮样式
- [ ] Picker 使用 `nut-popup` + `nut-picker`
### 测试检查
- [ ] 输入框文字不贴边(有适当间距)
- [ ] 圆角显示正常
- [ ] 单选只能选中一个
- [ ] 多选可以选中多个
- [ ] Picker 弹窗正常弹出和选择
- [ ] 提交按钮触发 emit 事件
---
## 常见问题
### Q: 输入框圆角被截掉?
**A**: 确保容器添加了 `overflow-hidden`,并且样式覆盖中有 `border-radius: inherit`
### Q: 带单位的输入框,单位文字被挤压?
**A**: 确保:
- 输入框有 `flex-1`
- 单位文字有 `shrink-0`
- 可选:添加右侧留白 `mr-5`
### Q: 多选按钮样式不一致?
**A**: 复制标准模板,检查:
- 未选中状态:`bg-gray-50 text-gray-600 border-gray-200`
- 选中状态:`bg-blue-600 text-white border-blue-600`
- 必须包含:`cursor-pointer transition-colors`
### Q: Picker 弹窗不显示?
**A**: 检查:
- `v-model:visible` 绑定正确
- Picker 数据格式正确:`{ text: '', value: '' }`
- 确认事件处理正确
### Q: 单选/多选数据格式?
**A**:
- 单选:字符串 `form.field = 'value'`
- 多选:数组 `form.field = ['value1', 'value2']`
---
## 版本历史
| 版本 | 日期 | 更新内容 | 作者 |
|------|------|----------|------|
| v1.0 | 2026-01-31 | 初始版本,基于 SchemeA 和 SchemeB 总结 | Claude Code |
---
## 相关文件
- [SchemeA.vue](../src/components/PlanSchemes/SchemeA.vue) - 方案A 实现
- [SchemeB.vue](../src/components/PlanSchemes/SchemeB.vue) - 方案B 实现
- [IconFont.vue](../src/components/IconFont.vue) - 图标组件
- [项目 CLAUDE.md](../CLAUDE.md) - 项目整体规范
<template>
<div class="flex flex-col h-full bg-gray-50">
<!-- Header -->
<div class="flex justify-between items-center px-5 py-5 bg-white rounded-t-xl">
<span class="text-lg font-normal text-gray-900">{{ title }}</span>
<IconFont name="close" size="16" color="#9CA3AF" @click="handleClose" />
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-4">
<div class="bg-white rounded-xl p-5 shadow-sm">
<slot />
</div>
</div>
<!-- Footer Buttons -->
<div class="p-4 pt-2 pb-8 flex justify-between gap-3 bg-gray-50">
<nut-button
plain
type="primary"
class="flex-1 !h-auto !py-2.5 !text-sm"
@click="handleClose"
>
取消
</nut-button>
<nut-button
type="primary"
class="flex-1 !h-auto !py-2.5 !text-sm"
@click="handleSubmit"
>
提交申请
</nut-button>
</div>
</div>
</template>
<script setup>
/**
* @description 计划书弹窗容器组件
* @description 提供统一的头部、底部按钮和布局结构
*
* @props {string} title - 弹窗标题
*
* @emits close - 关闭弹窗事件
* @emits submit - 提交事件
*
* @example
* <PlanPopup title="申请计划书" @close="handleClose" @submit="handleSubmit">
* <!-- 具体的表单内容 -->
* </PlanPopup>
*/
import IconFont from '@/components/IconFont.vue'
defineProps({
/** 弹窗标题 */
title: {
type: String,
default: '计划书'
}
})
const emit = defineEmits(['close', 'submit'])
/**
* 处理关闭事件
*/
const handleClose = () => {
emit('close')
}
/**
* 处理提交事件
*/
const handleSubmit = () => {
emit('submit')
}
</script>
<style lang="less" scoped>
/* 确保 NutUI 按钮样式正确 */
:deep(.nut-button) {
border-radius: 0.5rem /* 8px */;
font-size: 1rem /* 16px */;
}
</style>
<template>
<PlanPopup title="申请计划书" @close="close" @submit="submit">
<!-- 客户姓名 -->
<div class="text-sm text-gray-600 mb-2">客户姓名</div>
<div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
<nut-input
v-model="form.name"
placeholder="请输入客户姓名"
class="!p-0 !bg-transparent !text-sm !text-gray-900"
:border="false"
/>
</div>
<!-- 性别 -->
<div class="text-sm text-gray-600 mb-2">性别</div>
<nut-radio-group v-model="form.gender" direction="horizontal" class="mb-4">
<nut-radio label="male" class="mr-8">男</nut-radio>
<nut-radio label="female">女</nut-radio>
</nut-radio-group>
<!-- 年龄 -->
<div class="text-sm text-gray-600 mb-2">年龄</div>
<div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
<nut-input
v-model="form.age"
type="digit"
placeholder="请输入年龄"
class="!p-0 !bg-transparent !text-sm !text-gray-900"
:border="false"
/>
</div>
<!-- 行业 -->
<div class="text-sm text-gray-600 mb-2">行业</div>
<div
class="flex justify-between items-center border border-gray-200 rounded-lg p-3 mb-4 overflow-hidden"
@click="showIndustryPicker = true"
>
<span :class="form.industry ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
{{ form.industry || '请选择职业' }}
</span>
<IconFont name="right" size="14" color="#9CA3AF" />
</div>
<!-- 年收入区间 -->
<div class="text-sm text-gray-600 mb-2">年收入区间</div>
<div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
<nut-input
v-model="form.income"
type="digit"
placeholder="请输入年收入"
class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
:border="false"
/>
<span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">万元</span>
</div>
<!-- 家庭结构 -->
<div class="text-sm text-gray-600 mb-3">家庭结构(多选)</div>
<div class="flex flex-wrap gap-3 mb-5">
<div
v-for="item in familyOptions"
:key="item.value"
class="px-4 py-2 rounded-lg text-sm cursor-pointer transition-colors border"
:class="form.family.includes(item.value) ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200'"
@click="toggleSelection('family', item.value)"
>
{{ item.label }}
</div>
</div>
<!-- 保险需求 -->
<div class="text-sm text-gray-600 mb-3">保险需求(多选)</div>
<div class="flex flex-wrap gap-3 mb-5">
<div
v-for="item in insuranceOptions"
:key="item.value"
class="px-4 py-2 rounded-lg text-sm cursor-pointer transition-colors border"
:class="form.insurance.includes(item.value) ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200'"
@click="toggleSelection('insurance', item.value)"
>
{{ item.label }}
</div>
</div>
<!-- 期望收益率 -->
<div class="text-sm text-gray-600 mb-2">期望收益率</div>
<div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
<nut-input
v-model="form.returnRate"
type="digit"
placeholder="请输入期望收益率"
class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
:border="false"
/>
<span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">%</span>
</div>
</PlanPopup>
<!-- Industry Picker - 提升到 PlanPopup 外部,避免嵌套弹窗导致的层级问题 -->
<nut-popup
position="bottom"
v-model:visible="showIndustryPicker"
:z-index="9999"
:overlay="true"
>
<nut-picker
:columns="industryColumns"
title="选择行业"
@confirm="confirmIndustry"
@cancel="showIndustryPicker = false"
/>
</nut-popup>
</template>
<script setup>
/**
* @description 录入计划书 - 方案A 内容组件
* @description 使用 PlanPopup 容器组件提供统一的布局和按钮
*
* @emits close - 关闭弹窗事件
* @emits submit - 提交事件,携带表单数据
*/
import { ref, reactive } from 'vue'
import PlanPopup from './PlanPopup.vue'
import IconFont from '@/components/IconFont.vue'
const emit = defineEmits(['close', 'submit'])
/**
* 表单数据
*/
const form = reactive({
name: '',
gender: '', // 'male' | 'female'
age: '',
industry: '',
income: '',
family: [],
insurance: [],
returnRate: ''
})
/**
* 控制行业选择器显示
*/
const showIndustryPicker = ref(false)
/**
* 行业选项
*/
const industryColumns = [
{ text: 'IT/互联网', value: 'it' },
{ text: '金融', value: 'finance' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '其他', value: 'other' }
]
/**
* 家庭结构选项
*/
const familyOptions = [
{ label: '配偶', value: 'spouse' },
{ label: '子女', value: 'children' },
{ label: '父母', value: 'parents' },
{ label: '其他', value: 'others' }
]
/**
* 保险需求选项
*/
const insuranceOptions = [
{ label: '人身保障', value: 'life' },
{ label: '财富传承', value: 'wealth' },
{ label: '子女教育', value: 'education' },
{ label: '养老规划', value: 'pension' }
]
/**
* 切换多选项的选择状态
* @param {string} field - 字段名
* @param {string} value - 选项值
*/
const toggleSelection = (field, value) => {
const index = form[field].indexOf(value)
if (index === -1) {
form[field].push(value)
} else {
form[field].splice(index, 1)
}
}
/**
* 确认行业选择
* @param {Object} params - 选择器返回参数
* @param {Array} params.selectedOptions - 选中的选项
*/
const confirmIndustry = ({ selectedOptions }) => {
form.industry = selectedOptions[0].text
showIndustryPicker.value = false
}
/**
* 关闭弹窗
*/
const close = () => {
emit('close')
}
/**
* 提交表单
*/
const submit = () => {
console.log('SchemeA Submit:', form)
emit('submit', form)
}
</script>
<style lang="less" scoped>
/* Override NutUI input styles to match design */
:deep(.nut-input) {
padding: 0;
background: transparent;
border-radius: inherit;
}
</style>
<template>
<PlanPopup title="保险计划书申请" @close="close" @submit="submit">
<!-- 币种 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">币种</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">美元保单</span>
</div>
</div>
<!-- 计划 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">计划</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">基础情景</span>
</div>
</div>
<!-- 附加计划 -->
<div class="mb-5">
<span class="text-base text-gray-900">附加计划</span>
</div>
<!-- 性别 -->
<div class="text-sm text-gray-600 mb-2">性别</div>
<nut-radio-group v-model="form.gender" direction="horizontal" class="mb-4">
<nut-radio label="female" class="mr-8">女</nut-radio>
<nut-radio label="male">男</nut-radio>
</nut-radio-group>
<!-- 年龄 -->
<div class="text-sm text-gray-600 mb-2">年龄</div>
<div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
<nut-input
v-model="form.age"
type="digit"
placeholder="请输入年龄"
class="!p-0 !bg-transparent !text-sm !text-gray-900"
:border="false"
/>
</div>
<!-- 保险期间 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">保险期间</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">终身</span>
</div>
</div>
<!-- 交费期间 -->
<div class="text-sm text-gray-600 mb-3">交费期间</div>
<nut-radio-group v-model="form.paymentPeriod" direction="horizontal" class="mb-4">
<nut-radio
v-for="period in paymentPeriods"
:key="period"
:label="period"
class="mr-6"
>
{{ period }}
</nut-radio>
</nut-radio-group>
<!-- 年交保费 -->
<div class="text-sm text-gray-600 mb-2">年交保费</div>
<div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
<nut-input
v-model="form.premium"
type="digit"
placeholder="请输入保费"
class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
:border="false"
/>
<span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">美元</span>
</div>
</PlanPopup>
</template>
<script setup>
/**
* @description 录入计划书 - 方案B 内容组件
* @description 使用 PlanPopup 容器组件提供统一的布局和按钮
*
* @emits close - 关闭弹窗事件
* @emits submit - 提交事件,携带表单数据
*/
import { reactive } from 'vue'
import PlanPopup from './PlanPopup.vue'
const emit = defineEmits(['close', 'submit'])
/**
* 表单数据
*/
const form = reactive({
currency: '美元保单',
plan: '基础情景',
gender: 'female',
age: '30',
insurancePeriod: '终身',
paymentPeriod: '10年交',
premium: '100000'
})
/**
* 交费期间选项
*/
const paymentPeriods = ['10年交', '3年交', '5年交', '2年交']
/**
* 关闭弹窗
*/
const close = () => {
emit('close')
}
/**
* 提交表单
*/
const submit = () => {
console.log('SchemeB Submit:', form)
emit('submit', form)
}
</script>
<style lang="less" scoped>
/* Override NutUI input styles to match design */
:deep(.nut-input) {
padding: 0;
background: transparent;
border-radius: inherit;
}
</style>
<template>
<canvas
type="2d"
:id="canvasId"
:style="`height: ${height}rpx; width:${width}rpx;
position: absolute;
${debug ? '' : 'transform:translate3d(-9999rpx, 0, 0)'}`"
/>
</template>
<script>
import Taro from "@tarojs/taro"
import { defineComponent, onMounted, ref } from "vue"
import { drawImage, drawText, drawBlock, drawLine } from "./utils/draw.js"
import {
toPx,
toRpx,
getRandomId,
getImageInfo,
getLinearColor,
} from "./utils/tools.js"
export default defineComponent({
name: "PosterBuilder",
props: {
showLoading: {
type: Boolean,
default: false,
},
config: {
type: Object,
default: () => ({}),
},
},
emits: ["success", "fail"],
setup(props, context) {
const count = ref(1)
const {
width,
height,
backgroundColor,
texts = [],
blocks = [],
lines = [],
debug = false,
} = props.config || {}
const canvasId = getRandomId()
/**
* step1: 初始化图片资源
* @param {Array} images = imgTask
* @return {Promise} downloadImagePromise
*/
const initImages = (images) => {
const imagesTemp = images.filter((item) => item.url)
const drawList = imagesTemp.map((item, index) =>
getImageInfo(item, index)
)
return Promise.all(drawList)
}
/**
* step2: 初始化 canvas && 获取其 dom 节点和实例
* @return {Promise} resolve 里返回其 dom 和实例
*/
const initCanvas = () =>
new Promise((resolve) => {
setTimeout(() => {
const pageInstance = Taro.getCurrentInstance()?.page || {} // 拿到当前页面实例
const query = Taro.createSelectorQuery().in(pageInstance) // 确定在当前页面内匹配子元素
query
.select(`#${canvasId}`)
.fields({ node: true, size: true, context: true }, (res) => {
const canvas = res.node
const ctx = canvas.getContext("2d")
resolve({ ctx, canvas })
})
.exec()
}, 300)
})
/**
* @description 保存绘制的图片
* @param { object } config
*/
const getTempFile = (canvas) => {
Taro.canvasToTempFilePath(
{
canvas,
success: (result) => {
Taro.hideLoading()
context.emit("success", result)
},
fail: (error) => {
const { errMsg } = error
if (errMsg === "canvasToTempFilePath:fail:create bitmap failed") {
count.value += 1
if (count.value <= 3) {
getTempFile(canvas)
} else {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: errMsg || "绘制海报失败",
})
context.emit("fail", errMsg)
}
}
},
},
context
)
}
/**
* step2: 开始绘制任务
* @param { Array } drawTasks 待绘制任务
*/
const startDrawing = async (drawTasks) => {
// TODO: check
// const configHeight = getHeight(config)
const { ctx, canvas } = await initCanvas()
canvas.width = width
canvas.height = height
// 设置画布底色
if (backgroundColor) {
ctx.save() // 保存绘图上下文
const grd = getLinearColor(ctx, backgroundColor, 0, 0, width, height)
ctx.fillStyle = grd // 设置填充颜色
ctx.fillRect(0, 0, width, height) // 填充一个矩形
ctx.restore() // 恢复之前保存的绘图上下文
}
// 将要画的方块、文字、线条放进队列数组
const queue = drawTasks
.concat(
texts.map((item) => {
item.type = "text"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
blocks.map((item) => {
item.type = "block"
item.zIndex = item.zIndex || 0
return item
})
)
.concat(
lines.map((item) => {
item.type = "line"
item.zIndex = item.zIndex || 0
return item
})
)
queue.sort((a, b) => a.zIndex - b.zIndex) // 按照层叠顺序由低至高排序, 先画低的,再画高的
for (let i = 0; i < queue.length; i++) {
const drawOptions = {
canvas,
ctx,
toPx,
toRpx,
}
if (queue[i].type === "image") {
await drawImage(queue[i], drawOptions)
} else if (queue[i].type === "text") {
drawText(queue[i], drawOptions)
} else if (queue[i].type === "block") {
drawBlock(queue[i], drawOptions)
} else if (queue[i].type === "line") {
drawLine(queue[i], drawOptions)
}
}
setTimeout(() => {
getTempFile(canvas) // 需要做延时才能能正常加载图片
}, 300)
}
// start: 初始化 canvas 实例 && 下载图片资源
const init = () => {
if (props.showLoading)
Taro.showLoading({ mask: true, title: "生成中..." })
if (props.config?.images?.length) {
initImages(props.config.images)
.then((result) => {
// 1. 下载图片资源
startDrawing(result)
})
.catch((err) => {
Taro.hideLoading()
Taro.showToast({
icon: "none",
title: err.errMsg || "下载图片失败",
})
context.emit("fail", err)
})
} else {
startDrawing([])
}
}
onMounted(() => {
init()
})
return {
canvasId,
debug,
width,
height,
}
},
})
</script>
import { getLinearColor, getTextX, toPx } from './tools'
const drawRadiusRect = ({ x, y, w, h, r }, { ctx }) => {
const minSize = Math.min(w, h)
if (r > minSize / 2) r = minSize / 2
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
const drawRadiusGroupRect = ({ x, y, w, h, g }, { ctx }) => {
const [
borderTopLeftRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius
] = g
ctx.beginPath()
ctx.arc(
x + w - borderBottomRightRadius,
y + h - borderBottomRightRadius,
borderBottomRightRadius,
0,
Math.PI * 0.5
)
ctx.lineTo(x + borderBottomLeftRadius, y + h)
ctx.arc(
x + borderBottomLeftRadius,
y + h - borderBottomLeftRadius,
borderBottomLeftRadius,
Math.PI * 0.5,
Math.PI
)
ctx.lineTo(x, y + borderTopLeftRadius)
ctx.arc(
x + borderTopLeftRadius,
y + borderTopLeftRadius,
borderTopLeftRadius,
Math.PI,
Math.PI * 1.5
)
ctx.lineTo(x + w - borderTopRightRadius, y)
ctx.arc(
x + w - borderTopRightRadius,
y + borderTopRightRadius,
borderTopRightRadius,
Math.PI * 1.5,
Math.PI * 2
)
ctx.lineTo(x + w, y + h - borderBottomRightRadius)
ctx.closePath()
}
const getTextWidth = (text, drawOptions) => {
const { ctx } = drawOptions
let texts = []
if (Object.prototype.toString.call(text) === '[object Object]') {
texts.push(text)
} else {
texts = text
}
let width = 0
texts.forEach(
({
fontSize,
text: textStr,
fontStyle = 'normal',
fontWeight = 'normal',
fontFamily = 'sans-serif',
marginLeft = 0,
marginRight = 0
}) => {
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
width += ctx.measureText(textStr).width + marginLeft + marginRight
}
)
return width
}
const drawSingleText = (drawData, drawOptions) => {
const {
x = 0,
y = 0,
text,
color,
width,
fontSize = 28,
baseLine = 'top',
textAlign = 'left',
opacity = 1,
textDecoration = 'none',
lineNum = 1,
lineHeight = 0,
fontWeight = 'normal',
fontStyle = 'normal',
fontFamily = 'sans-serif'
} = drawData
const { ctx } = drawOptions
ctx.save()
ctx.beginPath()
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
ctx.globalAlpha = opacity
ctx.fillStyle = color
ctx.textBaseline = baseLine
ctx.textAlign = textAlign
let textWidth = ctx.measureText(text).width
const textArr = []
if (textWidth > width) {
let fillText = ''
let line = 1
for (let i = 0; i <= text.length - 1; i++) {
fillText += text[i]
const nextText = i < text.length - 1 ? fillText + text[i + 1] : fillText
const restWidth = width - ctx.measureText(nextText).width
if (restWidth < 0) {
if (line === lineNum) {
if (
restWidth + ctx.measureText(text[i + 1]).width >
ctx.measureText('...').width
) {
fillText = `${fillText}...`
} else {
fillText = `${fillText.substr(0, fillText.length - 1)}...`
}
textArr.push(fillText)
break
} else {
textArr.push(fillText)
line++
fillText = ''
}
} else if (i === text.length - 1) {
textArr.push(fillText)
}
}
textWidth = width
} else {
textArr.push(text)
}
textArr.forEach((item, index) =>
ctx.fillText(
item,
getTextX(textAlign, x, width),
y + (lineHeight || fontSize) * index
)
)
ctx.restore()
if (textDecoration !== 'none') {
let lineY = y
if (textDecoration === 'line-through') {
lineY = y
}
ctx.save()
ctx.moveTo(x, lineY)
ctx.lineTo(x + textWidth, lineY)
ctx.strokeStyle = color
ctx.stroke()
ctx.restore()
}
return textWidth
}
export function drawText(params, drawOptions) {
const { x = 0, y = 0, text, baseLine } = params
if (Object.prototype.toString.call(text) === '[object Array]') {
const preText = { x, y, baseLine }
text.forEach((item) => {
preText.x += item.marginLeft || 0
const textWidth = drawSingleText(
Object.assign(item, { ...preText, y: y + (item.marginTop || 0) }),
drawOptions
)
preText.x += textWidth + (item.marginRight || 0)
})
} else {
drawSingleText(params, drawOptions)
}
}
export function drawLine(drawData, drawOptions) {
const { startX, startY, endX, endY, color, width } = drawData
const { ctx } = drawOptions
if (!width) return
ctx.save()
ctx.beginPath()
ctx.strokeStyle = color
ctx.lineWidth = width
ctx.moveTo(startX, startY)
ctx.lineTo(endX, endY)
ctx.stroke()
ctx.closePath()
ctx.restore()
}
export function drawBlock(data, drawOptions) {
const {
x,
y,
text,
width = 0,
height,
opacity = 1,
paddingLeft = 0,
paddingRight = 0,
borderWidth,
backgroundColor,
borderColor,
borderRadius = 0,
borderRadiusGroup = null
} = data || {}
const { ctx } = drawOptions
ctx.save()
ctx.globalAlpha = opacity
let blockWidth = 0
let textX = 0
let textY = 0
if (text) {
const textWidth = getTextWidth(
typeof text.text === 'string' ? text : text.text,
drawOptions
)
blockWidth = textWidth > width ? textWidth : width
blockWidth += paddingLeft + paddingLeft
const { textAlign = 'left' } = text
textY = y
textX = x + paddingLeft
if (textAlign === 'center') {
textX = blockWidth / 2 + x
} else if (textAlign === 'right') {
textX = x + blockWidth - paddingRight
}
drawText(Object.assign(text, { x: textX, y: textY }), drawOptions)
} else {
blockWidth = width
}
if (backgroundColor) {
const grd = getLinearColor(ctx, backgroundColor, x, y, blockWidth, height)
ctx.fillStyle = grd
if (borderRadius > 0) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
}
drawRadiusRect(drawData, drawOptions)
ctx.fill()
} else if (borderRadiusGroup) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
g: borderRadiusGroup
}
drawRadiusGroupRect(drawData, drawOptions)
ctx.fill()
} else {
ctx.fillRect(x, y, blockWidth, height)
}
}
if (borderWidth && borderRadius > 0) {
ctx.strokeStyle = borderColor
ctx.lineWidth = borderWidth
if (borderRadius > 0) {
const drawData = {
x,
y,
w: blockWidth,
h: height,
r: borderRadius
}
drawRadiusRect(drawData, drawOptions)
ctx.stroke()
} else {
ctx.strokeRect(x, y, blockWidth, height)
}
}
ctx.restore()
}
export const drawImage = (data, drawOptions) =>
new Promise((resolve) => {
const { canvas, ctx } = drawOptions
const {
x,
y,
w,
h,
sx,
sy,
sw,
sh,
imgPath,
borderRadius = 0,
borderWidth = 0,
borderColor,
borderRadiusGroup = null
} = data
ctx.save()
if (borderRadius > 0) {
drawRadiusRect(
{
x,
y,
w,
h,
r: borderRadius
},
drawOptions
)
ctx.clip()
ctx.fill()
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
if (borderWidth > 0) {
ctx.strokeStyle = borderColor
ctx.lineWidth = borderWidth
ctx.stroke()
}
resolve()
ctx.restore()
}
} else if (borderRadiusGroup) {
drawRadiusGroupRect(
{
x,
y,
w,
h,
g: borderRadiusGroup
},
drawOptions
)
ctx.clip()
ctx.fill()
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
resolve()
ctx.restore()
}
} else {
const img = canvas.createImage()
img.src = imgPath
img.onload = () => {
ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
resolve()
ctx.restore()
}
}
})
import Taro from '@tarojs/taro'
/**
* @description 生成随机字符串(递归补齐长度)
* @param {number} length 目标长度
* @returns {string} 随机字符串
*/
export function randomString(length) {
let str = Math.random().toString(36).substr(2)
if (str.length >= length) {
return str.substr(0, length)
}
str += randomString(length - str.length)
return str
}
/**
* @description 生成随机 id(常用于 canvasId)
* @param {string} prefix 前缀
* @param {number} length 随机段长度
* @returns {string} 随机 id
*/
export function getRandomId(prefix = 'canvas', length = 10) {
return prefix + randomString(length)
}
/**
* @description 将 http 链接转换为 https(小程序部分场景要求 https)
* @param {string} rawUrl 原始 url
* @returns {string} 处理后的 url
*/
export function mapHttpToHttps(rawUrl) {
if (rawUrl.indexOf(':') < 0 || rawUrl.startsWith('http://tmp')) {
return rawUrl
}
const urlComponent = rawUrl.split(':')
if (urlComponent.length === 2) {
if (urlComponent[0] === 'http') {
urlComponent[0] = 'https'
return `${urlComponent[0]}:${urlComponent[1]}`
}
}
return rawUrl
}
/**
* @description 获取 rpx 与 px 的换算系数(以 750 设计稿为基准)
* @returns {number} 系数(screenWidth / 750)
*/
export const getFactor = () => {
const sysInfo = Taro.getSystemInfoSync()
const { screenWidth } = sysInfo
return screenWidth / 750
}
/**
* @description rpx 转 px
* @param {number} rpx rpx 值
* @param {number} factor 换算系数
* @returns {number} px 值(整数)
*/
export const toPx = (rpx, factor = getFactor()) =>
parseInt(String(rpx * factor), 10)
/**
* @description px 转 rpx
* @param {number} px px 值
* @param {number} factor 换算系数
* @returns {number} rpx 值(整数)
*/
export const toRpx = (px, factor = getFactor()) =>
parseInt(String(px / factor), 10)
/**
* @description 下载图片到本地临时路径(避免跨域/协议限制)
* - 已是本地路径/用户数据路径时直接返回
* @param {string} url 图片地址
* @returns {Promise<string>} 本地可用的图片路径
*/
export function downImage(url) {
return new Promise((resolve, reject) => {
const wx_user_data_path =
(typeof wx !== 'undefined' && wx && wx.env && wx.env.USER_DATA_PATH)
? wx.env.USER_DATA_PATH
: ''
const is_local_user_path = wx_user_data_path
? new RegExp(wx_user_data_path).test(url)
: false
if (/^http/.test(url) && !is_local_user_path) {
Taro.downloadFile({
url: mapHttpToHttps(url),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.tempFilePath)
} else {
reject(res)
}
},
fail(err) {
reject(err)
}
})
} else {
resolve(url)
}
})
}
/**
* @description 获取图片信息并计算裁剪参数(居中裁剪)
* @param {Object} item 图片配置
* @param {number} index 渲染顺序(默认 zIndex)
* @returns {Promise<Object>} 标准化后的图片绘制参数
*/
export const getImageInfo = (item, index) =>
new Promise((resolve, reject) => {
const { x, y, width, height, url, zIndex } = item
downImage(url).then((imgPath) =>
Taro.getImageInfo({ src: imgPath })
.then((imgInfo) => {
let sx
let sy
const borderRadius = item.borderRadius || 0
const imgWidth = toRpx(imgInfo.width)
const imgHeight = toRpx(imgInfo.height)
if (imgWidth / imgHeight <= width / height) {
sx = 0
sy = (imgHeight - (imgWidth / width) * height) / 2
} else {
sy = 0
sx = (imgWidth - (imgHeight / height) * width) / 2
}
const result = {
type: 'image',
borderRadius,
borderWidth: item.borderWidth,
borderColor: item.borderColor,
borderRadiusGroup: item.borderRadiusGroup,
zIndex: typeof zIndex !== 'undefined' ? zIndex : index,
imgPath: url,
sx,
sy,
sw: imgWidth - sx * 2,
sh: imgHeight - sy * 2,
x,
y,
w: width,
h: height
}
resolve(result)
})
.catch((err) => {
reject(err)
})
)
})
/**
* @description 解析 linear-gradient 字符串为 canvas 渐变色
* @param {CanvasRenderingContext2D} ctx canvas 上下文
* @param {string} color 颜色字符串(支持 linear-gradient(...))
* @param {number} startX 起点 x
* @param {number} startY 起点 y
* @param {number} w 宽度
* @param {number} h 高度
* @returns {any} 普通颜色字符串或渐变对象
*/
export function getLinearColor(ctx, color, startX, startY, w, h) {
if (
typeof startX !== 'number' ||
typeof startY !== 'number' ||
typeof w !== 'number' ||
typeof h !== 'number'
) {
return color
}
let grd = color
if (color.includes('linear-gradient')) {
const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/)
const radian = colorList[1]
const color1 = colorList[2]
const color2 = colorList[3]
const L = Math.sqrt(w * w + h * h)
const x = Math.ceil(Math.sin(180 - radian) * L)
const y = Math.ceil(Math.cos(180 - radian) * L)
if (Number(radian) === 180 || Number(radian) === 0) {
if (Number(radian) === 180) {
grd = ctx.createLinearGradient(startX, startY, startX, startY + h)
}
if (Number(radian) === 0) {
grd = ctx.createLinearGradient(startX, startY + h, startX, startY)
}
} else if (radian > 0 && radian < 180) {
grd = ctx.createLinearGradient(startX, startY, x + startX, y + startY)
} else {
throw new Error('只支持0 <= 颜色弧度 <= 180')
}
grd.addColorStop(0, color1)
grd.addColorStop(1, color2)
}
return grd
}
/**
* @description 根据 textAlign 计算文本绘制起点 x
* @param {'left'|'center'|'right'} textAlign 对齐方式
* @param {number} x 原始 x
* @param {number} width 容器宽
* @returns {number} 计算后的 x
*/
export function getTextX(textAlign, x, width) {
let newX = x
if (textAlign === 'center') {
newX = width / 2 + x
} else if (textAlign === 'right') {
newX = width + x
}
return newX
}
<template>
<div>
<!-- 性别 -->
<PlanFieldRadio
v-model="form.gender"
label="性别"
:options="['男', '女']"
/>
<!-- 年龄(根据出生日期自动计算,可编辑) -->
<PlanFieldAgePicker
v-model="form.age"
label="年龄"
placeholder="请选择出生日期自动计算"
/>
<!-- 出生年月日 -->
<PlanFieldDatePicker
v-model="form.birthday"
label="出生年月日"
placeholder="请选择日期"
@change="onBirthdayChange"
/>
<!-- 是否吸烟 -->
<PlanFieldRadio
v-model="form.smoker"
label="是否吸烟"
:options="['是', '否']"
/>
<!-- 保额 -->
<PlanFieldAmount
v-model="form.coverage"
label="保额"
placeholder="请输入保额"
:currency="config.currency"
/>
<!-- 缴费年期 -->
<PlanFieldSelect
v-model="form.payment_period"
label="缴费年期"
placeholder="请选择缴费年期"
:options="config.payment_periods"
/>
<!-- 保险期间 -->
<div class="flex justify-between items-start mb-5">
<span class="text-sm text-gray-600 mt-1.5">保险期间</span>
<div class="bg-blue-50 rounded-md px-3 py-1.5">
<span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
</div>
</div>
<!-- ====== 提取计划功能(储蓄产品专用)====== -->
<div v-if="config.withdrawal_plan?.enabled" class="mt-6 pt-6 border-t border-gray-200">
<div class="text-base font-medium text-gray-900 mb-4">提取计划</div>
<!-- 提取方式选择 -->
<PlanFieldRadio
v-model="form.withdrawal_plan.mode"
label="提取方式"
:options="config.withdrawal_plan.withdrawal_modes"
/>
<!-- 开始年龄 -->
<PlanFieldAgePicker
v-model="form.withdrawal_plan.start_age"
label="开始年龄"
placeholder="请选择开始提取年龄"
/>
<!-- 提取年期 -->
<PlanFieldSelect
v-model="form.withdrawal_plan.withdrawal_period"
label="提取年期"
placeholder="请选择提取年期"
:options="config.withdrawal_plan.withdrawal_periods"
/>
<!-- 方式1:年龄指定金额 - 额外字段 -->
<template v-if="form.withdrawal_plan.mode === '年龄指定金额'">
<!-- 每年提取金额 -->
<PlanFieldAmount
v-model="form.withdrawal_plan.annual_amount"
label="每年提取金额"
placeholder="请输入金额"
:currency="form.withdrawal_plan.currency || config.withdrawal_plan.default_currency"
/>
<!-- 币种 -->
<div class="mb-5">
<div class="text-sm text-gray-600 mb-2">币种</div>
<div class="flex gap-2">
<button
v-for="curr in currencyOptions"
:key="curr.value"
:class="[
'px-4 py-2 rounded-lg text-sm border transition-colors',
(form.withdrawal_plan.currency || config.withdrawal_plan.default_currency) === curr.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200'
]"
@tap="selectCurrency(curr.value)"
>
{{ curr.label }}
</button>
</div>
</div>
<!-- 增加率 -->
<div class="mb-5">
<div class="text-sm text-gray-600 mb-2">增加率(%</div>
<nut-input
v-model="form.withdrawal_plan.increase_rate"
type="digit"
placeholder="请输入增加率"
class="border border-gray-200 rounded-lg"
/>
</div>
</template>
</div>
</div>
</template>
<script setup>
/**
* 储蓄型保险计划书模版
*
* @description GS/GC/FA/LV2 等储蓄型保险产品的计划书录入表单
* - 支持出生日期自动计算年龄
* - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
* - 提取计划功能:年龄指定金额、最高固定金额
* @author Claude Code
* @example
* <SavingsTemplate
* v-model="formData"
* :config="templateConfig"
* />
*/
import { reactive, watch, computed } from 'vue'
import PlanFieldAgePicker from './PlanFields/AgePicker.vue'
import PlanFieldAmount from './PlanFields/AmountInput.vue'
import PlanFieldDatePicker from './PlanFields/DatePicker.vue'
import PlanFieldRadio from './PlanFields/RadioGroup.vue'
import PlanFieldSelect from './PlanFields/SelectPicker.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 表单数据对象
* @type {Object}
*/
modelValue: {
type: Object,
default: () => ({})
},
/**
* 模版配置
* @type {Object}
* @property {string} currency - 币种代码
* @property {Array<string>} payment_periods - 缴费年期选项
* @property {Object} age_range - 年龄范围 { min, max }
* @property {string} insurance_period - 保险期间
* @property {Object} withdrawal_plan - 提取计划配置
*/
config: {
type: Object,
required: true
}
})
/**
* 组件事件
*/
const emit = defineEmits([
/**
* 更新表单数据事件
* @event update:modelValue
* @param {Object} value - 表单数据
*/
'update:modelValue'
])
/**
* 表单数据
* @type {Object}
*/
const form = reactive(props.modelValue || {
// 初始化提取计划数据
withdrawal_plan: {
mode: '年龄指定金额',
start_age: null,
withdrawal_period: null,
annual_amount: null,
currency: props.config?.withdrawal_plan?.default_currency || 'HKD',
increase_rate: 0
}
})
/**
* 币种选项(用于提取计划)
* @type {ComputedRef<Array>}
*/
const currencyOptions = computed(() => {
const CURRENCY_MAP = {
HKD: { label: '港币', value: 'HKD' },
USD: { label: '美元', value: 'USD' },
CNY: { label: '人民币', value: 'CNY' }
}
const supportedCurrencies = props.config?.withdrawal_plan?.currencies || ['HKD']
return supportedCurrencies
.map(code => CURRENCY_MAP[code])
.filter(Boolean)
})
/**
* 监听表单数据变化,同步到父组件
*/
watch(
() => form,
(newVal) => emit('update:modelValue', newVal),
{ deep: true }
)
/**
* 出生日期变化时自动计算年龄
* @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
*
* @description 用户选择出生日期后,自动计算并填充年龄字段
* 计算公式:当前年份 - 出生年份
*/
const onBirthdayChange = (birthday) => {
if (birthday) {
const birthYear = new Date(birthday).getFullYear()
const currentYear = new Date().getFullYear()
const calculatedAge = currentYear - birthYear
// 自动填充年龄字段
form.age = calculatedAge
}
}
/**
* 选择币种(用于提取计划)
* @param {string} currencyCode - 币种代码
*/
const selectCurrency = (currencyCode) => {
if (form.withdrawal_plan) {
form.withdrawal_plan.currency = currencyCode
}
}
</script>
<style lang="less" scoped>
/* 模版样式 */
</style>
<template>
<view class="index-nav" :class="[`is-${position}`]">
<view class="nav-logo is-home" :class="{ 'is-active': active === 'home' }" @tap="() => on_select('home')">
<view class="nav-icon-wrap">
<image class="nav-icon" :src="icons?.home" mode="aspectFit" />
</view>
<text class="nav-text">首页</text>
</view>
<view
class="nav-logo is-code"
:class="[{ 'is-active': active === 'code' }, { 'is-center-raised': center_variant === 'raised' }]"
@tap="() => on_select('code')"
>
<view class="nav-icon-wrap">
<image
class="nav-icon"
:class="{ 'nav-icon--raised': center_variant === 'raised' }"
:src="icons?.code"
mode="aspectFit"
/>
</view>
<text class="nav-text">预约码</text>
</view>
<view class="nav-logo is-me" :class="{ 'is-active': active === 'me' }" @tap="() => on_select('me')">
<view class="nav-icon-wrap">
<image class="nav-icon" :src="icons?.me" mode="aspectFit" />
</view>
<text class="nav-text">我的</text>
</view>
</view>
</template>
<script setup>
const props = defineProps({
icons: {
type: Object,
default: () => ({})
},
active: {
type: String,
default: ''
},
position: {
type: String,
default: 'fixed'
},
center_variant: {
type: String,
default: 'normal'
},
allow_active_tap: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['select'])
const on_select = (key) => {
if (!props.allow_active_tap && props.active && key === props.active) return
emit('select', key)
}
</script>
<style lang="less">
.index-nav {
left: 0;
bottom: 0;
width: 750rpx;
height: calc(134rpx + constant(safe-area-inset-bottom));
height: calc(134rpx + env(safe-area-inset-bottom));
padding-bottom: calc(0rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(0rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #FFFFFF;
box-shadow: 0 -8rpx 8rpx 0 rgba(0, 0, 0, 0.1);
display: flex;
align-items: flex-end;
justify-content: space-around;
color: #A67939;
z-index: 99;
&.is-fixed {
position: fixed;
}
&.is-absolute {
position: absolute;
}
.nav-logo {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-icon-wrap {
position: relative;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon {
width: 56rpx;
height: 56rpx;
display: block;
&.nav-icon--raised {
width: 140rpx;
height: 140rpx;
position: absolute;
top: -100rpx;
left: 50%;
transform: translateX(-50%);
}
}
.nav-logo.is-home,
.nav-logo.is-me {
.nav-icon {
width: 56rpx;
height: 56rpx;
}
}
.nav-text {
font-size: 26rpx;
margin-top: 12rpx;
line-height: 1;
}
}
</style>
<!--
* @Date: 2024-01-16 10:06:47
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-14 21:57:40
* @FilePath: /xyxBooking-weapp/src/components/qrCodeSearch.vue
* @Description: 预约码卡组件
-->
<template>
<view class="qr-code-page">
<view v-if="userinfo.qr_code" class="show-qrcode">
<view class="qrcode-content">
<view class="user-info">{{ userinfo.name }}&nbsp;{{ userinfo.id }}</view>
<view class="user-qrcode">
<view class="left">
<!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png"> -->
</view>
<view class="center">
<image :src="userinfo.qr_code_url" mode="aspectFit" />
<view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used">
<view class="overlay"></view>
<text class="status-text">二维码{{ qr_code_status[useStatus] }}</text>
</view>
</view>
<view class="right">
<!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png"> -->
</view>
</view>
<view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view>
</view>
</view>
<view v-else class="no-qrcode">
<image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" />
<view class="no-qrcode-title">您还没有预约过今天参观</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { formatDatetime } from '@/utils/tools';
import { qrcodeStatusAPI, queryQrCodeAPI } from '@/api/index'
import BASE_URL from '@/utils/config';
const props = defineProps({
id: {
type: String,
default: ''
},
id_type: {
type: Number,
default: 1
}
});
const userinfo = ref({});
const replaceMiddleCharacters = (input_string) => {
if (!input_string || input_string.length < 15) {
return input_string;
}
const start = Math.floor((input_string.length - 8) / 2);
const end = start + 8;
const replacement = '*'.repeat(8);
return input_string.substring(0, start) + replacement + input_string.substring(end);
}
const formatId = (id) => replaceMiddleCharacters(id);
const useStatus = ref('0');
const is_loading = ref(false)
let is_destroyed = false
const qr_code_status = {
'1': '未激活',
'3': '待使用',
'5': '被取消',
'7': '已使用',
};
const STATUS_CODE = {
APPLY: '1',
SUCCESS: '3',
CANCELED: '5',
USED: '7',
};
const build_qr_code_url = (qr_code) => {
if (!qr_code) return ''
return `${BASE_URL}/admin?m=srv&a=get_qrcode&key=${encodeURIComponent(String(qr_code))}`
}
/**
* @description: 格式化预约码卡数据
* @param {*} raw 原始数据
* @return {*} 格式化后的数据
*/
const normalize_item = (raw) => {
if (!raw || typeof raw !== 'object') return null
const qr_code = raw.qr_code ? String(raw.qr_code) : ''
const id_number = raw.id_number ? String(raw.id_number) : ''
return {
...raw,
qr_code,
qr_code_url: build_qr_code_url(qr_code),
datetime: formatDatetime({ begin_time: raw.begin_time, end_time: raw.end_time }),
id: formatId(id_number),
}
}
/**
* @description: 重置状态
*/
const reset_state = () => {
userinfo.value = {}
useStatus.value = '0'
}
/**
* @description: 加载预约码卡状态
* @param {*} qr_code 预约码
* @return {*} 状态码
*/
const load_qr_code_status = async (qr_code) => {
if (!qr_code) return
const res = await qrcodeStatusAPI({ qr_code })
if (is_destroyed) return
if (!res || res.code !== 1) return
const status = res?.data?.status
if (status === undefined || status === null) return
useStatus.value = String(status)
}
/**
* @description: 加载预约码卡信息
* @param {*} id_number 身份证号
* @return {*} 预约码卡信息
*/
const load_qr_code_info = async (id_number) => {
const id = String(id_number || '').trim()
if (!id) {
reset_state()
return
}
is_loading.value = true
const params = { id_number: id }
if (props.id_type) params.id_type = props.id_type
const res = await queryQrCodeAPI(params)
if (is_destroyed) return
is_loading.value = false
if (!res || res.code !== 1 || !res.data) {
reset_state()
return
}
const raw = Array.isArray(res.data) ? res.data[0] : res.data
const item = normalize_item(raw)
if (!item || !item.qr_code) {
reset_state()
return
}
userinfo.value = item
await load_qr_code_status(item.qr_code)
}
onUnmounted(() => {
is_destroyed = true
})
onMounted(() => {
load_qr_code_info(props.id)
})
watch(
() => [props.id, props.id_type],
([val]) => {
if (is_loading.value) return
load_qr_code_info(val)
}
)
</script>
<style lang="less">
.qr-code-page {
.qrcode-content {
padding: 32rpx 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #FFF;
border-radius: 16rpx;
box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27);
.user-info {
color: #A6A6A6;
font-size: 37rpx;
margin-top: 16rpx;
margin-bottom: 16rpx;
}
.user-qrcode {
display: flex;
align-items: center;
.center {
border: 2rpx solid #D1D1D1;
border-radius: 40rpx;
padding: 16rpx;
position: relative;
image {
width: 480rpx; height: 480rpx;
}
.qrcode-used {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 40rpx;
overflow: hidden;
.overlay {
width: 100%;
height: 100%;
background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg');
background-size: contain;
opacity: 0.9;
}
.status-text {
color: #A67939;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 38rpx;
white-space: nowrap;
font-weight: bold;
z-index: 10;
}
}
}
}
}
.no-qrcode {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 32rpx;
.no-qrcode-title {
color: #A67939;
font-size: 34rpx;
}
}
}
</style>
var getDaysInOneMonth = function (year, month) {
let _month = parseInt(month, 10);
let d = new Date(year, _month, 0);
return d.getDate();
}
var dateDate = function (date) {
let year = date && date.getFullYear();
let month = date && date.getMonth() + 1;
let day = date && date.getDate();
let hours = date && date.getHours();
let minutes = date && date.getMinutes();
return {
year, month, day, hours, minutes
}
}
var dateTimePicker = function (startyear, endyear) {
// 获取date time 年份,月份,天数,小时,分钟推后30分
const years = [];
const months = [];
const hours = [];
const minutes = [];
for (let i = startyear; i <= endyear; i++) {
years.push({
name: i + '年',
id: i
});
}
//获取月份
for (let i = 1; i <= 12; i++) {
if (i < 10) {
i = "0" + i;
}
months.push({
name: i + '月',
id: i
});
}
//获取小时
for (let i = 0; i < 24; i++) {
if (i < 10) {
i = "0" + i;
}
hours.push({
name: i + '时',
id: i
});
}
//获取分钟
for (let i = 0; i < 60; i++) {
if (i < 10) {
i = "0" + i;
}
minutes.push({
name: i + '分',
id: i
});
}
return function (_year, _month) {
const days = [];
_year = parseInt(_year);
_month = parseInt(_month);
//获取日期
for (let i = 1; i <= getDaysInOneMonth(_year, _month); i++) {
if (i < 10) {
i = "0" + i;
}
days.push({
name: i + '日',
id: i
});
}
return [years, months, days, hours, minutes];
}
}
export {
dateTimePicker,
getDaysInOneMonth,
dateDate
}
<template>
<picker mode="multiSelector" :range-key="'name'" :value="timeIndex" :range="activityArray" :disabled="disabled"
@change="bindMultiPickerChange" @columnChange="bindMultiPickerColumnChange">
<slot />
</picker>
</template>
<script>
import { dateTimePicker, dateDate } from "./dateTimePicker.js";
export default {
name: "TimePickerDataPicker",
props: {
startTime: {
type: [Object, Date],
default: new Date(),
},
endTime: {
type: [Object, Date],
default: new Date(),
},
defaultTime: {
type: [Object, Date],
default: new Date(),
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
timeIndex: [0, 0, 0, 0, 0],
activityArray: [],
year: 0,
month: 1,
day: 1,
hour: 0,
minute: 0,
datePicker: "",
defaultIndex: [0, 0, 0, 0, 0],
startIndex: [0, 0, 0, 0, 0],
endIndex: [0, 0, 0, 0, 0],
};
},
computed: {
timeDate() {
const { startTime, endTime } = this;
return { startTime, endTime };
},
},
watch: {
timeDate() {
this.initData();
},
defaultTime () {
this.initData();
}
},
created() {
this.initData();
},
methods: {
initData() {
let startTime = this.startTime;
let endTime = this.endTime;
this.datePicker = dateTimePicker(
startTime.getFullYear(),
endTime.getFullYear()
);
this.setDateData(this.defaultTime);
this.getKeyIndex(this.startTime, "startIndex");
// 截止时间索引
this.getKeyIndex(this.endTime, "endIndex");
// 默认索引
this.getKeyIndex(this.defaultTime, "defaultIndex");
this.timeIndex = this.defaultIndex;
// 初始时间
this.initTime();
},
getKeyIndex(time, key) {
let Arr = dateDate(time);
let _index = this.getIndex(Arr);
this[key] = _index;
},
getIndex(arr) {
let timeIndex = [];
let indexKey = ["year", "month", "day", "hours", "minutes"];
this.activityArray.forEach((element, index) => {
let _index = element.findIndex(
(item) => parseInt(item.id) === parseInt(arr[indexKey[index]])
);
timeIndex[index] = _index >= 0 ? _index : 0;
});
return timeIndex;
},
initTime() {
let _index = this.timeIndex;
this.year = this.activityArray[0][_index[0]].id;
this.month = this.activityArray[1].length && this.activityArray[1][_index[1]].id;
this.day = this.activityArray[2].length && this.activityArray[2][_index[2]].id;
this.hour = this.activityArray[3].length && this.activityArray[3][_index[3]].id;
this.minute = this.activityArray[4].length && this.activityArray[4][_index[4]].id;
},
setDateData(_date) {
let _data = dateDate(_date);
this.activityArray = this.datePicker(_data.year, _data.month);
},
bindMultiPickerChange(e) {
console.log("picker发送选择改变,携带值为", e.detail.value);
let activityArray = JSON.parse(JSON.stringify(this.activityArray)),
{ value } = e.detail,
_result = [];
for (let i = 0; i < value.length; i++) {
_result[i] = activityArray[i][value[i]].id;
}
this.$emit("result", _result);
},
bindMultiPickerColumnChange(e) {
console.log("修改的列为", e.detail.column, ",值为", e.detail.value);
let _data = JSON.parse(JSON.stringify(this.activityArray)),
timeIndex = JSON.parse(JSON.stringify(this.timeIndex)),
{ startIndex, endIndex } = this,
{ column, value } = e.detail,
_value = _data[column][value].id,
_start = dateDate(this.startTime),
_end = dateDate(this.endTime);
switch (e.detail.column) {
case 0:
if (_value <= _start.year) {
timeIndex = startIndex;
this.year = _start.year;
this.setDateData(this.startTime);
} else if (_value >= _end.year) {
this.year = _end.year;
timeIndex = [endIndex[0], 0, 0, 0, 0];
this.setDateData(this.endTime);
} else {
this.year = _value;
timeIndex = [value, 0, 0, 0, 0];
this.activityArray = this.datePicker(_value, 1);
}
timeIndex = this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
this.timeIndex = timeIndex;
break;
case 1:
if (this.year == _start.year && value <= startIndex[1]) {
timeIndex = startIndex;
this.month = _start.month;
this.setDateData(this.startTime);
} else if (this.year == _end.year && value >= endIndex[1]) {
timeIndex = endIndex;
this.month = _end.month;
this.setDateData(this.endTime);
} else {
this.month = _value;
_data[2] = this.datePicker(this.year, this.month)[2];
timeIndex = [timeIndex[0], value, 0, 0, 0];
this.activityArray = _data;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 2:
if (
this.year == _start.year &&
this.month == _start.month &&
value <= startIndex[2]
) {
this.day = _start.day;
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
value >= endIndex[2]
) {
this.day = _end.day;
timeIndex = endIndex;
} else {
this.day = _value;
timeIndex = [timeIndex[0], timeIndex[1], value, 0, 0];
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 3:
if (
this.year == _start.year &&
this.month == _start.month &&
this.day == _start.day &&
value <= startIndex[3]
) {
this.hour = _start.hours;
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
this.day == _end.day &&
value >= endIndex[3]
) {
this.hour = _end.hours;
timeIndex = endIndex;
} else {
this.hour = _value;
timeIndex[3] = value;
timeIndex[4] = 0;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
case 4:
timeIndex[4] = value;
if (
this.year == _start.year &&
this.month == _start.month &&
this.day == _start.day &&
this.hour == _start.hours &&
value <= startIndex[4]
) {
timeIndex = startIndex;
} else if (
this.year == _end.year &&
this.month == _end.month &&
this.day == _end.day &&
this.hour == _end.hours &&
value >= endIndex[4]
) {
timeIndex = endIndex;
}
this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
break;
}
},
},
};
</script>