refactor(ui): 清理未使用和重复的组件
- 删除未使用组件: indexNav, qrCodeSearch, time-picker-data, PosterBuilder - 删除旧版计划组件: PlanSchemes (SchemeA, SchemeB, PlanPopup) - 删除重复组件: 旧版 SavingsTemplate 和 AmountInput - 统一使用 AmountKeyboard 作为金额输入组件 - 总计删除 13 个文件,约 1500-2000 行代码
Showing
14 changed files
with
10 additions
and
2560 deletions
| ... | @@ -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 | -} |
src/components/SavingsTemplate.vue
deleted
100644 → 0
| 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> |
src/components/indexNav.vue
deleted
100644 → 0
| 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> |
src/components/qrCodeSearch.vue
deleted
100644 → 0
| 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 }} {{ 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> |
-
Please register or login to post a comment