hookehuyr

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

添加家庭报名表单页面、路由、服务及模拟数据
- 创建表单页面,包含幼儿园班级选择、参赛人数、孩童姓名、手机号、证件登记和服装尺码等字段
- 添加路由配置,支持 /family-form 路径访问
- 实现服务层,提供获取学校选项和提交报名信息的功能
- 添加模拟数据,包含幼儿园列表和本地存储的报名记录
const familyFormRoutes = [
{
path: '/family-form',
name: '家庭报名表单',
component: () => import('@/views/familyForm/index.vue'),
meta: {
title: '家庭表单录入'
},
children: []
}
];
export default familyFormRoutes;
<template>
<div class="family-form-page">
<van-config-provider :theme-vars="themeVars">
<div class="form-card">
<header class="page-header">
<h1>[新]第六届“益起走”幼儿园公益践行赛报名</h1>
<p>感谢参加本场活动!</p>
</header>
<van-form
scroll-to-error
scroll-to-error-position="center"
validate-first
@submit="saveForm"
@failed="handleSubmitFailed"
>
<section class="form-section">
<div class="field-block required">
<div class="field-label">所在幼儿园班级</div>
<div class="school-row">
<van-field
v-model="form.kg_name"
readonly
is-link
name="kg_name"
placeholder="请选择幼儿园"
:rules="[{ required: true, message: '请选择幼儿园' }]"
@click="showSchoolPicker = true"
/>
<van-field
v-model="form.class_name"
readonly
is-link
name="class_name"
placeholder="请选择班级"
:rules="[{ required: true, message: '请选择班级' }]"
@click="openClassPicker"
/>
</div>
</div>
<div class="field-block required">
<div class="field-label">活动当天参赛的孩童数量(输入大于等于1的数字)</div>
<van-field
v-model="form.child_count"
name="child_count"
type="digit"
placeholder="请输入孩童数量"
:rules="[{ validator: childCountValidator, message: '请输入大于等于1的数字' }]"
/>
</div>
<div class="field-block required">
<div class="field-label">活动当天参赛的家长数量(请输入1-2之间的数字)</div>
<van-field
v-model="form.parent_count"
name="parent_count"
type="digit"
placeholder="请输入家长数量"
:rules="[{ validator: parentCountValidator, message: '请输入1-2之间的数字' }]"
/>
</div>
<div class="field-block required">
<div class="field-label">参赛孩童姓名</div>
<p class="field-desc">如果有2位小朋友参赛,可以用空格间隔孩童姓名,例:申小花 申小朵</p>
<van-field
v-model="form.child_names"
name="child_names"
placeholder="请输入参赛孩童姓名"
:rules="[{ required: true, message: '请输入参赛孩童姓名' }]"
/>
</div>
<div class="field-block required">
<div class="field-label">手机</div>
<p class="field-desc">每组家庭填写一个手机号码,该号码将用于登录专属小程序支付报名费、募款、并在活动当天在线打卡等。</p>
<van-field
v-model="form.phone"
name="phone"
type="tel"
maxlength="11"
placeholder="请输入手机号"
:rules="[
{ required: true, message: '请输入手机号' },
{ validator: phoneValidator, message: '请输入正确手机号' }
]"
/>
</div>
<div class="field-block required">
<div class="field-label">证件登记</div>
<p class="field-desc">请准确填写参赛人员姓名和证件信息,用于购买赛事保险;如证件为非身份证,请填写出生年月日。</p>
<p class="field-desc">填写格式:<br>姓名1 证件号1<br>姓名2 证件号2</p>
<van-field
v-model="form.certificate_info"
name="certificate_info"
type="textarea"
rows="4"
autosize
placeholder="请输入证件登记信息"
:rules="[{ required: true, message: '请输入证件登记信息' }]"
/>
</div>
</section>
<section class="form-section clothes-section">
<div class="section-title">服装尺码登记</div>
<p class="section-desc">在需要的尺码里,填写数量</p>
<div v-for="item in clothesFields" :key="item.field" class="field-block">
<div class="field-label">{{ item.label }}</div>
<van-field
v-model="form[item.field]"
:name="item.field"
type="digit"
:placeholder="`请输入${item.label}数量`"
/>
</div>
</section>
<div class="submit-bar">
<van-button block round type="primary" native-type="submit" :loading="saveLoading">保存</van-button>
</div>
</van-form>
</div>
<van-popup v-model:show="showSchoolPicker" round position="bottom">
<van-picker
:columns="schoolOnlyColumns"
@cancel="showSchoolPicker = false"
@confirm="onSchoolConfirm"
/>
</van-popup>
<van-popup v-model:show="showClassPicker" round position="bottom">
<van-picker
:columns="classColumns"
@cancel="showClassPicker = false"
@confirm="onClassConfirm"
/>
</van-popup>
</van-config-provider>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { showDialog, showFailToast, showSuccessToast } from 'vant';
import { styleColor } from '@/constant.js';
import {
fetchSchoolOptions,
submitRegistration
} from './service';
const themeVars = {
buttonPrimaryBackground: styleColor.baseColor,
buttonPrimaryBorderColor: styleColor.baseColor,
buttonPrimaryColor: styleColor.baseFontColor
};
const defaultForm = {
kg_id: '',
kg_name: '',
class_id: '',
class_name: '',
child_count: '',
parent_count: '',
child_names: '',
phone: '',
certificate_info: '',
clothes_child_110: '',
clothes_child_120: '',
clothes_child_130: '',
clothes_adult_xs: '',
clothes_adult_s: '',
clothes_adult_m: '',
clothes_adult_l: ''
};
const clothesFields = [
{ label: '孩童110码', field: 'clothes_child_110' },
{ label: '孩童120码', field: 'clothes_child_120' },
{ label: '孩童130码', field: 'clothes_child_130' },
{ label: '成年XS码', field: 'clothes_adult_xs' },
{ label: '成年S码', field: 'clothes_adult_s' },
{ label: '成年M码', field: 'clothes_adult_m' },
{ label: '成年L码', field: 'clothes_adult_l' }
];
const form = reactive({ ...defaultForm });
const schoolColumns = ref([]);
const showSchoolPicker = ref(false);
const showClassPicker = ref(false);
const saveLoading = ref(false);
const currentSchool = computed(() => schoolColumns.value.find((item) => item.value === form.kg_id));
const classColumns = computed(() => currentSchool.value?.children || []);
const schoolOnlyColumns = computed(() => schoolColumns.value.map((item) => ({
text: item.text,
value: item.value
})));
const childCountValidator = (value) => Number(value) >= 1;
const parentCountValidator = (value) => {
const count = Number(value);
return count >= 1 && count <= 2;
};
const phoneValidator = (value) => /^1\d{10}$/.test(value);
const openClassPicker = () => {
if (!form.kg_id) {
showFailToast('请先选择幼儿园');
return;
}
showClassPicker.value = true;
};
const onSchoolConfirm = ({ selectedOptions }) => {
const school = selectedOptions[0];
showSchoolPicker.value = false;
form.kg_id = school.value;
form.kg_name = school.text;
form.class_id = '';
form.class_name = '';
};
const onClassConfirm = ({ selectedOptions }) => {
const classItem = selectedOptions[0];
showClassPicker.value = false;
form.class_id = classItem.value;
form.class_name = classItem.text;
};
const saveForm = async () => {
saveLoading.value = true;
try {
await submitRegistration({ ...form });
showSuccessToast('保存成功');
} catch (error) {
showFailToast(error.message);
} finally {
saveLoading.value = false;
}
};
const handleSubmitFailed = ({ errors = [] }) => {
const message = errors.find((item) => item.message)?.message || '请检查报名信息';
showDialog({
title: '请完善报名信息',
message,
confirmButtonColor: styleColor.baseColor
});
};
onMounted(async () => {
document.title = '家庭表单录入';
try {
schoolColumns.value = await fetchSchoolOptions();
} catch (error) {
showFailToast(error.message);
}
});
</script>
<style lang="less" scoped>
.family-form-page {
min-height: 100vh;
padding: 16px;
background: #f3f5f7;
box-sizing: border-box;
}
.form-card {
min-height: calc(100vh - 32px);
padding: 22px 14px 28px;
background: #ffffff;
border-radius: 16px;
box-sizing: border-box;
}
.page-header {
margin-bottom: 42px;
h1 {
margin: 0;
color: #101010;
font-size: 22px;
line-height: 1.35;
font-weight: 700;
}
p {
margin: 14px 0 0;
color: #8a8f96;
font-size: 14px;
line-height: 1.6;
}
}
.form-section {
margin-bottom: 36px;
}
.field-block {
margin-bottom: 30px;
:deep(.van-cell) {
min-height: 46px;
padding: 0 14px;
align-items: center;
border: 1px solid #e8e8e8;
border-radius: 6px;
box-shadow: none;
}
:deep(.van-field__control) {
color: #222222;
font-size: 14px;
}
:deep(.van-field__control::placeholder) {
color: #b9bec3;
}
:deep(.van-field__error-message) {
padding-top: 4px;
}
}
.field-label,
.section-title {
position: relative;
margin-bottom: 14px;
color: #101010;
font-size: 17px;
line-height: 1.4;
font-weight: 700;
}
.required .field-label {
padding-left: 14px;
&::before {
position: absolute;
left: 0;
top: 1px;
color: #ee3f3f;
content: '*';
}
}
.field-desc,
.section-desc {
margin: -6px 0 14px;
color: #9aa1a8;
font-size: 14px;
line-height: 1.65;
}
.school-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.clothes-section {
padding-top: 4px;
}
.section-desc {
margin-top: 0;
margin-bottom: 58px;
}
.submit-bar {
padding-top: 4px;
}
@media (max-width: 340px) {
.school-row {
grid-template-columns: 1fr;
}
}
</style>
const STORAGE_KEY = 'family-form-registrations';
const mockSchoolList = [
{
id: 'kg_001',
name: '上海市杨浦区科技幼儿园',
classes: [
{ id: 'kg_001_c_001', name: '小一班' },
{ id: 'kg_001_c_002', name: '中二班' },
{ id: 'kg_001_c_003', name: '大三班' }
]
},
{
id: 'kg_002',
name: '上海市黄浦区蓓蕾幼儿园',
classes: [
{ id: 'kg_002_c_001', name: '小太阳班' },
{ id: 'kg_002_c_002', name: '星星班' },
{ id: 'kg_002_c_003', name: '彩虹班' }
]
},
{
id: 'kg_003',
name: '上海市徐汇区童心幼儿园',
classes: [
{ id: 'kg_003_c_001', name: '亲子一班' },
{ id: 'kg_003_c_002', name: '亲子二班' },
{ id: 'kg_003_c_003', name: '亲子三班' }
]
}
];
const delay = (data, timeout = 200) => new Promise((resolve) => {
setTimeout(() => resolve(data), timeout);
});
const readRegistrations = () => {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
} catch (error) {
console.warn('读取家庭报名 mock 缓存失败', error);
return [];
}
};
const writeRegistrations = (list) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
};
export const getMockSchoolList = () => delay({
code: 1,
data: mockSchoolList
});
export const saveMockRegistration = (params) => {
const list = readRegistrations();
const now = Date.now();
const nextItem = {
...params,
id: `family_${now}`,
updated_at: now
};
list.unshift(nextItem);
writeRegistrations(list);
return delay({
code: 1,
msg: '保存成功',
data: nextItem
}, 300);
};
import {
getMockSchoolList,
saveMockRegistration
} from './mock';
const normalizeSchoolList = (list = []) => list.map((school) => ({
text: school.name,
value: school.id,
children: (school.classes || []).map((item) => ({
text: item.name,
value: item.id
}))
}));
export const fetchSchoolOptions = async () => {
const res = await getMockSchoolList();
if (res.code !== 1) {
throw new Error(res.msg || '获取幼儿园信息失败');
}
return normalizeSchoolList(res.data);
};
export const submitRegistration = async (formData) => {
const res = await saveMockRegistration(formData);
if (res.code !== 1) {
throw new Error(res.msg || '保存报名信息失败');
}
return res.data;
};