feat(familyForm): 新增家庭报名表单模块
添加家庭报名表单页面、路由、服务及模拟数据 - 创建表单页面,包含幼儿园班级选择、参赛人数、孩童姓名、手机号、证件登记和服装尺码等字段 - 添加路由配置,支持 /family-form 路径访问 - 实现服务层,提供获取学校选项和提交报名信息的功能 - 添加模拟数据,包含幼儿园列表和本地存储的报名记录
Showing
4 changed files
with
503 additions
and
0 deletions
src/views/familyForm/index.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="family-form-page"> | ||
| 3 | + <van-config-provider :theme-vars="themeVars"> | ||
| 4 | + <div class="form-card"> | ||
| 5 | + <header class="page-header"> | ||
| 6 | + <h1>[新]第六届“益起走”幼儿园公益践行赛报名</h1> | ||
| 7 | + <p>感谢参加本场活动!</p> | ||
| 8 | + </header> | ||
| 9 | + | ||
| 10 | + <van-form | ||
| 11 | + scroll-to-error | ||
| 12 | + scroll-to-error-position="center" | ||
| 13 | + validate-first | ||
| 14 | + @submit="saveForm" | ||
| 15 | + @failed="handleSubmitFailed" | ||
| 16 | + > | ||
| 17 | + <section class="form-section"> | ||
| 18 | + <div class="field-block required"> | ||
| 19 | + <div class="field-label">所在幼儿园班级</div> | ||
| 20 | + <div class="school-row"> | ||
| 21 | + <van-field | ||
| 22 | + v-model="form.kg_name" | ||
| 23 | + readonly | ||
| 24 | + is-link | ||
| 25 | + name="kg_name" | ||
| 26 | + placeholder="请选择幼儿园" | ||
| 27 | + :rules="[{ required: true, message: '请选择幼儿园' }]" | ||
| 28 | + @click="showSchoolPicker = true" | ||
| 29 | + /> | ||
| 30 | + <van-field | ||
| 31 | + v-model="form.class_name" | ||
| 32 | + readonly | ||
| 33 | + is-link | ||
| 34 | + name="class_name" | ||
| 35 | + placeholder="请选择班级" | ||
| 36 | + :rules="[{ required: true, message: '请选择班级' }]" | ||
| 37 | + @click="openClassPicker" | ||
| 38 | + /> | ||
| 39 | + </div> | ||
| 40 | + </div> | ||
| 41 | + | ||
| 42 | + <div class="field-block required"> | ||
| 43 | + <div class="field-label">活动当天参赛的孩童数量(输入大于等于1的数字)</div> | ||
| 44 | + <van-field | ||
| 45 | + v-model="form.child_count" | ||
| 46 | + name="child_count" | ||
| 47 | + type="digit" | ||
| 48 | + placeholder="请输入孩童数量" | ||
| 49 | + :rules="[{ validator: childCountValidator, message: '请输入大于等于1的数字' }]" | ||
| 50 | + /> | ||
| 51 | + </div> | ||
| 52 | + | ||
| 53 | + <div class="field-block required"> | ||
| 54 | + <div class="field-label">活动当天参赛的家长数量(请输入1-2之间的数字)</div> | ||
| 55 | + <van-field | ||
| 56 | + v-model="form.parent_count" | ||
| 57 | + name="parent_count" | ||
| 58 | + type="digit" | ||
| 59 | + placeholder="请输入家长数量" | ||
| 60 | + :rules="[{ validator: parentCountValidator, message: '请输入1-2之间的数字' }]" | ||
| 61 | + /> | ||
| 62 | + </div> | ||
| 63 | + | ||
| 64 | + <div class="field-block required"> | ||
| 65 | + <div class="field-label">参赛孩童姓名</div> | ||
| 66 | + <p class="field-desc">如果有2位小朋友参赛,可以用空格间隔孩童姓名,例:申小花 申小朵</p> | ||
| 67 | + <van-field | ||
| 68 | + v-model="form.child_names" | ||
| 69 | + name="child_names" | ||
| 70 | + placeholder="请输入参赛孩童姓名" | ||
| 71 | + :rules="[{ required: true, message: '请输入参赛孩童姓名' }]" | ||
| 72 | + /> | ||
| 73 | + </div> | ||
| 74 | + | ||
| 75 | + <div class="field-block required"> | ||
| 76 | + <div class="field-label">手机</div> | ||
| 77 | + <p class="field-desc">每组家庭填写一个手机号码,该号码将用于登录专属小程序支付报名费、募款、并在活动当天在线打卡等。</p> | ||
| 78 | + <van-field | ||
| 79 | + v-model="form.phone" | ||
| 80 | + name="phone" | ||
| 81 | + type="tel" | ||
| 82 | + maxlength="11" | ||
| 83 | + placeholder="请输入手机号" | ||
| 84 | + :rules="[ | ||
| 85 | + { required: true, message: '请输入手机号' }, | ||
| 86 | + { validator: phoneValidator, message: '请输入正确手机号' } | ||
| 87 | + ]" | ||
| 88 | + /> | ||
| 89 | + </div> | ||
| 90 | + | ||
| 91 | + <div class="field-block required"> | ||
| 92 | + <div class="field-label">证件登记</div> | ||
| 93 | + <p class="field-desc">请准确填写参赛人员姓名和证件信息,用于购买赛事保险;如证件为非身份证,请填写出生年月日。</p> | ||
| 94 | + <p class="field-desc">填写格式:<br>姓名1 证件号1<br>姓名2 证件号2</p> | ||
| 95 | + <van-field | ||
| 96 | + v-model="form.certificate_info" | ||
| 97 | + name="certificate_info" | ||
| 98 | + type="textarea" | ||
| 99 | + rows="4" | ||
| 100 | + autosize | ||
| 101 | + placeholder="请输入证件登记信息" | ||
| 102 | + :rules="[{ required: true, message: '请输入证件登记信息' }]" | ||
| 103 | + /> | ||
| 104 | + </div> | ||
| 105 | + </section> | ||
| 106 | + | ||
| 107 | + <section class="form-section clothes-section"> | ||
| 108 | + <div class="section-title">服装尺码登记</div> | ||
| 109 | + <p class="section-desc">在需要的尺码里,填写数量</p> | ||
| 110 | + | ||
| 111 | + <div v-for="item in clothesFields" :key="item.field" class="field-block"> | ||
| 112 | + <div class="field-label">{{ item.label }}</div> | ||
| 113 | + <van-field | ||
| 114 | + v-model="form[item.field]" | ||
| 115 | + :name="item.field" | ||
| 116 | + type="digit" | ||
| 117 | + :placeholder="`请输入${item.label}数量`" | ||
| 118 | + /> | ||
| 119 | + </div> | ||
| 120 | + </section> | ||
| 121 | + | ||
| 122 | + <div class="submit-bar"> | ||
| 123 | + <van-button block round type="primary" native-type="submit" :loading="saveLoading">保存</van-button> | ||
| 124 | + </div> | ||
| 125 | + </van-form> | ||
| 126 | + </div> | ||
| 127 | + | ||
| 128 | + <van-popup v-model:show="showSchoolPicker" round position="bottom"> | ||
| 129 | + <van-picker | ||
| 130 | + :columns="schoolOnlyColumns" | ||
| 131 | + @cancel="showSchoolPicker = false" | ||
| 132 | + @confirm="onSchoolConfirm" | ||
| 133 | + /> | ||
| 134 | + </van-popup> | ||
| 135 | + | ||
| 136 | + <van-popup v-model:show="showClassPicker" round position="bottom"> | ||
| 137 | + <van-picker | ||
| 138 | + :columns="classColumns" | ||
| 139 | + @cancel="showClassPicker = false" | ||
| 140 | + @confirm="onClassConfirm" | ||
| 141 | + /> | ||
| 142 | + </van-popup> | ||
| 143 | + </van-config-provider> | ||
| 144 | + </div> | ||
| 145 | +</template> | ||
| 146 | + | ||
| 147 | +<script setup> | ||
| 148 | +import { computed, onMounted, reactive, ref } from 'vue'; | ||
| 149 | +import { showDialog, showFailToast, showSuccessToast } from 'vant'; | ||
| 150 | +import { styleColor } from '@/constant.js'; | ||
| 151 | +import { | ||
| 152 | + fetchSchoolOptions, | ||
| 153 | + submitRegistration | ||
| 154 | +} from './service'; | ||
| 155 | + | ||
| 156 | +const themeVars = { | ||
| 157 | + buttonPrimaryBackground: styleColor.baseColor, | ||
| 158 | + buttonPrimaryBorderColor: styleColor.baseColor, | ||
| 159 | + buttonPrimaryColor: styleColor.baseFontColor | ||
| 160 | +}; | ||
| 161 | + | ||
| 162 | +const defaultForm = { | ||
| 163 | + kg_id: '', | ||
| 164 | + kg_name: '', | ||
| 165 | + class_id: '', | ||
| 166 | + class_name: '', | ||
| 167 | + child_count: '', | ||
| 168 | + parent_count: '', | ||
| 169 | + child_names: '', | ||
| 170 | + phone: '', | ||
| 171 | + certificate_info: '', | ||
| 172 | + clothes_child_110: '', | ||
| 173 | + clothes_child_120: '', | ||
| 174 | + clothes_child_130: '', | ||
| 175 | + clothes_adult_xs: '', | ||
| 176 | + clothes_adult_s: '', | ||
| 177 | + clothes_adult_m: '', | ||
| 178 | + clothes_adult_l: '' | ||
| 179 | +}; | ||
| 180 | + | ||
| 181 | +const clothesFields = [ | ||
| 182 | + { label: '孩童110码', field: 'clothes_child_110' }, | ||
| 183 | + { label: '孩童120码', field: 'clothes_child_120' }, | ||
| 184 | + { label: '孩童130码', field: 'clothes_child_130' }, | ||
| 185 | + { label: '成年XS码', field: 'clothes_adult_xs' }, | ||
| 186 | + { label: '成年S码', field: 'clothes_adult_s' }, | ||
| 187 | + { label: '成年M码', field: 'clothes_adult_m' }, | ||
| 188 | + { label: '成年L码', field: 'clothes_adult_l' } | ||
| 189 | +]; | ||
| 190 | + | ||
| 191 | +const form = reactive({ ...defaultForm }); | ||
| 192 | +const schoolColumns = ref([]); | ||
| 193 | +const showSchoolPicker = ref(false); | ||
| 194 | +const showClassPicker = ref(false); | ||
| 195 | +const saveLoading = ref(false); | ||
| 196 | + | ||
| 197 | +const currentSchool = computed(() => schoolColumns.value.find((item) => item.value === form.kg_id)); | ||
| 198 | +const classColumns = computed(() => currentSchool.value?.children || []); | ||
| 199 | +const schoolOnlyColumns = computed(() => schoolColumns.value.map((item) => ({ | ||
| 200 | + text: item.text, | ||
| 201 | + value: item.value | ||
| 202 | +}))); | ||
| 203 | + | ||
| 204 | +const childCountValidator = (value) => Number(value) >= 1; | ||
| 205 | +const parentCountValidator = (value) => { | ||
| 206 | + const count = Number(value); | ||
| 207 | + return count >= 1 && count <= 2; | ||
| 208 | +}; | ||
| 209 | +const phoneValidator = (value) => /^1\d{10}$/.test(value); | ||
| 210 | + | ||
| 211 | +const openClassPicker = () => { | ||
| 212 | + if (!form.kg_id) { | ||
| 213 | + showFailToast('请先选择幼儿园'); | ||
| 214 | + return; | ||
| 215 | + } | ||
| 216 | + showClassPicker.value = true; | ||
| 217 | +}; | ||
| 218 | + | ||
| 219 | +const onSchoolConfirm = ({ selectedOptions }) => { | ||
| 220 | + const school = selectedOptions[0]; | ||
| 221 | + showSchoolPicker.value = false; | ||
| 222 | + form.kg_id = school.value; | ||
| 223 | + form.kg_name = school.text; | ||
| 224 | + form.class_id = ''; | ||
| 225 | + form.class_name = ''; | ||
| 226 | +}; | ||
| 227 | + | ||
| 228 | +const onClassConfirm = ({ selectedOptions }) => { | ||
| 229 | + const classItem = selectedOptions[0]; | ||
| 230 | + showClassPicker.value = false; | ||
| 231 | + form.class_id = classItem.value; | ||
| 232 | + form.class_name = classItem.text; | ||
| 233 | +}; | ||
| 234 | + | ||
| 235 | +const saveForm = async () => { | ||
| 236 | + saveLoading.value = true; | ||
| 237 | + try { | ||
| 238 | + await submitRegistration({ ...form }); | ||
| 239 | + showSuccessToast('保存成功'); | ||
| 240 | + } catch (error) { | ||
| 241 | + showFailToast(error.message); | ||
| 242 | + } finally { | ||
| 243 | + saveLoading.value = false; | ||
| 244 | + } | ||
| 245 | +}; | ||
| 246 | + | ||
| 247 | +const handleSubmitFailed = ({ errors = [] }) => { | ||
| 248 | + const message = errors.find((item) => item.message)?.message || '请检查报名信息'; | ||
| 249 | + | ||
| 250 | + showDialog({ | ||
| 251 | + title: '请完善报名信息', | ||
| 252 | + message, | ||
| 253 | + confirmButtonColor: styleColor.baseColor | ||
| 254 | + }); | ||
| 255 | +}; | ||
| 256 | + | ||
| 257 | +onMounted(async () => { | ||
| 258 | + document.title = '家庭表单录入'; | ||
| 259 | + | ||
| 260 | + try { | ||
| 261 | + schoolColumns.value = await fetchSchoolOptions(); | ||
| 262 | + } catch (error) { | ||
| 263 | + showFailToast(error.message); | ||
| 264 | + } | ||
| 265 | +}); | ||
| 266 | +</script> | ||
| 267 | + | ||
| 268 | +<style lang="less" scoped> | ||
| 269 | +.family-form-page { | ||
| 270 | + min-height: 100vh; | ||
| 271 | + padding: 16px; | ||
| 272 | + background: #f3f5f7; | ||
| 273 | + box-sizing: border-box; | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +.form-card { | ||
| 277 | + min-height: calc(100vh - 32px); | ||
| 278 | + padding: 22px 14px 28px; | ||
| 279 | + background: #ffffff; | ||
| 280 | + border-radius: 16px; | ||
| 281 | + box-sizing: border-box; | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +.page-header { | ||
| 285 | + margin-bottom: 42px; | ||
| 286 | + | ||
| 287 | + h1 { | ||
| 288 | + margin: 0; | ||
| 289 | + color: #101010; | ||
| 290 | + font-size: 22px; | ||
| 291 | + line-height: 1.35; | ||
| 292 | + font-weight: 700; | ||
| 293 | + } | ||
| 294 | + | ||
| 295 | + p { | ||
| 296 | + margin: 14px 0 0; | ||
| 297 | + color: #8a8f96; | ||
| 298 | + font-size: 14px; | ||
| 299 | + line-height: 1.6; | ||
| 300 | + } | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +.form-section { | ||
| 304 | + margin-bottom: 36px; | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +.field-block { | ||
| 308 | + margin-bottom: 30px; | ||
| 309 | + | ||
| 310 | + :deep(.van-cell) { | ||
| 311 | + min-height: 46px; | ||
| 312 | + padding: 0 14px; | ||
| 313 | + align-items: center; | ||
| 314 | + border: 1px solid #e8e8e8; | ||
| 315 | + border-radius: 6px; | ||
| 316 | + box-shadow: none; | ||
| 317 | + } | ||
| 318 | + | ||
| 319 | + :deep(.van-field__control) { | ||
| 320 | + color: #222222; | ||
| 321 | + font-size: 14px; | ||
| 322 | + } | ||
| 323 | + | ||
| 324 | + :deep(.van-field__control::placeholder) { | ||
| 325 | + color: #b9bec3; | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + :deep(.van-field__error-message) { | ||
| 329 | + padding-top: 4px; | ||
| 330 | + } | ||
| 331 | +} | ||
| 332 | + | ||
| 333 | +.field-label, | ||
| 334 | +.section-title { | ||
| 335 | + position: relative; | ||
| 336 | + margin-bottom: 14px; | ||
| 337 | + color: #101010; | ||
| 338 | + font-size: 17px; | ||
| 339 | + line-height: 1.4; | ||
| 340 | + font-weight: 700; | ||
| 341 | +} | ||
| 342 | + | ||
| 343 | +.required .field-label { | ||
| 344 | + padding-left: 14px; | ||
| 345 | + | ||
| 346 | + &::before { | ||
| 347 | + position: absolute; | ||
| 348 | + left: 0; | ||
| 349 | + top: 1px; | ||
| 350 | + color: #ee3f3f; | ||
| 351 | + content: '*'; | ||
| 352 | + } | ||
| 353 | +} | ||
| 354 | + | ||
| 355 | +.field-desc, | ||
| 356 | +.section-desc { | ||
| 357 | + margin: -6px 0 14px; | ||
| 358 | + color: #9aa1a8; | ||
| 359 | + font-size: 14px; | ||
| 360 | + line-height: 1.65; | ||
| 361 | +} | ||
| 362 | + | ||
| 363 | +.school-row { | ||
| 364 | + display: grid; | ||
| 365 | + grid-template-columns: 1fr 1fr; | ||
| 366 | + gap: 12px; | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +.clothes-section { | ||
| 370 | + padding-top: 4px; | ||
| 371 | +} | ||
| 372 | + | ||
| 373 | +.section-desc { | ||
| 374 | + margin-top: 0; | ||
| 375 | + margin-bottom: 58px; | ||
| 376 | +} | ||
| 377 | + | ||
| 378 | +.submit-bar { | ||
| 379 | + padding-top: 4px; | ||
| 380 | +} | ||
| 381 | + | ||
| 382 | +@media (max-width: 340px) { | ||
| 383 | + .school-row { | ||
| 384 | + grid-template-columns: 1fr; | ||
| 385 | + } | ||
| 386 | +} | ||
| 387 | +</style> |
src/views/familyForm/mock.js
0 → 100644
| 1 | +const STORAGE_KEY = 'family-form-registrations'; | ||
| 2 | + | ||
| 3 | +const mockSchoolList = [ | ||
| 4 | + { | ||
| 5 | + id: 'kg_001', | ||
| 6 | + name: '上海市杨浦区科技幼儿园', | ||
| 7 | + classes: [ | ||
| 8 | + { id: 'kg_001_c_001', name: '小一班' }, | ||
| 9 | + { id: 'kg_001_c_002', name: '中二班' }, | ||
| 10 | + { id: 'kg_001_c_003', name: '大三班' } | ||
| 11 | + ] | ||
| 12 | + }, | ||
| 13 | + { | ||
| 14 | + id: 'kg_002', | ||
| 15 | + name: '上海市黄浦区蓓蕾幼儿园', | ||
| 16 | + classes: [ | ||
| 17 | + { id: 'kg_002_c_001', name: '小太阳班' }, | ||
| 18 | + { id: 'kg_002_c_002', name: '星星班' }, | ||
| 19 | + { id: 'kg_002_c_003', name: '彩虹班' } | ||
| 20 | + ] | ||
| 21 | + }, | ||
| 22 | + { | ||
| 23 | + id: 'kg_003', | ||
| 24 | + name: '上海市徐汇区童心幼儿园', | ||
| 25 | + classes: [ | ||
| 26 | + { id: 'kg_003_c_001', name: '亲子一班' }, | ||
| 27 | + { id: 'kg_003_c_002', name: '亲子二班' }, | ||
| 28 | + { id: 'kg_003_c_003', name: '亲子三班' } | ||
| 29 | + ] | ||
| 30 | + } | ||
| 31 | +]; | ||
| 32 | + | ||
| 33 | +const delay = (data, timeout = 200) => new Promise((resolve) => { | ||
| 34 | + setTimeout(() => resolve(data), timeout); | ||
| 35 | +}); | ||
| 36 | + | ||
| 37 | +const readRegistrations = () => { | ||
| 38 | + try { | ||
| 39 | + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; | ||
| 40 | + } catch (error) { | ||
| 41 | + console.warn('读取家庭报名 mock 缓存失败', error); | ||
| 42 | + return []; | ||
| 43 | + } | ||
| 44 | +}; | ||
| 45 | + | ||
| 46 | +const writeRegistrations = (list) => { | ||
| 47 | + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); | ||
| 48 | +}; | ||
| 49 | + | ||
| 50 | +export const getMockSchoolList = () => delay({ | ||
| 51 | + code: 1, | ||
| 52 | + data: mockSchoolList | ||
| 53 | +}); | ||
| 54 | + | ||
| 55 | +export const saveMockRegistration = (params) => { | ||
| 56 | + const list = readRegistrations(); | ||
| 57 | + const now = Date.now(); | ||
| 58 | + const nextItem = { | ||
| 59 | + ...params, | ||
| 60 | + id: `family_${now}`, | ||
| 61 | + updated_at: now | ||
| 62 | + }; | ||
| 63 | + | ||
| 64 | + list.unshift(nextItem); | ||
| 65 | + writeRegistrations(list); | ||
| 66 | + | ||
| 67 | + return delay({ | ||
| 68 | + code: 1, | ||
| 69 | + msg: '保存成功', | ||
| 70 | + data: nextItem | ||
| 71 | + }, 300); | ||
| 72 | +}; |
src/views/familyForm/service.js
0 → 100644
| 1 | +import { | ||
| 2 | + getMockSchoolList, | ||
| 3 | + saveMockRegistration | ||
| 4 | +} from './mock'; | ||
| 5 | + | ||
| 6 | +const normalizeSchoolList = (list = []) => list.map((school) => ({ | ||
| 7 | + text: school.name, | ||
| 8 | + value: school.id, | ||
| 9 | + children: (school.classes || []).map((item) => ({ | ||
| 10 | + text: item.name, | ||
| 11 | + value: item.id | ||
| 12 | + })) | ||
| 13 | +})); | ||
| 14 | + | ||
| 15 | +export const fetchSchoolOptions = async () => { | ||
| 16 | + const res = await getMockSchoolList(); | ||
| 17 | + if (res.code !== 1) { | ||
| 18 | + throw new Error(res.msg || '获取幼儿园信息失败'); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + return normalizeSchoolList(res.data); | ||
| 22 | +}; | ||
| 23 | + | ||
| 24 | +export const submitRegistration = async (formData) => { | ||
| 25 | + const res = await saveMockRegistration(formData); | ||
| 26 | + if (res.code !== 1) { | ||
| 27 | + throw new Error(res.msg || '保存报名信息失败'); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + return res.data; | ||
| 31 | +}; |
-
Please register or login to post a comment