hookehuyr

feat(familyForm): 新增家庭报名表单模块

添加家庭报名表单页面、路由、服务及模拟数据
- 创建表单页面,包含幼儿园班级选择、参赛人数、孩童姓名、手机号、证件登记和服装尺码等字段
- 添加路由配置,支持 /family-form 路径访问
- 实现服务层,提供获取学校选项和提交报名信息的功能
- 添加模拟数据,包含幼儿园列表和本地存储的报名记录
1 +const familyFormRoutes = [
2 + {
3 + path: '/family-form',
4 + name: '家庭报名表单',
5 + component: () => import('@/views/familyForm/index.vue'),
6 + meta: {
7 + title: '家庭表单录入'
8 + },
9 + children: []
10 + }
11 +];
12 +
13 +export default familyFormRoutes;
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>
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 +};
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 +};