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 @@ ...@@ -64,7 +64,16 @@
64 "Bash(npm run dev:weapp:*)", 64 "Bash(npm run dev:weapp:*)",
65 "Bash(__NEW_LINE_19c6a134b9496225__ echo \"✅ 已删除不再使用的 Apifox 相关脚本\")", 65 "Bash(__NEW_LINE_19c6a134b9496225__ echo \"✅ 已删除不再使用的 Apifox 相关脚本\")",
66 "Bash(cat:*)", 66 "Bash(cat:*)",
67 - "Bash(pkill:*)" 67 + "Bash(pkill:*)",
68 + "Bash(then echo \" ❌ 仍然存在\")",
69 + "Bash(else echo \" ✅ 已成功删除\")",
70 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"2. qrCodeSearch.vue\")",
71 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"3. time-picker-data 目录\")",
72 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"4. PosterBuilder 目录\")",
73 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"5. PlanSchemes 目录\")",
74 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"6. 根目录 SavingsTemplate.vue\")",
75 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"7. PlanFields/AmountInput.vue\")",
76 + "Bash(__NEW_LINE_91a02bd62c4bd02a__ echo \"\")"
68 ] 77 ]
69 }, 78 },
70 "enableAllProjectMcpServers": true, 79 "enableAllProjectMcpServers": true,
......
1 -<template>
2 - <div>
3 - <!-- 标签 -->
4 - <div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
5 - <span v-if="required" class="text-red-500 mr-1">*</span>
6 - <span>{{ label }}</span>
7 - <span v-if="currencyText" class="text-gray-500">{{ currencyText }}</span>
8 - </div>
9 -
10 - <!-- 多币种模式(方案 2 - 未来扩展) -->
11 - <div v-if="multiCurrencyEnabled" class="mb-2">
12 - <div class="text-sm text-gray-600 mb-2">币种</div>
13 - <div class="flex gap-2">
14 - <button
15 - v-for="curr in supportedCurrencies"
16 - :key="curr.value"
17 - :class="[
18 - 'px-4 py-2 rounded-lg text-sm border transition-colors',
19 - selectedCurrency === curr.value
20 - ? 'bg-blue-600 text-white border-blue-600'
21 - : 'bg-white text-gray-600 border-gray-200'
22 - ]"
23 - @tap="selectCurrency(curr.value)"
24 - >
25 - {{ curr.label }}
26 - </button>
27 - </div>
28 - </div>
29 -
30 - <!-- 保额输入 -->
31 - <div class="border border-gray-200 rounded-lg flex items-center overflow-hidden bg-gray-50">
32 - <nut-input
33 - :model-value="inputValue"
34 - @input="onInput"
35 - @blur="onBlur"
36 - type="digit"
37 - :placeholder="placeholder"
38 - class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
39 - :border="false"
40 - :cursorSpacing="80"
41 - />
42 - <span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">{{ currencySymbol }}</span>
43 - </div>
44 - </div>
45 -</template>
46 -
47 -<script setup>
48 -/**
49 - * 保额输入组件
50 - *
51 - * @description 支持多币种的保额输入组件
52 - * - 单位转换:内部存储为分(整数),显示为元(带2位小数)
53 - * - 币种支持:CNY、USD、HKD、EUR
54 - * - 多币种模式:通过 FEATURE_FLAGS.MULTI_CURRENCY_ENABLED 控制
55 - * @author Claude Code
56 - * @example
57 - * <!-- 固定币种模式 -->
58 - * <AmountInput
59 - * v-model="coverage"
60 - * label="保额"
61 - * currency="USD"
62 - * placeholder="请输入保额"
63 - * />
64 - *
65 - * @example
66 - * <!-- 多币种模式 -->
67 - * <AmountInput
68 - * v-model="coverage"
69 - * label="保额"
70 - * :config="{ supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }"
71 - * placeholder="请输入保额"
72 - * />
73 - */
74 -import { ref, computed, watch } from 'vue'
75 -import { FEATURE_FLAGS, CURRENCY_SYMBOLS, CURRENCY_MAP } from '@/config/plan-templates'
76 -
77 -/**
78 - * 组件属性
79 - */
80 -const props = defineProps({
81 - /**
82 - * 标签文本
83 - * @type {string}
84 - */
85 - label: {
86 - type: String,
87 - default: ''
88 - },
89 -
90 - /**
91 - * 是否必填
92 - * @type {boolean}
93 - */
94 - required: {
95 - type: Boolean,
96 - default: false
97 - },
98 -
99 - /**
100 - * 占位符文本
101 - * @type {string}
102 - */
103 - placeholder: {
104 - type: String,
105 - default: '请输入保额'
106 - },
107 -
108 - /**
109 - * 绑定的值(单位:分)
110 - * @type {number}
111 - * @example 100000 表示 1000.00 元
112 - */
113 - modelValue: {
114 - type: Number,
115 - default: null
116 - },
117 -
118 - /**
119 - * 币种代码(固定币种模式)
120 - * @type {string}
121 - * @default 'CNY'
122 - */
123 - currency: {
124 - type: String,
125 - default: 'CNY'
126 - },
127 -
128 - /**
129 - * 模版配置(多币种模式)
130 - * @type {Object}
131 - * @property {Array<string>} supported_currencies - 支持的币种代码数组
132 - * @property {string} default_currency - 默认币种代码
133 - * @example { supported_currencies: ['CNY', 'USD'], default_currency: 'CNY' }
134 - */
135 - config: {
136 - type: Object,
137 - default: () => ({})
138 - }
139 -})
140 -
141 -/**
142 - * 组件事件
143 - */
144 -const emit = defineEmits([
145 - /**
146 - * 更新值事件
147 - * @event update:modelValue
148 - * @param {number} value - 保额值(单位:分)
149 - */
150 - 'update:modelValue'
151 -])
152 -
153 -/**
154 - * 判断是否启用多币种
155 - * @type {ComputedRef<boolean>}
156 - */
157 -const multiCurrencyEnabled = computed(() => FEATURE_FLAGS.MULTI_CURRENCY_ENABLED)
158 -
159 -/**
160 - * 当前选中的币种
161 - * @type {Ref<string>}
162 - */
163 -const selectedCurrency = ref(props.config.default_currency || props.currency || 'CNY')
164 -
165 -/**
166 - * 支持的币种列表(多币种模式)
167 - * @type {ComputedRef<Array<{label: string, symbol: string, value: string}>>}
168 - */
169 -const supportedCurrencies = computed(() => {
170 - if (!multiCurrencyEnabled.value) return []
171 -
172 - return (props.config.supported_currencies || ['CNY'])
173 - .map(code => CURRENCY_MAP[code])
174 - .filter(Boolean)
175 -})
176 -
177 -/**
178 - * 当前币种符号
179 - * @type {ComputedRef<string>}
180 - * @example
181 - * // CNY -> '¥'
182 - * // USD -> '$'
183 - */
184 -const currencySymbol = computed(() => {
185 - if (multiCurrencyEnabled.value) {
186 - // 多币种模式:使用用户选择的币种
187 - const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
188 - return curr?.symbol || '¥'
189 - }
190 -
191 - // 固定币种模式:使用 props.currency
192 - return CURRENCY_SYMBOLS[props.currency] || '¥'
193 -})
194 -
195 -/**
196 - * 币种文本(用于标签显示)
197 - * @type {ComputedRef<string>}
198 - */
199 -const currencyText = computed(() => {
200 - if (multiCurrencyEnabled.value) {
201 - const curr = supportedCurrencies.value.find(c => c.value === selectedCurrency.value)
202 - return curr?.label || ''
203 - }
204 -
205 - const CURRENCY_NAMES = {
206 - CNY: '人民币',
207 - USD: '美元',
208 - HKD: '港币',
209 - EUR: '欧元'
210 - }
211 -
212 - return CURRENCY_NAMES[props.currency] || ''
213 -})
214 -
215 -/**
216 - * 内部显示值(元)
217 - * @type {Ref<string>}
218 - */
219 -const inputValue = ref('')
220 -
221 -/**
222 - * 监听 modelValue 变化,同步到内部 inputValue
223 - * 仅当外部值与当前输入值解析结果不一致时才同步,避免输入过程中被格式化打断
224 - */
225 -watch(
226 - () => props.modelValue,
227 - (newVal) => {
228 - // 如果输入框有内容且用户正在输入,不覆盖显示值
229 - if (inputValue.value && inputValue.value !== '0.00') {
230 - // 解析当前显示值为分
231 - const currentCents = Math.round(parseFloat(inputValue.value || '0') * 100)
232 -
233 - // 如果外部值与当前输入值一致,说明是用户输入触发的更新,不需要重新格式化
234 - if (newVal === currentCents) {
235 - return
236 - }
237 - }
238 -
239 - // 外部值改变(如重置、从其他地方更新),需要同步显示值
240 - if (newVal === null || newVal === undefined) {
241 - inputValue.value = '0.00'
242 - } else if (newVal === 0) {
243 - inputValue.value = '0.00'
244 - } else {
245 - // 分 -> 元,显示格式
246 - const yuan = newVal / 100
247 - // 判断是否为整数
248 - if (Number.isInteger(yuan)) {
249 - // 整数,不添加小数点
250 - inputValue.value = yuan.toString()
251 - } else {
252 - // 有小数,保留原样
253 - inputValue.value = yuan.toString()
254 - }
255 - }
256 - },
257 - { immediate: true }
258 -)
259 -
260 -/**
261 - * 用户输入处理
262 - * @description 将用户输入的元转换为分存储
263 - * @param {string|number|Object} val - 输入值
264 - */
265 -const onInput = (val) => {
266 - let value = val
267 -
268 - // 防御性处理:如果接收到的是事件对象
269 - if (typeof val === 'object' && val !== null) {
270 - if (val.detail && typeof val.detail.value !== 'undefined') {
271 - // 小程序原生事件
272 - value = val.detail.value
273 - } else if (val.target && typeof val.target.value !== 'undefined') {
274 - // Web 原生事件
275 - value = val.target.value
276 - } else {
277 - // 无法提取值,直接返回,避免 [object Object]
278 - return
279 - }
280 - }
281 -
282 - // 确保 value 为字符串
283 - const valStr = String(value)
284 -
285 - // 移除非数字和小数点(安全处理)
286 - const cleanValue = valStr.replace(/[^\d.]/g, '')
287 -
288 - // 如果输入为空或只有小数点,显示 0.00 并重置值为 0
289 - if (cleanValue === '' || cleanValue === '.') {
290 - inputValue.value = '0.00'
291 - emit('update:modelValue', 0)
292 - return
293 - }
294 -
295 - // 更新内部显示值(保持用户原始输入,不自动添加小数点)
296 - inputValue.value = valStr
297 -
298 - // 转换为分(整数)
299 - const yuan = parseFloat(cleanValue)
300 - if (!Number.isNaN(yuan)) {
301 - emit('update:modelValue', Math.round(yuan * 100))
302 - } else {
303 - emit('update:modelValue', 0)
304 - }
305 -}
306 -
307 -/**
308 - * 失去焦点时格式化
309 - */
310 -const onBlur = () => {
311 - if (props.modelValue !== null && props.modelValue !== undefined) {
312 - inputValue.value = (props.modelValue / 100).toFixed(2)
313 - }
314 -}
315 -
316 -
317 -/**
318 - * 选择币种(多币种模式)
319 - * @param {string} value - 币种代码
320 - */
321 -const selectCurrency = (value) => {
322 - selectedCurrency.value = value
323 -}
324 -</script>
325 -
326 -<style lang="less">
327 -/* 组件样式 */
328 -</style>
This diff is collapsed. Click to expand it.
1 -<template>
2 - <div class="flex flex-col h-full bg-gray-50">
3 - <!-- Header -->
4 - <div class="flex justify-between items-center px-5 py-5 bg-white rounded-t-xl">
5 - <span class="text-lg font-normal text-gray-900">{{ title }}</span>
6 - <IconFont name="close" size="16" color="#9CA3AF" @click="handleClose" />
7 - </div>
8 -
9 - <!-- Scrollable Content -->
10 - <div class="flex-1 overflow-y-auto p-4">
11 - <div class="bg-white rounded-xl p-5 shadow-sm">
12 - <slot />
13 - </div>
14 - </div>
15 -
16 - <!-- Footer Buttons -->
17 - <div class="p-4 pt-2 pb-8 flex justify-between gap-3 bg-gray-50">
18 - <nut-button
19 - plain
20 - type="primary"
21 - class="flex-1 !h-auto !py-2.5 !text-sm"
22 - @click="handleClose"
23 - >
24 - 取消
25 - </nut-button>
26 - <nut-button
27 - type="primary"
28 - class="flex-1 !h-auto !py-2.5 !text-sm"
29 - @click="handleSubmit"
30 - >
31 - 提交申请
32 - </nut-button>
33 - </div>
34 - </div>
35 -</template>
36 -
37 -<script setup>
38 -/**
39 - * @description 计划书弹窗容器组件
40 - * @description 提供统一的头部、底部按钮和布局结构
41 - *
42 - * @props {string} title - 弹窗标题
43 - *
44 - * @emits close - 关闭弹窗事件
45 - * @emits submit - 提交事件
46 - *
47 - * @example
48 - * <PlanPopup title="申请计划书" @close="handleClose" @submit="handleSubmit">
49 - * <!-- 具体的表单内容 -->
50 - * </PlanPopup>
51 - */
52 -import IconFont from '@/components/IconFont.vue'
53 -
54 -defineProps({
55 - /** 弹窗标题 */
56 - title: {
57 - type: String,
58 - default: '计划书'
59 - }
60 -})
61 -
62 -const emit = defineEmits(['close', 'submit'])
63 -
64 -/**
65 - * 处理关闭事件
66 - */
67 -const handleClose = () => {
68 - emit('close')
69 -}
70 -
71 -/**
72 - * 处理提交事件
73 - */
74 -const handleSubmit = () => {
75 - emit('submit')
76 -}
77 -</script>
78 -
79 -<style lang="less" scoped>
80 -/* 确保 NutUI 按钮样式正确 */
81 -:deep(.nut-button) {
82 - border-radius: 0.5rem /* 8px */;
83 - font-size: 1rem /* 16px */;
84 -}
85 -</style>
1 -<template>
2 - <PlanPopup title="申请计划书" @close="close" @submit="submit">
3 - <!-- 客户姓名 -->
4 - <div class="text-sm text-gray-600 mb-2">客户姓名</div>
5 - <div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
6 - <nut-input
7 - v-model="form.name"
8 - placeholder="请输入客户姓名"
9 - class="!p-0 !bg-transparent !text-sm !text-gray-900"
10 - :border="false"
11 - />
12 - </div>
13 -
14 - <!-- 性别 -->
15 - <div class="text-sm text-gray-600 mb-2">性别</div>
16 - <nut-radio-group v-model="form.gender" direction="horizontal" class="mb-4">
17 - <nut-radio label="male" class="mr-8">男</nut-radio>
18 - <nut-radio label="female">女</nut-radio>
19 - </nut-radio-group>
20 -
21 - <!-- 年龄 -->
22 - <div class="text-sm text-gray-600 mb-2">年龄</div>
23 - <div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
24 - <nut-input
25 - v-model="form.age"
26 - type="digit"
27 - placeholder="请输入年龄"
28 - class="!p-0 !bg-transparent !text-sm !text-gray-900"
29 - :border="false"
30 - />
31 - </div>
32 -
33 - <!-- 行业 -->
34 - <div class="text-sm text-gray-600 mb-2">行业</div>
35 - <div
36 - class="flex justify-between items-center border border-gray-200 rounded-lg p-3 mb-4 overflow-hidden"
37 - @click="showIndustryPicker = true"
38 - >
39 - <span :class="form.industry ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
40 - {{ form.industry || '请选择职业' }}
41 - </span>
42 - <IconFont name="right" size="14" color="#9CA3AF" />
43 - </div>
44 -
45 - <!-- 年收入区间 -->
46 - <div class="text-sm text-gray-600 mb-2">年收入区间</div>
47 - <div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
48 - <nut-input
49 - v-model="form.income"
50 - type="digit"
51 - placeholder="请输入年收入"
52 - class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
53 - :border="false"
54 - />
55 - <span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">万元</span>
56 - </div>
57 -
58 - <!-- 家庭结构 -->
59 - <div class="text-sm text-gray-600 mb-3">家庭结构(多选)</div>
60 - <div class="flex flex-wrap gap-3 mb-5">
61 - <div
62 - v-for="item in familyOptions"
63 - :key="item.value"
64 - class="px-4 py-2 rounded-lg text-sm cursor-pointer transition-colors border"
65 - :class="form.family.includes(item.value) ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200'"
66 - @click="toggleSelection('family', item.value)"
67 - >
68 - {{ item.label }}
69 - </div>
70 - </div>
71 -
72 - <!-- 保险需求 -->
73 - <div class="text-sm text-gray-600 mb-3">保险需求(多选)</div>
74 - <div class="flex flex-wrap gap-3 mb-5">
75 - <div
76 - v-for="item in insuranceOptions"
77 - :key="item.value"
78 - class="px-4 py-2 rounded-lg text-sm cursor-pointer transition-colors border"
79 - :class="form.insurance.includes(item.value) ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200'"
80 - @click="toggleSelection('insurance', item.value)"
81 - >
82 - {{ item.label }}
83 - </div>
84 - </div>
85 -
86 - <!-- 期望收益率 -->
87 - <div class="text-sm text-gray-600 mb-2">期望收益率</div>
88 - <div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
89 - <nut-input
90 - v-model="form.returnRate"
91 - type="digit"
92 - placeholder="请输入期望收益率"
93 - class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
94 - :border="false"
95 - />
96 - <span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">%</span>
97 - </div>
98 - </PlanPopup>
99 -
100 - <!-- Industry Picker - 提升到 PlanPopup 外部,避免嵌套弹窗导致的层级问题 -->
101 - <nut-popup
102 - position="bottom"
103 - v-model:visible="showIndustryPicker"
104 - :z-index="9999"
105 - :overlay="true"
106 - >
107 - <nut-picker
108 - :columns="industryColumns"
109 - title="选择行业"
110 - @confirm="confirmIndustry"
111 - @cancel="showIndustryPicker = false"
112 - />
113 - </nut-popup>
114 -</template>
115 -
116 -<script setup>
117 -/**
118 - * @description 录入计划书 - 方案A 内容组件
119 - * @description 使用 PlanPopup 容器组件提供统一的布局和按钮
120 - *
121 - * @emits close - 关闭弹窗事件
122 - * @emits submit - 提交事件,携带表单数据
123 - */
124 -import { ref, reactive } from 'vue'
125 -import PlanPopup from './PlanPopup.vue'
126 -import IconFont from '@/components/IconFont.vue'
127 -
128 -const emit = defineEmits(['close', 'submit'])
129 -
130 -/**
131 - * 表单数据
132 - */
133 -const form = reactive({
134 - name: '',
135 - gender: '', // 'male' | 'female'
136 - age: '',
137 - industry: '',
138 - income: '',
139 - family: [],
140 - insurance: [],
141 - returnRate: ''
142 -})
143 -
144 -/**
145 - * 控制行业选择器显示
146 - */
147 -const showIndustryPicker = ref(false)
148 -
149 -/**
150 - * 行业选项
151 - */
152 -const industryColumns = [
153 - { text: 'IT/互联网', value: 'it' },
154 - { text: '金融', value: 'finance' },
155 - { text: '教育', value: 'education' },
156 - { text: '医疗', value: 'medical' },
157 - { text: '其他', value: 'other' }
158 -]
159 -
160 -/**
161 - * 家庭结构选项
162 - */
163 -const familyOptions = [
164 - { label: '配偶', value: 'spouse' },
165 - { label: '子女', value: 'children' },
166 - { label: '父母', value: 'parents' },
167 - { label: '其他', value: 'others' }
168 -]
169 -
170 -/**
171 - * 保险需求选项
172 - */
173 -const insuranceOptions = [
174 - { label: '人身保障', value: 'life' },
175 - { label: '财富传承', value: 'wealth' },
176 - { label: '子女教育', value: 'education' },
177 - { label: '养老规划', value: 'pension' }
178 -]
179 -
180 -/**
181 - * 切换多选项的选择状态
182 - * @param {string} field - 字段名
183 - * @param {string} value - 选项值
184 - */
185 -const toggleSelection = (field, value) => {
186 - const index = form[field].indexOf(value)
187 - if (index === -1) {
188 - form[field].push(value)
189 - } else {
190 - form[field].splice(index, 1)
191 - }
192 -}
193 -
194 -/**
195 - * 确认行业选择
196 - * @param {Object} params - 选择器返回参数
197 - * @param {Array} params.selectedOptions - 选中的选项
198 - */
199 -const confirmIndustry = ({ selectedOptions }) => {
200 - form.industry = selectedOptions[0].text
201 - showIndustryPicker.value = false
202 -}
203 -
204 -/**
205 - * 关闭弹窗
206 - */
207 -const close = () => {
208 - emit('close')
209 -}
210 -
211 -/**
212 - * 提交表单
213 - */
214 -const submit = () => {
215 - console.log('SchemeA Submit:', form)
216 - emit('submit', form)
217 -}
218 -</script>
219 -
220 -<style lang="less" scoped>
221 -/* Override NutUI input styles to match design */
222 -:deep(.nut-input) {
223 - padding: 0;
224 - background: transparent;
225 - border-radius: inherit;
226 -}
227 -</style>
1 -<template>
2 - <PlanPopup title="保险计划书申请" @close="close" @submit="submit">
3 - <!-- 币种 -->
4 - <div class="flex justify-between items-start mb-5">
5 - <span class="text-sm text-gray-600 mt-1.5">币种</span>
6 - <div class="bg-blue-50 rounded-md px-3 py-1.5">
7 - <span class="text-sm text-blue-600">美元保单</span>
8 - </div>
9 - </div>
10 -
11 - <!-- 计划 -->
12 - <div class="flex justify-between items-start mb-5">
13 - <span class="text-sm text-gray-600 mt-1.5">计划</span>
14 - <div class="bg-blue-50 rounded-md px-3 py-1.5">
15 - <span class="text-sm text-blue-600">基础情景</span>
16 - </div>
17 - </div>
18 -
19 - <!-- 附加计划 -->
20 - <div class="mb-5">
21 - <span class="text-base text-gray-900">附加计划</span>
22 - </div>
23 -
24 - <!-- 性别 -->
25 - <div class="text-sm text-gray-600 mb-2">性别</div>
26 - <nut-radio-group v-model="form.gender" direction="horizontal" class="mb-4">
27 - <nut-radio label="female" class="mr-8">女</nut-radio>
28 - <nut-radio label="male">男</nut-radio>
29 - </nut-radio-group>
30 -
31 - <!-- 年龄 -->
32 - <div class="text-sm text-gray-600 mb-2">年龄</div>
33 - <div class="border border-gray-200 rounded-lg mb-4 overflow-hidden">
34 - <nut-input
35 - v-model="form.age"
36 - type="digit"
37 - placeholder="请输入年龄"
38 - class="!p-0 !bg-transparent !text-sm !text-gray-900"
39 - :border="false"
40 - />
41 - </div>
42 -
43 - <!-- 保险期间 -->
44 - <div class="flex justify-between items-start mb-5">
45 - <span class="text-sm text-gray-600 mt-1.5">保险期间</span>
46 - <div class="bg-blue-50 rounded-md px-3 py-1.5">
47 - <span class="text-sm text-blue-600">终身</span>
48 - </div>
49 - </div>
50 -
51 - <!-- 交费期间 -->
52 - <div class="text-sm text-gray-600 mb-3">交费期间</div>
53 - <nut-radio-group v-model="form.paymentPeriod" direction="horizontal" class="mb-4">
54 - <nut-radio
55 - v-for="period in paymentPeriods"
56 - :key="period"
57 - :label="period"
58 - class="mr-6"
59 - >
60 - {{ period }}
61 - </nut-radio>
62 - </nut-radio-group>
63 -
64 - <!-- 年交保费 -->
65 - <div class="text-sm text-gray-600 mb-2">年交保费</div>
66 - <div class="border border-gray-200 rounded-lg mb-4 flex items-center overflow-hidden">
67 - <nut-input
68 - v-model="form.premium"
69 - type="digit"
70 - placeholder="请输入保费"
71 - class="!p-0 !bg-transparent flex-1 !text-sm !text-gray-900"
72 - :border="false"
73 - />
74 - <span class="text-sm text-gray-500 shrink-0 ml-2 mr-5">美元</span>
75 - </div>
76 - </PlanPopup>
77 -</template>
78 -
79 -<script setup>
80 -/**
81 - * @description 录入计划书 - 方案B 内容组件
82 - * @description 使用 PlanPopup 容器组件提供统一的布局和按钮
83 - *
84 - * @emits close - 关闭弹窗事件
85 - * @emits submit - 提交事件,携带表单数据
86 - */
87 -import { reactive } from 'vue'
88 -import PlanPopup from './PlanPopup.vue'
89 -
90 -const emit = defineEmits(['close', 'submit'])
91 -
92 -/**
93 - * 表单数据
94 - */
95 -const form = reactive({
96 - currency: '美元保单',
97 - plan: '基础情景',
98 - gender: 'female',
99 - age: '30',
100 - insurancePeriod: '终身',
101 - paymentPeriod: '10年交',
102 - premium: '100000'
103 -})
104 -
105 -/**
106 - * 交费期间选项
107 - */
108 -const paymentPeriods = ['10年交', '3年交', '5年交', '2年交']
109 -
110 -/**
111 - * 关闭弹窗
112 - */
113 -const close = () => {
114 - emit('close')
115 -}
116 -
117 -/**
118 - * 提交表单
119 - */
120 -const submit = () => {
121 - console.log('SchemeB Submit:', form)
122 - emit('submit', form)
123 -}
124 -</script>
125 -
126 -<style lang="less" scoped>
127 -/* Override NutUI input styles to match design */
128 -:deep(.nut-input) {
129 - padding: 0;
130 - background: transparent;
131 - border-radius: inherit;
132 -}
133 -</style>
1 -<template>
2 - <canvas
3 - type="2d"
4 - :id="canvasId"
5 - :style="`height: ${height}rpx; width:${width}rpx;
6 - position: absolute;
7 - ${debug ? '' : 'transform:translate3d(-9999rpx, 0, 0)'}`"
8 - />
9 -</template>
10 -<script>
11 -import Taro from "@tarojs/taro"
12 -import { defineComponent, onMounted, ref } from "vue"
13 -import { drawImage, drawText, drawBlock, drawLine } from "./utils/draw.js"
14 -import {
15 - toPx,
16 - toRpx,
17 - getRandomId,
18 - getImageInfo,
19 - getLinearColor,
20 -} from "./utils/tools.js"
21 -
22 -export default defineComponent({
23 - name: "PosterBuilder",
24 - props: {
25 - showLoading: {
26 - type: Boolean,
27 - default: false,
28 - },
29 - config: {
30 - type: Object,
31 - default: () => ({}),
32 - },
33 - },
34 - emits: ["success", "fail"],
35 - setup(props, context) {
36 - const count = ref(1)
37 - const {
38 - width,
39 - height,
40 - backgroundColor,
41 - texts = [],
42 - blocks = [],
43 - lines = [],
44 - debug = false,
45 - } = props.config || {}
46 -
47 - const canvasId = getRandomId()
48 -
49 - /**
50 - * step1: 初始化图片资源
51 - * @param {Array} images = imgTask
52 - * @return {Promise} downloadImagePromise
53 - */
54 - const initImages = (images) => {
55 - const imagesTemp = images.filter((item) => item.url)
56 - const drawList = imagesTemp.map((item, index) =>
57 - getImageInfo(item, index)
58 - )
59 - return Promise.all(drawList)
60 - }
61 -
62 - /**
63 - * step2: 初始化 canvas && 获取其 dom 节点和实例
64 - * @return {Promise} resolve 里返回其 dom 和实例
65 - */
66 - const initCanvas = () =>
67 - new Promise((resolve) => {
68 - setTimeout(() => {
69 - const pageInstance = Taro.getCurrentInstance()?.page || {} // 拿到当前页面实例
70 - const query = Taro.createSelectorQuery().in(pageInstance) // 确定在当前页面内匹配子元素
71 - query
72 - .select(`#${canvasId}`)
73 - .fields({ node: true, size: true, context: true }, (res) => {
74 - const canvas = res.node
75 - const ctx = canvas.getContext("2d")
76 - resolve({ ctx, canvas })
77 - })
78 - .exec()
79 - }, 300)
80 - })
81 -
82 - /**
83 - * @description 保存绘制的图片
84 - * @param { object } config
85 - */
86 - const getTempFile = (canvas) => {
87 - Taro.canvasToTempFilePath(
88 - {
89 - canvas,
90 - success: (result) => {
91 - Taro.hideLoading()
92 - context.emit("success", result)
93 - },
94 - fail: (error) => {
95 - const { errMsg } = error
96 - if (errMsg === "canvasToTempFilePath:fail:create bitmap failed") {
97 - count.value += 1
98 - if (count.value <= 3) {
99 - getTempFile(canvas)
100 - } else {
101 - Taro.hideLoading()
102 - Taro.showToast({
103 - icon: "none",
104 - title: errMsg || "绘制海报失败",
105 - })
106 - context.emit("fail", errMsg)
107 - }
108 - }
109 - },
110 - },
111 - context
112 - )
113 - }
114 -
115 - /**
116 - * step2: 开始绘制任务
117 - * @param { Array } drawTasks 待绘制任务
118 - */
119 - const startDrawing = async (drawTasks) => {
120 - // TODO: check
121 - // const configHeight = getHeight(config)
122 - const { ctx, canvas } = await initCanvas()
123 -
124 - canvas.width = width
125 - canvas.height = height
126 -
127 - // 设置画布底色
128 - if (backgroundColor) {
129 - ctx.save() // 保存绘图上下文
130 - const grd = getLinearColor(ctx, backgroundColor, 0, 0, width, height)
131 - ctx.fillStyle = grd // 设置填充颜色
132 - ctx.fillRect(0, 0, width, height) // 填充一个矩形
133 - ctx.restore() // 恢复之前保存的绘图上下文
134 - }
135 - // 将要画的方块、文字、线条放进队列数组
136 - const queue = drawTasks
137 - .concat(
138 - texts.map((item) => {
139 - item.type = "text"
140 - item.zIndex = item.zIndex || 0
141 - return item
142 - })
143 - )
144 - .concat(
145 - blocks.map((item) => {
146 - item.type = "block"
147 - item.zIndex = item.zIndex || 0
148 - return item
149 - })
150 - )
151 - .concat(
152 - lines.map((item) => {
153 - item.type = "line"
154 - item.zIndex = item.zIndex || 0
155 - return item
156 - })
157 - )
158 -
159 - queue.sort((a, b) => a.zIndex - b.zIndex) // 按照层叠顺序由低至高排序, 先画低的,再画高的
160 - for (let i = 0; i < queue.length; i++) {
161 - const drawOptions = {
162 - canvas,
163 - ctx,
164 - toPx,
165 - toRpx,
166 - }
167 - if (queue[i].type === "image") {
168 - await drawImage(queue[i], drawOptions)
169 - } else if (queue[i].type === "text") {
170 - drawText(queue[i], drawOptions)
171 - } else if (queue[i].type === "block") {
172 - drawBlock(queue[i], drawOptions)
173 - } else if (queue[i].type === "line") {
174 - drawLine(queue[i], drawOptions)
175 - }
176 - }
177 -
178 - setTimeout(() => {
179 - getTempFile(canvas) // 需要做延时才能能正常加载图片
180 - }, 300)
181 - }
182 -
183 - // start: 初始化 canvas 实例 && 下载图片资源
184 - const init = () => {
185 - if (props.showLoading)
186 - Taro.showLoading({ mask: true, title: "生成中..." })
187 - if (props.config?.images?.length) {
188 - initImages(props.config.images)
189 - .then((result) => {
190 - // 1. 下载图片资源
191 - startDrawing(result)
192 - })
193 - .catch((err) => {
194 - Taro.hideLoading()
195 - Taro.showToast({
196 - icon: "none",
197 - title: err.errMsg || "下载图片失败",
198 - })
199 - context.emit("fail", err)
200 - })
201 - } else {
202 - startDrawing([])
203 - }
204 - }
205 -
206 - onMounted(() => {
207 - init()
208 - })
209 -
210 - return {
211 - canvasId,
212 - debug,
213 - width,
214 - height,
215 - }
216 - },
217 -})
218 -</script>
1 -import { getLinearColor, getTextX, toPx } from './tools'
2 -
3 -const drawRadiusRect = ({ x, y, w, h, r }, { ctx }) => {
4 - const minSize = Math.min(w, h)
5 - if (r > minSize / 2) r = minSize / 2
6 - ctx.beginPath()
7 - ctx.moveTo(x + r, y)
8 - ctx.arcTo(x + w, y, x + w, y + h, r)
9 - ctx.arcTo(x + w, y + h, x, y + h, r)
10 - ctx.arcTo(x, y + h, x, y, r)
11 - ctx.arcTo(x, y, x + w, y, r)
12 - ctx.closePath()
13 -}
14 -
15 -const drawRadiusGroupRect = ({ x, y, w, h, g }, { ctx }) => {
16 - const [
17 - borderTopLeftRadius,
18 - borderTopRightRadius,
19 - borderBottomRightRadius,
20 - borderBottomLeftRadius
21 - ] = g
22 - ctx.beginPath()
23 - ctx.arc(
24 - x + w - borderBottomRightRadius,
25 - y + h - borderBottomRightRadius,
26 - borderBottomRightRadius,
27 - 0,
28 - Math.PI * 0.5
29 - )
30 - ctx.lineTo(x + borderBottomLeftRadius, y + h)
31 - ctx.arc(
32 - x + borderBottomLeftRadius,
33 - y + h - borderBottomLeftRadius,
34 - borderBottomLeftRadius,
35 - Math.PI * 0.5,
36 - Math.PI
37 - )
38 - ctx.lineTo(x, y + borderTopLeftRadius)
39 - ctx.arc(
40 - x + borderTopLeftRadius,
41 - y + borderTopLeftRadius,
42 - borderTopLeftRadius,
43 - Math.PI,
44 - Math.PI * 1.5
45 - )
46 - ctx.lineTo(x + w - borderTopRightRadius, y)
47 - ctx.arc(
48 - x + w - borderTopRightRadius,
49 - y + borderTopRightRadius,
50 - borderTopRightRadius,
51 - Math.PI * 1.5,
52 - Math.PI * 2
53 - )
54 - ctx.lineTo(x + w, y + h - borderBottomRightRadius)
55 - ctx.closePath()
56 -}
57 -
58 -const getTextWidth = (text, drawOptions) => {
59 - const { ctx } = drawOptions
60 - let texts = []
61 - if (Object.prototype.toString.call(text) === '[object Object]') {
62 - texts.push(text)
63 - } else {
64 - texts = text
65 - }
66 - let width = 0
67 - texts.forEach(
68 - ({
69 - fontSize,
70 - text: textStr,
71 - fontStyle = 'normal',
72 - fontWeight = 'normal',
73 - fontFamily = 'sans-serif',
74 - marginLeft = 0,
75 - marginRight = 0
76 - }) => {
77 - ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
78 - width += ctx.measureText(textStr).width + marginLeft + marginRight
79 - }
80 - )
81 - return width
82 -}
83 -
84 -const drawSingleText = (drawData, drawOptions) => {
85 - const {
86 - x = 0,
87 - y = 0,
88 - text,
89 - color,
90 - width,
91 - fontSize = 28,
92 - baseLine = 'top',
93 - textAlign = 'left',
94 - opacity = 1,
95 - textDecoration = 'none',
96 - lineNum = 1,
97 - lineHeight = 0,
98 - fontWeight = 'normal',
99 - fontStyle = 'normal',
100 - fontFamily = 'sans-serif'
101 - } = drawData
102 - const { ctx } = drawOptions
103 - ctx.save()
104 - ctx.beginPath()
105 - ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
106 - ctx.globalAlpha = opacity
107 - ctx.fillStyle = color
108 - ctx.textBaseline = baseLine
109 - ctx.textAlign = textAlign
110 - let textWidth = ctx.measureText(text).width
111 - const textArr = []
112 -
113 - if (textWidth > width) {
114 - let fillText = ''
115 - let line = 1
116 - for (let i = 0; i <= text.length - 1; i++) {
117 - fillText += text[i]
118 - const nextText = i < text.length - 1 ? fillText + text[i + 1] : fillText
119 - const restWidth = width - ctx.measureText(nextText).width
120 -
121 - if (restWidth < 0) {
122 - if (line === lineNum) {
123 - if (
124 - restWidth + ctx.measureText(text[i + 1]).width >
125 - ctx.measureText('...').width
126 - ) {
127 - fillText = `${fillText}...`
128 - } else {
129 - fillText = `${fillText.substr(0, fillText.length - 1)}...`
130 - }
131 - textArr.push(fillText)
132 - break
133 - } else {
134 - textArr.push(fillText)
135 - line++
136 - fillText = ''
137 - }
138 - } else if (i === text.length - 1) {
139 - textArr.push(fillText)
140 - }
141 - }
142 - textWidth = width
143 - } else {
144 - textArr.push(text)
145 - }
146 -
147 - textArr.forEach((item, index) =>
148 - ctx.fillText(
149 - item,
150 - getTextX(textAlign, x, width),
151 - y + (lineHeight || fontSize) * index
152 - )
153 - )
154 - ctx.restore()
155 -
156 - if (textDecoration !== 'none') {
157 - let lineY = y
158 - if (textDecoration === 'line-through') {
159 - lineY = y
160 - }
161 - ctx.save()
162 - ctx.moveTo(x, lineY)
163 - ctx.lineTo(x + textWidth, lineY)
164 - ctx.strokeStyle = color
165 - ctx.stroke()
166 - ctx.restore()
167 - }
168 - return textWidth
169 -}
170 -
171 -export function drawText(params, drawOptions) {
172 - const { x = 0, y = 0, text, baseLine } = params
173 - if (Object.prototype.toString.call(text) === '[object Array]') {
174 - const preText = { x, y, baseLine }
175 - text.forEach((item) => {
176 - preText.x += item.marginLeft || 0
177 - const textWidth = drawSingleText(
178 - Object.assign(item, { ...preText, y: y + (item.marginTop || 0) }),
179 - drawOptions
180 - )
181 - preText.x += textWidth + (item.marginRight || 0)
182 - })
183 - } else {
184 - drawSingleText(params, drawOptions)
185 - }
186 -}
187 -
188 -export function drawLine(drawData, drawOptions) {
189 - const { startX, startY, endX, endY, color, width } = drawData
190 - const { ctx } = drawOptions
191 - if (!width) return
192 - ctx.save()
193 - ctx.beginPath()
194 - ctx.strokeStyle = color
195 - ctx.lineWidth = width
196 - ctx.moveTo(startX, startY)
197 - ctx.lineTo(endX, endY)
198 - ctx.stroke()
199 - ctx.closePath()
200 - ctx.restore()
201 -}
202 -
203 -export function drawBlock(data, drawOptions) {
204 - const {
205 - x,
206 - y,
207 - text,
208 - width = 0,
209 - height,
210 - opacity = 1,
211 - paddingLeft = 0,
212 - paddingRight = 0,
213 - borderWidth,
214 - backgroundColor,
215 - borderColor,
216 - borderRadius = 0,
217 - borderRadiusGroup = null
218 - } = data || {}
219 - const { ctx } = drawOptions
220 - ctx.save()
221 - ctx.globalAlpha = opacity
222 -
223 - let blockWidth = 0
224 - let textX = 0
225 - let textY = 0
226 -
227 - if (text) {
228 - const textWidth = getTextWidth(
229 - typeof text.text === 'string' ? text : text.text,
230 - drawOptions
231 - )
232 - blockWidth = textWidth > width ? textWidth : width
233 - blockWidth += paddingLeft + paddingLeft
234 -
235 - const { textAlign = 'left' } = text
236 - textY = y
237 - textX = x + paddingLeft
238 -
239 - if (textAlign === 'center') {
240 - textX = blockWidth / 2 + x
241 - } else if (textAlign === 'right') {
242 - textX = x + blockWidth - paddingRight
243 - }
244 - drawText(Object.assign(text, { x: textX, y: textY }), drawOptions)
245 - } else {
246 - blockWidth = width
247 - }
248 -
249 - if (backgroundColor) {
250 - const grd = getLinearColor(ctx, backgroundColor, x, y, blockWidth, height)
251 - ctx.fillStyle = grd
252 -
253 - if (borderRadius > 0) {
254 - const drawData = {
255 - x,
256 - y,
257 - w: blockWidth,
258 - h: height,
259 - r: borderRadius
260 - }
261 - drawRadiusRect(drawData, drawOptions)
262 - ctx.fill()
263 - } else if (borderRadiusGroup) {
264 - const drawData = {
265 - x,
266 - y,
267 - w: blockWidth,
268 - h: height,
269 - g: borderRadiusGroup
270 - }
271 - drawRadiusGroupRect(drawData, drawOptions)
272 - ctx.fill()
273 - } else {
274 - ctx.fillRect(x, y, blockWidth, height)
275 - }
276 - }
277 -
278 - if (borderWidth && borderRadius > 0) {
279 - ctx.strokeStyle = borderColor
280 - ctx.lineWidth = borderWidth
281 - if (borderRadius > 0) {
282 - const drawData = {
283 - x,
284 - y,
285 - w: blockWidth,
286 - h: height,
287 - r: borderRadius
288 - }
289 - drawRadiusRect(drawData, drawOptions)
290 - ctx.stroke()
291 - } else {
292 - ctx.strokeRect(x, y, blockWidth, height)
293 - }
294 - }
295 - ctx.restore()
296 -}
297 -
298 -export const drawImage = (data, drawOptions) =>
299 - new Promise((resolve) => {
300 - const { canvas, ctx } = drawOptions
301 - const {
302 - x,
303 - y,
304 - w,
305 - h,
306 - sx,
307 - sy,
308 - sw,
309 - sh,
310 - imgPath,
311 - borderRadius = 0,
312 - borderWidth = 0,
313 - borderColor,
314 - borderRadiusGroup = null
315 - } = data
316 -
317 - ctx.save()
318 - if (borderRadius > 0) {
319 - drawRadiusRect(
320 - {
321 - x,
322 - y,
323 - w,
324 - h,
325 - r: borderRadius
326 - },
327 - drawOptions
328 - )
329 - ctx.clip()
330 - ctx.fill()
331 - const img = canvas.createImage()
332 - img.src = imgPath
333 - img.onload = () => {
334 - ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
335 - if (borderWidth > 0) {
336 - ctx.strokeStyle = borderColor
337 - ctx.lineWidth = borderWidth
338 - ctx.stroke()
339 - }
340 - resolve()
341 - ctx.restore()
342 - }
343 - } else if (borderRadiusGroup) {
344 - drawRadiusGroupRect(
345 - {
346 - x,
347 - y,
348 - w,
349 - h,
350 - g: borderRadiusGroup
351 - },
352 - drawOptions
353 - )
354 - ctx.clip()
355 - ctx.fill()
356 - const img = canvas.createImage()
357 - img.src = imgPath
358 - img.onload = () => {
359 - ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
360 - resolve()
361 - ctx.restore()
362 - }
363 - } else {
364 - const img = canvas.createImage()
365 - img.src = imgPath
366 - img.onload = () => {
367 - ctx.drawImage(img, toPx(sx), toPx(sy), toPx(sw), toPx(sh), x, y, w, h)
368 - resolve()
369 - ctx.restore()
370 - }
371 - }
372 - })
1 -import Taro from '@tarojs/taro'
2 -
3 -/**
4 - * @description 生成随机字符串(递归补齐长度)
5 - * @param {number} length 目标长度
6 - * @returns {string} 随机字符串
7 - */
8 -export function randomString(length) {
9 - let str = Math.random().toString(36).substr(2)
10 - if (str.length >= length) {
11 - return str.substr(0, length)
12 - }
13 - str += randomString(length - str.length)
14 - return str
15 -}
16 -
17 -/**
18 - * @description 生成随机 id(常用于 canvasId)
19 - * @param {string} prefix 前缀
20 - * @param {number} length 随机段长度
21 - * @returns {string} 随机 id
22 - */
23 -export function getRandomId(prefix = 'canvas', length = 10) {
24 - return prefix + randomString(length)
25 -}
26 -
27 -/**
28 - * @description 将 http 链接转换为 https(小程序部分场景要求 https)
29 - * @param {string} rawUrl 原始 url
30 - * @returns {string} 处理后的 url
31 - */
32 -export function mapHttpToHttps(rawUrl) {
33 - if (rawUrl.indexOf(':') < 0 || rawUrl.startsWith('http://tmp')) {
34 - return rawUrl
35 - }
36 - const urlComponent = rawUrl.split(':')
37 - if (urlComponent.length === 2) {
38 - if (urlComponent[0] === 'http') {
39 - urlComponent[0] = 'https'
40 - return `${urlComponent[0]}:${urlComponent[1]}`
41 - }
42 - }
43 - return rawUrl
44 -}
45 -
46 -/**
47 - * @description 获取 rpx 与 px 的换算系数(以 750 设计稿为基准)
48 - * @returns {number} 系数(screenWidth / 750)
49 - */
50 -export const getFactor = () => {
51 - const sysInfo = Taro.getSystemInfoSync()
52 - const { screenWidth } = sysInfo
53 - return screenWidth / 750
54 -}
55 -
56 -/**
57 - * @description rpx 转 px
58 - * @param {number} rpx rpx 值
59 - * @param {number} factor 换算系数
60 - * @returns {number} px 值(整数)
61 - */
62 -export const toPx = (rpx, factor = getFactor()) =>
63 - parseInt(String(rpx * factor), 10)
64 -
65 -/**
66 - * @description px 转 rpx
67 - * @param {number} px px 值
68 - * @param {number} factor 换算系数
69 - * @returns {number} rpx 值(整数)
70 - */
71 -export const toRpx = (px, factor = getFactor()) =>
72 - parseInt(String(px / factor), 10)
73 -
74 -/**
75 - * @description 下载图片到本地临时路径(避免跨域/协议限制)
76 - * - 已是本地路径/用户数据路径时直接返回
77 - * @param {string} url 图片地址
78 - * @returns {Promise<string>} 本地可用的图片路径
79 - */
80 -export function downImage(url) {
81 - return new Promise((resolve, reject) => {
82 - const wx_user_data_path =
83 - (typeof wx !== 'undefined' && wx && wx.env && wx.env.USER_DATA_PATH)
84 - ? wx.env.USER_DATA_PATH
85 - : ''
86 - const is_local_user_path = wx_user_data_path
87 - ? new RegExp(wx_user_data_path).test(url)
88 - : false
89 -
90 - if (/^http/.test(url) && !is_local_user_path) {
91 - Taro.downloadFile({
92 - url: mapHttpToHttps(url),
93 - success: (res) => {
94 - if (res.statusCode === 200) {
95 - resolve(res.tempFilePath)
96 - } else {
97 - reject(res)
98 - }
99 - },
100 - fail(err) {
101 - reject(err)
102 - }
103 - })
104 - } else {
105 - resolve(url)
106 - }
107 - })
108 -}
109 -
110 -/**
111 - * @description 获取图片信息并计算裁剪参数(居中裁剪)
112 - * @param {Object} item 图片配置
113 - * @param {number} index 渲染顺序(默认 zIndex)
114 - * @returns {Promise<Object>} 标准化后的图片绘制参数
115 - */
116 -export const getImageInfo = (item, index) =>
117 - new Promise((resolve, reject) => {
118 - const { x, y, width, height, url, zIndex } = item
119 - downImage(url).then((imgPath) =>
120 - Taro.getImageInfo({ src: imgPath })
121 - .then((imgInfo) => {
122 - let sx
123 - let sy
124 - const borderRadius = item.borderRadius || 0
125 - const imgWidth = toRpx(imgInfo.width)
126 - const imgHeight = toRpx(imgInfo.height)
127 - if (imgWidth / imgHeight <= width / height) {
128 - sx = 0
129 - sy = (imgHeight - (imgWidth / width) * height) / 2
130 - } else {
131 - sy = 0
132 - sx = (imgWidth - (imgHeight / height) * width) / 2
133 - }
134 - const result = {
135 - type: 'image',
136 - borderRadius,
137 - borderWidth: item.borderWidth,
138 - borderColor: item.borderColor,
139 - borderRadiusGroup: item.borderRadiusGroup,
140 - zIndex: typeof zIndex !== 'undefined' ? zIndex : index,
141 - imgPath: url,
142 - sx,
143 - sy,
144 - sw: imgWidth - sx * 2,
145 - sh: imgHeight - sy * 2,
146 - x,
147 - y,
148 - w: width,
149 - h: height
150 - }
151 - resolve(result)
152 - })
153 - .catch((err) => {
154 - reject(err)
155 - })
156 - )
157 - })
158 -
159 -/**
160 - * @description 解析 linear-gradient 字符串为 canvas 渐变色
161 - * @param {CanvasRenderingContext2D} ctx canvas 上下文
162 - * @param {string} color 颜色字符串(支持 linear-gradient(...))
163 - * @param {number} startX 起点 x
164 - * @param {number} startY 起点 y
165 - * @param {number} w 宽度
166 - * @param {number} h 高度
167 - * @returns {any} 普通颜色字符串或渐变对象
168 - */
169 -export function getLinearColor(ctx, color, startX, startY, w, h) {
170 - if (
171 - typeof startX !== 'number' ||
172 - typeof startY !== 'number' ||
173 - typeof w !== 'number' ||
174 - typeof h !== 'number'
175 - ) {
176 - return color
177 - }
178 - let grd = color
179 - if (color.includes('linear-gradient')) {
180 - const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/)
181 - const radian = colorList[1]
182 - const color1 = colorList[2]
183 - const color2 = colorList[3]
184 -
185 - const L = Math.sqrt(w * w + h * h)
186 - const x = Math.ceil(Math.sin(180 - radian) * L)
187 - const y = Math.ceil(Math.cos(180 - radian) * L)
188 -
189 - if (Number(radian) === 180 || Number(radian) === 0) {
190 - if (Number(radian) === 180) {
191 - grd = ctx.createLinearGradient(startX, startY, startX, startY + h)
192 - }
193 - if (Number(radian) === 0) {
194 - grd = ctx.createLinearGradient(startX, startY + h, startX, startY)
195 - }
196 - } else if (radian > 0 && radian < 180) {
197 - grd = ctx.createLinearGradient(startX, startY, x + startX, y + startY)
198 - } else {
199 - throw new Error('只支持0 <= 颜色弧度 <= 180')
200 - }
201 - grd.addColorStop(0, color1)
202 - grd.addColorStop(1, color2)
203 - }
204 - return grd
205 -}
206 -
207 -/**
208 - * @description 根据 textAlign 计算文本绘制起点 x
209 - * @param {'left'|'center'|'right'} textAlign 对齐方式
210 - * @param {number} x 原始 x
211 - * @param {number} width 容器宽
212 - * @returns {number} 计算后的 x
213 - */
214 -export function getTextX(textAlign, x, width) {
215 - let newX = x
216 - if (textAlign === 'center') {
217 - newX = width / 2 + x
218 - } else if (textAlign === 'right') {
219 - newX = width + x
220 - }
221 - return newX
222 -}
1 -<template>
2 - <div>
3 - <!-- 性别 -->
4 - <PlanFieldRadio
5 - v-model="form.gender"
6 - label="性别"
7 - :options="['男', '女']"
8 - />
9 -
10 - <!-- 年龄(根据出生日期自动计算,可编辑) -->
11 - <PlanFieldAgePicker
12 - v-model="form.age"
13 - label="年龄"
14 - placeholder="请选择出生日期自动计算"
15 - />
16 -
17 - <!-- 出生年月日 -->
18 - <PlanFieldDatePicker
19 - v-model="form.birthday"
20 - label="出生年月日"
21 - placeholder="请选择日期"
22 - @change="onBirthdayChange"
23 - />
24 -
25 - <!-- 是否吸烟 -->
26 - <PlanFieldRadio
27 - v-model="form.smoker"
28 - label="是否吸烟"
29 - :options="['是', '否']"
30 - />
31 -
32 - <!-- 保额 -->
33 - <PlanFieldAmount
34 - v-model="form.coverage"
35 - label="保额"
36 - placeholder="请输入保额"
37 - :currency="config.currency"
38 - />
39 -
40 - <!-- 缴费年期 -->
41 - <PlanFieldSelect
42 - v-model="form.payment_period"
43 - label="缴费年期"
44 - placeholder="请选择缴费年期"
45 - :options="config.payment_periods"
46 - />
47 -
48 - <!-- 保险期间 -->
49 - <div class="flex justify-between items-start mb-5">
50 - <span class="text-sm text-gray-600 mt-1.5">保险期间</span>
51 - <div class="bg-blue-50 rounded-md px-3 py-1.5">
52 - <span class="text-sm text-blue-600">{{ config.insurance_period }}</span>
53 - </div>
54 - </div>
55 -
56 - <!-- ====== 提取计划功能(储蓄产品专用)====== -->
57 - <div v-if="config.withdrawal_plan?.enabled" class="mt-6 pt-6 border-t border-gray-200">
58 - <div class="text-base font-medium text-gray-900 mb-4">提取计划</div>
59 -
60 - <!-- 提取方式选择 -->
61 - <PlanFieldRadio
62 - v-model="form.withdrawal_plan.mode"
63 - label="提取方式"
64 - :options="config.withdrawal_plan.withdrawal_modes"
65 - />
66 -
67 - <!-- 开始年龄 -->
68 - <PlanFieldAgePicker
69 - v-model="form.withdrawal_plan.start_age"
70 - label="开始年龄"
71 - placeholder="请选择开始提取年龄"
72 - />
73 -
74 - <!-- 提取年期 -->
75 - <PlanFieldSelect
76 - v-model="form.withdrawal_plan.withdrawal_period"
77 - label="提取年期"
78 - placeholder="请选择提取年期"
79 - :options="config.withdrawal_plan.withdrawal_periods"
80 - />
81 -
82 - <!-- 方式1:年龄指定金额 - 额外字段 -->
83 - <template v-if="form.withdrawal_plan.mode === '年龄指定金额'">
84 - <!-- 每年提取金额 -->
85 - <PlanFieldAmount
86 - v-model="form.withdrawal_plan.annual_amount"
87 - label="每年提取金额"
88 - placeholder="请输入金额"
89 - :currency="form.withdrawal_plan.currency || config.withdrawal_plan.default_currency"
90 - />
91 -
92 - <!-- 币种 -->
93 - <div class="mb-5">
94 - <div class="text-sm text-gray-600 mb-2">币种</div>
95 - <div class="flex gap-2">
96 - <button
97 - v-for="curr in currencyOptions"
98 - :key="curr.value"
99 - :class="[
100 - 'px-4 py-2 rounded-lg text-sm border transition-colors',
101 - (form.withdrawal_plan.currency || config.withdrawal_plan.default_currency) === curr.value
102 - ? 'bg-blue-600 text-white border-blue-600'
103 - : 'bg-white text-gray-600 border-gray-200'
104 - ]"
105 - @tap="selectCurrency(curr.value)"
106 - >
107 - {{ curr.label }}
108 - </button>
109 - </div>
110 - </div>
111 -
112 - <!-- 增加率 -->
113 - <div class="mb-5">
114 - <div class="text-sm text-gray-600 mb-2">增加率(%</div>
115 - <nut-input
116 - v-model="form.withdrawal_plan.increase_rate"
117 - type="digit"
118 - placeholder="请输入增加率"
119 - class="border border-gray-200 rounded-lg"
120 - />
121 - </div>
122 - </template>
123 - </div>
124 - </div>
125 -</template>
126 -
127 -<script setup>
128 -/**
129 - * 储蓄型保险计划书模版
130 - *
131 - * @description GS/GC/FA/LV2 等储蓄型保险产品的计划书录入表单
132 - * - 支持出生日期自动计算年龄
133 - * - 表单字段:性别、年龄、出生年月日、是否吸烟、保额、缴费年期
134 - * - 提取计划功能:年龄指定金额、最高固定金额
135 - * @author Claude Code
136 - * @example
137 - * <SavingsTemplate
138 - * v-model="formData"
139 - * :config="templateConfig"
140 - * />
141 - */
142 -import { reactive, watch, computed } from 'vue'
143 -import PlanFieldAgePicker from './PlanFields/AgePicker.vue'
144 -import PlanFieldAmount from './PlanFields/AmountInput.vue'
145 -import PlanFieldDatePicker from './PlanFields/DatePicker.vue'
146 -import PlanFieldRadio from './PlanFields/RadioGroup.vue'
147 -import PlanFieldSelect from './PlanFields/SelectPicker.vue'
148 -
149 -/**
150 - * 组件属性
151 - */
152 -const props = defineProps({
153 - /**
154 - * 表单数据对象
155 - * @type {Object}
156 - */
157 - modelValue: {
158 - type: Object,
159 - default: () => ({})
160 - },
161 -
162 - /**
163 - * 模版配置
164 - * @type {Object}
165 - * @property {string} currency - 币种代码
166 - * @property {Array<string>} payment_periods - 缴费年期选项
167 - * @property {Object} age_range - 年龄范围 { min, max }
168 - * @property {string} insurance_period - 保险期间
169 - * @property {Object} withdrawal_plan - 提取计划配置
170 - */
171 - config: {
172 - type: Object,
173 - required: true
174 - }
175 -})
176 -
177 -/**
178 - * 组件事件
179 - */
180 -const emit = defineEmits([
181 - /**
182 - * 更新表单数据事件
183 - * @event update:modelValue
184 - * @param {Object} value - 表单数据
185 - */
186 - 'update:modelValue'
187 -])
188 -
189 -/**
190 - * 表单数据
191 - * @type {Object}
192 - */
193 -const form = reactive(props.modelValue || {
194 - // 初始化提取计划数据
195 - withdrawal_plan: {
196 - mode: '年龄指定金额',
197 - start_age: null,
198 - withdrawal_period: null,
199 - annual_amount: null,
200 - currency: props.config?.withdrawal_plan?.default_currency || 'HKD',
201 - increase_rate: 0
202 - }
203 -})
204 -
205 -/**
206 - * 币种选项(用于提取计划)
207 - * @type {ComputedRef<Array>}
208 - */
209 -const currencyOptions = computed(() => {
210 - const CURRENCY_MAP = {
211 - HKD: { label: '港币', value: 'HKD' },
212 - USD: { label: '美元', value: 'USD' },
213 - CNY: { label: '人民币', value: 'CNY' }
214 - }
215 -
216 - const supportedCurrencies = props.config?.withdrawal_plan?.currencies || ['HKD']
217 - return supportedCurrencies
218 - .map(code => CURRENCY_MAP[code])
219 - .filter(Boolean)
220 -})
221 -
222 -/**
223 - * 监听表单数据变化,同步到父组件
224 - */
225 -watch(
226 - () => form,
227 - (newVal) => emit('update:modelValue', newVal),
228 - { deep: true }
229 -)
230 -
231 -/**
232 - * 出生日期变化时自动计算年龄
233 - * @param {string} birthday - 出生日期(格式:YYYY-MM-DD)
234 - *
235 - * @description 用户选择出生日期后,自动计算并填充年龄字段
236 - * 计算公式:当前年份 - 出生年份
237 - */
238 -const onBirthdayChange = (birthday) => {
239 - if (birthday) {
240 - const birthYear = new Date(birthday).getFullYear()
241 - const currentYear = new Date().getFullYear()
242 - const calculatedAge = currentYear - birthYear
243 -
244 - // 自动填充年龄字段
245 - form.age = calculatedAge
246 - }
247 -}
248 -
249 -/**
250 - * 选择币种(用于提取计划)
251 - * @param {string} currencyCode - 币种代码
252 - */
253 -const selectCurrency = (currencyCode) => {
254 - if (form.withdrawal_plan) {
255 - form.withdrawal_plan.currency = currencyCode
256 - }
257 -}
258 -</script>
259 -
260 -<style lang="less" scoped>
261 -/* 模版样式 */
262 -</style>
1 -<template>
2 - <view class="index-nav" :class="[`is-${position}`]">
3 - <view class="nav-logo is-home" :class="{ 'is-active': active === 'home' }" @tap="() => on_select('home')">
4 - <view class="nav-icon-wrap">
5 - <image class="nav-icon" :src="icons?.home" mode="aspectFit" />
6 - </view>
7 - <text class="nav-text">首页</text>
8 - </view>
9 -
10 - <view
11 - class="nav-logo is-code"
12 - :class="[{ 'is-active': active === 'code' }, { 'is-center-raised': center_variant === 'raised' }]"
13 - @tap="() => on_select('code')"
14 - >
15 - <view class="nav-icon-wrap">
16 - <image
17 - class="nav-icon"
18 - :class="{ 'nav-icon--raised': center_variant === 'raised' }"
19 - :src="icons?.code"
20 - mode="aspectFit"
21 - />
22 - </view>
23 - <text class="nav-text">预约码</text>
24 - </view>
25 -
26 - <view class="nav-logo is-me" :class="{ 'is-active': active === 'me' }" @tap="() => on_select('me')">
27 - <view class="nav-icon-wrap">
28 - <image class="nav-icon" :src="icons?.me" mode="aspectFit" />
29 - </view>
30 - <text class="nav-text">我的</text>
31 - </view>
32 - </view>
33 -</template>
34 -
35 -<script setup>
36 -const props = defineProps({
37 - icons: {
38 - type: Object,
39 - default: () => ({})
40 - },
41 - active: {
42 - type: String,
43 - default: ''
44 - },
45 - position: {
46 - type: String,
47 - default: 'fixed'
48 - },
49 - center_variant: {
50 - type: String,
51 - default: 'normal'
52 - },
53 - allow_active_tap: {
54 - type: Boolean,
55 - default: false
56 - }
57 -})
58 -
59 -const emit = defineEmits(['select'])
60 -
61 -const on_select = (key) => {
62 - if (!props.allow_active_tap && props.active && key === props.active) return
63 - emit('select', key)
64 -}
65 -</script>
66 -
67 -<style lang="less">
68 -.index-nav {
69 - left: 0;
70 - bottom: 0;
71 - width: 750rpx;
72 - height: calc(134rpx + constant(safe-area-inset-bottom));
73 - height: calc(134rpx + env(safe-area-inset-bottom));
74 - padding-bottom: calc(0rpx + constant(safe-area-inset-bottom));
75 - padding-bottom: calc(0rpx + env(safe-area-inset-bottom));
76 - box-sizing: border-box;
77 - background: #FFFFFF;
78 - box-shadow: 0 -8rpx 8rpx 0 rgba(0, 0, 0, 0.1);
79 - display: flex;
80 - align-items: flex-end;
81 - justify-content: space-around;
82 - color: #A67939;
83 - z-index: 99;
84 -
85 - &.is-fixed {
86 - position: fixed;
87 - }
88 -
89 - &.is-absolute {
90 - position: absolute;
91 - }
92 -
93 - .nav-logo {
94 - position: relative;
95 - display: flex;
96 - flex-direction: column;
97 - align-items: center;
98 - }
99 -
100 - .nav-icon-wrap {
101 - position: relative;
102 - width: 56rpx;
103 - height: 56rpx;
104 - display: flex;
105 - align-items: center;
106 - justify-content: center;
107 - }
108 -
109 - .nav-icon {
110 - width: 56rpx;
111 - height: 56rpx;
112 - display: block;
113 -
114 - &.nav-icon--raised {
115 - width: 140rpx;
116 - height: 140rpx;
117 - position: absolute;
118 - top: -100rpx;
119 - left: 50%;
120 - transform: translateX(-50%);
121 - }
122 - }
123 -
124 - .nav-logo.is-home,
125 - .nav-logo.is-me {
126 - .nav-icon {
127 - width: 56rpx;
128 - height: 56rpx;
129 - }
130 - }
131 -
132 - .nav-text {
133 - font-size: 26rpx;
134 - margin-top: 12rpx;
135 - line-height: 1;
136 - }
137 -}
138 -</style>
1 -<!--
2 - * @Date: 2024-01-16 10:06:47
3 - * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-14 21:57:40
5 - * @FilePath: /xyxBooking-weapp/src/components/qrCodeSearch.vue
6 - * @Description: 预约码卡组件
7 --->
8 -<template>
9 - <view class="qr-code-page">
10 - <view v-if="userinfo.qr_code" class="show-qrcode">
11 - <view class="qrcode-content">
12 - <view class="user-info">{{ userinfo.name }}&nbsp;{{ userinfo.id }}</view>
13 - <view class="user-qrcode">
14 - <view class="left">
15 - <!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%B7%A6@2x.png"> -->
16 - </view>
17 - <view class="center">
18 - <image :src="userinfo.qr_code_url" mode="aspectFit" />
19 - <view v-if="useStatus === STATUS_CODE.CANCELED || useStatus === STATUS_CODE.USED" class="qrcode-used">
20 - <view class="overlay"></view>
21 - <text class="status-text">二维码{{ qr_code_status[useStatus] }}</text>
22 - </view>
23 - </view>
24 - <view class="right">
25 - <!-- <image src="https://cdn.ipadbiz.cn/xys/booking/%E5%8F%B3@2x.png"> -->
26 - </view>
27 - </view>
28 - <view style="color: red; margin-top: 32rpx;">{{ userinfo.datetime }}</view>
29 - </view>
30 - </view>
31 - <view v-else class="no-qrcode">
32 - <image src="https://cdn.ipadbiz.cn/xys/booking/%E6%9A%82%E6%97%A0@2x.png" style="width: 320rpx; height: 320rpx;" />
33 - <view class="no-qrcode-title">您还没有预约过今天参观</view>
34 - </view>
35 - </view>
36 -</template>
37 -
38 -<script setup>
39 -import { ref, onMounted, watch, onUnmounted } from 'vue'
40 -import { formatDatetime } from '@/utils/tools';
41 -import { qrcodeStatusAPI, queryQrCodeAPI } from '@/api/index'
42 -import BASE_URL from '@/utils/config';
43 -
44 -const props = defineProps({
45 - id: {
46 - type: String,
47 - default: ''
48 - },
49 - id_type: {
50 - type: Number,
51 - default: 1
52 - }
53 -});
54 -
55 -const userinfo = ref({});
56 -
57 -const replaceMiddleCharacters = (input_string) => {
58 - if (!input_string || input_string.length < 15) {
59 - return input_string;
60 - }
61 - const start = Math.floor((input_string.length - 8) / 2);
62 - const end = start + 8;
63 - const replacement = '*'.repeat(8);
64 - return input_string.substring(0, start) + replacement + input_string.substring(end);
65 -}
66 -
67 -const formatId = (id) => replaceMiddleCharacters(id);
68 -
69 -const useStatus = ref('0');
70 -const is_loading = ref(false)
71 -let is_destroyed = false
72 -
73 -const qr_code_status = {
74 - '1': '未激活',
75 - '3': '待使用',
76 - '5': '被取消',
77 - '7': '已使用',
78 -};
79 -
80 -const STATUS_CODE = {
81 - APPLY: '1',
82 - SUCCESS: '3',
83 - CANCELED: '5',
84 - USED: '7',
85 -};
86 -
87 -const build_qr_code_url = (qr_code) => {
88 - if (!qr_code) return ''
89 - return `${BASE_URL}/admin?m=srv&a=get_qrcode&key=${encodeURIComponent(String(qr_code))}`
90 -}
91 -
92 -/**
93 - * @description: 格式化预约码卡数据
94 - * @param {*} raw 原始数据
95 - * @return {*} 格式化后的数据
96 - */
97 -
98 -const normalize_item = (raw) => {
99 - if (!raw || typeof raw !== 'object') return null
100 - const qr_code = raw.qr_code ? String(raw.qr_code) : ''
101 - const id_number = raw.id_number ? String(raw.id_number) : ''
102 - return {
103 - ...raw,
104 - qr_code,
105 - qr_code_url: build_qr_code_url(qr_code),
106 - datetime: formatDatetime({ begin_time: raw.begin_time, end_time: raw.end_time }),
107 - id: formatId(id_number),
108 - }
109 -}
110 -
111 -/**
112 - * @description: 重置状态
113 - */
114 -
115 -const reset_state = () => {
116 - userinfo.value = {}
117 - useStatus.value = '0'
118 -}
119 -
120 -/**
121 - * @description: 加载预约码卡状态
122 - * @param {*} qr_code 预约码
123 - * @return {*} 状态码
124 - */
125 -
126 -const load_qr_code_status = async (qr_code) => {
127 - if (!qr_code) return
128 - const res = await qrcodeStatusAPI({ qr_code })
129 - if (is_destroyed) return
130 - if (!res || res.code !== 1) return
131 - const status = res?.data?.status
132 - if (status === undefined || status === null) return
133 - useStatus.value = String(status)
134 -}
135 -
136 -/**
137 - * @description: 加载预约码卡信息
138 - * @param {*} id_number 身份证号
139 - * @return {*} 预约码卡信息
140 - */
141 -
142 -const load_qr_code_info = async (id_number) => {
143 - const id = String(id_number || '').trim()
144 - if (!id) {
145 - reset_state()
146 - return
147 - }
148 -
149 - is_loading.value = true
150 - const params = { id_number: id }
151 - if (props.id_type) params.id_type = props.id_type
152 - const res = await queryQrCodeAPI(params)
153 - if (is_destroyed) return
154 - is_loading.value = false
155 -
156 - if (!res || res.code !== 1 || !res.data) {
157 - reset_state()
158 - return
159 - }
160 -
161 - const raw = Array.isArray(res.data) ? res.data[0] : res.data
162 - const item = normalize_item(raw)
163 - if (!item || !item.qr_code) {
164 - reset_state()
165 - return
166 - }
167 -
168 - userinfo.value = item
169 - await load_qr_code_status(item.qr_code)
170 -}
171 -
172 -onUnmounted(() => {
173 - is_destroyed = true
174 -})
175 -
176 -onMounted(() => {
177 - load_qr_code_info(props.id)
178 -})
179 -
180 -watch(
181 - () => [props.id, props.id_type],
182 - ([val]) => {
183 - if (is_loading.value) return
184 - load_qr_code_info(val)
185 - }
186 -)
187 -</script>
188 -
189 -<style lang="less">
190 -.qr-code-page {
191 - .qrcode-content {
192 - padding: 32rpx 0;
193 - display: flex;
194 - flex-direction: column;
195 - justify-content: center;
196 - align-items: center;
197 - background-color: #FFF;
198 - border-radius: 16rpx;
199 - box-shadow: 0 0 29rpx 0 rgba(106,106,106,0.27);
200 -
201 - .user-info {
202 - color: #A6A6A6;
203 - font-size: 37rpx;
204 - margin-top: 16rpx;
205 - margin-bottom: 16rpx;
206 - }
207 - .user-qrcode {
208 - display: flex;
209 - align-items: center;
210 - .center {
211 - border: 2rpx solid #D1D1D1;
212 - border-radius: 40rpx;
213 - padding: 16rpx;
214 - position: relative;
215 - image {
216 - width: 480rpx; height: 480rpx;
217 - }
218 - .qrcode-used {
219 - position: absolute;
220 - top: 0;
221 - left: 0;
222 - right: 0;
223 - bottom: 0;
224 - border-radius: 40rpx;
225 - overflow: hidden;
226 -
227 - .overlay {
228 - width: 100%;
229 - height: 100%;
230 - background-image: url('https://cdn.ipadbiz.cn/xys/booking/southeast.jpeg');
231 - background-size: contain;
232 - opacity: 0.9;
233 - }
234 -
235 - .status-text {
236 - color: #A67939;
237 - position: absolute;
238 - top: 50%;
239 - left: 50%;
240 - transform: translate(-50%, -50%);
241 - font-size: 38rpx;
242 - white-space: nowrap;
243 - font-weight: bold;
244 - z-index: 10;
245 - }
246 - }
247 - }
248 - }
249 - }
250 -
251 - .no-qrcode {
252 - display: flex;
253 - justify-content: center;
254 - align-items: center;
255 - flex-direction: column;
256 - margin-bottom: 32rpx;
257 -
258 - .no-qrcode-title {
259 - color: #A67939;
260 - font-size: 34rpx;
261 - }
262 - }
263 -}
264 -</style>
1 -var getDaysInOneMonth = function (year, month) {
2 - let _month = parseInt(month, 10);
3 - let d = new Date(year, _month, 0);
4 - return d.getDate();
5 -}
6 -var dateDate = function (date) {
7 - let year = date && date.getFullYear();
8 - let month = date && date.getMonth() + 1;
9 - let day = date && date.getDate();
10 - let hours = date && date.getHours();
11 - let minutes = date && date.getMinutes();
12 - return {
13 - year, month, day, hours, minutes
14 - }
15 -}
16 -var dateTimePicker = function (startyear, endyear) {
17 - // 获取date time 年份,月份,天数,小时,分钟推后30分
18 - const years = [];
19 - const months = [];
20 - const hours = [];
21 - const minutes = [];
22 - for (let i = startyear; i <= endyear; i++) {
23 - years.push({
24 - name: i + '年',
25 - id: i
26 - });
27 - }
28 - //获取月份
29 - for (let i = 1; i <= 12; i++) {
30 - if (i < 10) {
31 - i = "0" + i;
32 - }
33 - months.push({
34 - name: i + '月',
35 - id: i
36 - });
37 - }
38 - //获取小时
39 - for (let i = 0; i < 24; i++) {
40 - if (i < 10) {
41 - i = "0" + i;
42 - }
43 - hours.push({
44 - name: i + '时',
45 - id: i
46 - });
47 - }
48 - //获取分钟
49 - for (let i = 0; i < 60; i++) {
50 - if (i < 10) {
51 - i = "0" + i;
52 - }
53 - minutes.push({
54 - name: i + '分',
55 - id: i
56 - });
57 - }
58 - return function (_year, _month) {
59 - const days = [];
60 - _year = parseInt(_year);
61 - _month = parseInt(_month);
62 - //获取日期
63 - for (let i = 1; i <= getDaysInOneMonth(_year, _month); i++) {
64 - if (i < 10) {
65 - i = "0" + i;
66 - }
67 - days.push({
68 - name: i + '日',
69 - id: i
70 - });
71 - }
72 - return [years, months, days, hours, minutes];
73 - }
74 -}
75 -export {
76 - dateTimePicker,
77 - getDaysInOneMonth,
78 - dateDate
79 -}
1 -<template>
2 - <picker mode="multiSelector" :range-key="'name'" :value="timeIndex" :range="activityArray" :disabled="disabled"
3 - @change="bindMultiPickerChange" @columnChange="bindMultiPickerColumnChange">
4 - <slot />
5 - </picker>
6 -</template>
7 -<script>
8 -import { dateTimePicker, dateDate } from "./dateTimePicker.js";
9 -export default {
10 - name: "TimePickerDataPicker",
11 - props: {
12 - startTime: {
13 - type: [Object, Date],
14 - default: new Date(),
15 - },
16 - endTime: {
17 - type: [Object, Date],
18 - default: new Date(),
19 - },
20 - defaultTime: {
21 - type: [Object, Date],
22 - default: new Date(),
23 - },
24 - disabled: {
25 - type: Boolean,
26 - default: false,
27 - },
28 - },
29 - data() {
30 - return {
31 - timeIndex: [0, 0, 0, 0, 0],
32 - activityArray: [],
33 - year: 0,
34 - month: 1,
35 - day: 1,
36 - hour: 0,
37 - minute: 0,
38 - datePicker: "",
39 - defaultIndex: [0, 0, 0, 0, 0],
40 - startIndex: [0, 0, 0, 0, 0],
41 - endIndex: [0, 0, 0, 0, 0],
42 - };
43 - },
44 - computed: {
45 - timeDate() {
46 - const { startTime, endTime } = this;
47 - return { startTime, endTime };
48 - },
49 - },
50 - watch: {
51 - timeDate() {
52 - this.initData();
53 - },
54 - defaultTime () {
55 - this.initData();
56 - }
57 - },
58 - created() {
59 - this.initData();
60 - },
61 - methods: {
62 - initData() {
63 - let startTime = this.startTime;
64 - let endTime = this.endTime;
65 - this.datePicker = dateTimePicker(
66 - startTime.getFullYear(),
67 - endTime.getFullYear()
68 - );
69 - this.setDateData(this.defaultTime);
70 - this.getKeyIndex(this.startTime, "startIndex");
71 - // 截止时间索引
72 - this.getKeyIndex(this.endTime, "endIndex");
73 - // 默认索引
74 - this.getKeyIndex(this.defaultTime, "defaultIndex");
75 - this.timeIndex = this.defaultIndex;
76 - // 初始时间
77 - this.initTime();
78 - },
79 - getKeyIndex(time, key) {
80 - let Arr = dateDate(time);
81 - let _index = this.getIndex(Arr);
82 - this[key] = _index;
83 - },
84 - getIndex(arr) {
85 - let timeIndex = [];
86 - let indexKey = ["year", "month", "day", "hours", "minutes"];
87 - this.activityArray.forEach((element, index) => {
88 - let _index = element.findIndex(
89 - (item) => parseInt(item.id) === parseInt(arr[indexKey[index]])
90 - );
91 - timeIndex[index] = _index >= 0 ? _index : 0;
92 - });
93 - return timeIndex;
94 - },
95 - initTime() {
96 - let _index = this.timeIndex;
97 - this.year = this.activityArray[0][_index[0]].id;
98 - this.month = this.activityArray[1].length && this.activityArray[1][_index[1]].id;
99 - this.day = this.activityArray[2].length && this.activityArray[2][_index[2]].id;
100 - this.hour = this.activityArray[3].length && this.activityArray[3][_index[3]].id;
101 - this.minute = this.activityArray[4].length && this.activityArray[4][_index[4]].id;
102 - },
103 - setDateData(_date) {
104 - let _data = dateDate(_date);
105 - this.activityArray = this.datePicker(_data.year, _data.month);
106 - },
107 - bindMultiPickerChange(e) {
108 - console.log("picker发送选择改变,携带值为", e.detail.value);
109 - let activityArray = JSON.parse(JSON.stringify(this.activityArray)),
110 - { value } = e.detail,
111 - _result = [];
112 - for (let i = 0; i < value.length; i++) {
113 - _result[i] = activityArray[i][value[i]].id;
114 - }
115 - this.$emit("result", _result);
116 - },
117 - bindMultiPickerColumnChange(e) {
118 - console.log("修改的列为", e.detail.column, ",值为", e.detail.value);
119 - let _data = JSON.parse(JSON.stringify(this.activityArray)),
120 - timeIndex = JSON.parse(JSON.stringify(this.timeIndex)),
121 - { startIndex, endIndex } = this,
122 - { column, value } = e.detail,
123 - _value = _data[column][value].id,
124 - _start = dateDate(this.startTime),
125 - _end = dateDate(this.endTime);
126 - switch (e.detail.column) {
127 - case 0:
128 - if (_value <= _start.year) {
129 - timeIndex = startIndex;
130 - this.year = _start.year;
131 - this.setDateData(this.startTime);
132 - } else if (_value >= _end.year) {
133 - this.year = _end.year;
134 - timeIndex = [endIndex[0], 0, 0, 0, 0];
135 - this.setDateData(this.endTime);
136 - } else {
137 - this.year = _value;
138 - timeIndex = [value, 0, 0, 0, 0];
139 - this.activityArray = this.datePicker(_value, 1);
140 - }
141 - timeIndex = this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
142 - this.timeIndex = timeIndex;
143 - break;
144 - case 1:
145 - if (this.year == _start.year && value <= startIndex[1]) {
146 - timeIndex = startIndex;
147 - this.month = _start.month;
148 - this.setDateData(this.startTime);
149 - } else if (this.year == _end.year && value >= endIndex[1]) {
150 - timeIndex = endIndex;
151 - this.month = _end.month;
152 - this.setDateData(this.endTime);
153 - } else {
154 - this.month = _value;
155 - _data[2] = this.datePicker(this.year, this.month)[2];
156 - timeIndex = [timeIndex[0], value, 0, 0, 0];
157 - this.activityArray = _data;
158 - }
159 - this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
160 - break;
161 - case 2:
162 - if (
163 - this.year == _start.year &&
164 - this.month == _start.month &&
165 - value <= startIndex[2]
166 - ) {
167 - this.day = _start.day;
168 - timeIndex = startIndex;
169 - } else if (
170 - this.year == _end.year &&
171 - this.month == _end.month &&
172 - value >= endIndex[2]
173 - ) {
174 - this.day = _end.day;
175 - timeIndex = endIndex;
176 - } else {
177 - this.day = _value;
178 - timeIndex = [timeIndex[0], timeIndex[1], value, 0, 0];
179 - }
180 - this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
181 - break;
182 - case 3:
183 - if (
184 - this.year == _start.year &&
185 - this.month == _start.month &&
186 - this.day == _start.day &&
187 - value <= startIndex[3]
188 - ) {
189 - this.hour = _start.hours;
190 - timeIndex = startIndex;
191 - } else if (
192 - this.year == _end.year &&
193 - this.month == _end.month &&
194 - this.day == _end.day &&
195 - value >= endIndex[3]
196 - ) {
197 - this.hour = _end.hours;
198 - timeIndex = endIndex;
199 - } else {
200 - this.hour = _value;
201 - timeIndex[3] = value;
202 - timeIndex[4] = 0;
203 - }
204 - this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
205 - break;
206 - case 4:
207 - timeIndex[4] = value;
208 - if (
209 - this.year == _start.year &&
210 - this.month == _start.month &&
211 - this.day == _start.day &&
212 - this.hour == _start.hours &&
213 - value <= startIndex[4]
214 - ) {
215 - timeIndex = startIndex;
216 - } else if (
217 - this.year == _end.year &&
218 - this.month == _end.month &&
219 - this.day == _end.day &&
220 - this.hour == _end.hours &&
221 - value >= endIndex[4]
222 - ) {
223 - timeIndex = endIndex;
224 - }
225 - this.timeIndex = JSON.parse(JSON.stringify(timeIndex));
226 - break;
227 - }
228 - },
229 - },
230 -};
231 -</script>