|
|
@@ -0,0 +1,1073 @@
|
|
|
+<template>
|
|
|
+ <view class="registration-page">
|
|
|
+ <!-- #ifdef H5 -->
|
|
|
+ <view class="h5-page-header">医药代表合规培训报名表</view>
|
|
|
+ <!-- #endif -->
|
|
|
+
|
|
|
+ <view class="hero">
|
|
|
+ <view class="hero-title">正式报名 · 一次填完</view>
|
|
|
+ <view class="hero-subtitle">信息仅用于学籍注册与证书制作</view>
|
|
|
+
|
|
|
+ <view class="progress-list">
|
|
|
+ <view
|
|
|
+ v-for="(step, index) in steps"
|
|
|
+ :key="step"
|
|
|
+ class="progress-step"
|
|
|
+ :class="{ active: index === 0 }"
|
|
|
+ >
|
|
|
+ <view class="step-number">{{ index + 1 }}</view>
|
|
|
+ <text class="step-name">{{ step }}</text>
|
|
|
+ <view v-if="index < steps.length - 1" class="step-line"></view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="page-content">
|
|
|
+ <view class="prepare-tip">
|
|
|
+ <view class="prepare-title">📋 填写前请备齐</view>
|
|
|
+ <view class="prepare-text">两寸免冠证件照一张(白底或蓝底,用于证书制作)。</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="section-title"><view class="section-mark"></view><text>一、基本信息</text></view>
|
|
|
+ <view class="form-card">
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label"
|
|
|
+ >姓名 <text class="required">*</text><text class="label-note">用于证书印制</text></view
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ v-model="form.name"
|
|
|
+ class="field-input"
|
|
|
+ :maxlength="50"
|
|
|
+ placeholder="请输入真实姓名"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label">性别 <text class="required">*</text></view>
|
|
|
+ <view class="gender-list">
|
|
|
+ <view
|
|
|
+ v-for="item in genderOptions"
|
|
|
+ :key="item"
|
|
|
+ class="gender-option"
|
|
|
+ :class="{ selected: form.gender === item }"
|
|
|
+ @click="form.gender = item"
|
|
|
+ >
|
|
|
+ <view class="radio-icon"
|
|
|
+ ><view v-if="form.gender === item" class="radio-dot"></view
|
|
|
+ ></view>
|
|
|
+ <text>{{ item }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label">手机号 <text class="required">*</text></view>
|
|
|
+ <input
|
|
|
+ :value="form.phone"
|
|
|
+ class="field-input"
|
|
|
+ type="number"
|
|
|
+ :maxlength="11"
|
|
|
+ placeholder="请输入手机号"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ @input="onPhoneInput"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label"
|
|
|
+ >身份证号 <text class="required">*</text
|
|
|
+ ><text class="label-note">用于学籍与证书注册</text></view
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ :value="form.idCard"
|
|
|
+ class="field-input"
|
|
|
+ :maxlength="18"
|
|
|
+ placeholder="请输入身份证号"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ @input="onIdCardInput"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label">所在地区(省/市) <text class="required">*</text></view>
|
|
|
+ <view class="field-input selector" @click="showRegionPicker = true">
|
|
|
+ <text :class="{ 'field-placeholder': !regionText }">{{
|
|
|
+ regionText || '请选择省 / 市'
|
|
|
+ }}</text>
|
|
|
+ <wd-icon name="down" size="24rpx" color="#aeb6c2" />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field">
|
|
|
+ <view class="field-label">所属公司 / 单位 / 机构 <text class="required">*</text></view>
|
|
|
+ <input
|
|
|
+ v-model="form.company"
|
|
|
+ class="field-input"
|
|
|
+ :maxlength="100"
|
|
|
+ placeholder="请输入单位全称"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="field field-last">
|
|
|
+ <view class="field-label">职务 <text class="required">*</text></view>
|
|
|
+ <input
|
|
|
+ v-model="form.jobTitle"
|
|
|
+ class="field-input"
|
|
|
+ :maxlength="50"
|
|
|
+ placeholder="请输入职务"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="section-title"><view class="section-mark"></view><text>二、学历报名</text></view>
|
|
|
+ <view class="form-card education-card">
|
|
|
+ <view class="field-label">是否需要同步报名学历 <text class="required">*</text></view>
|
|
|
+ <view
|
|
|
+ v-for="item in educationOptions"
|
|
|
+ :key="item.value"
|
|
|
+ class="education-option"
|
|
|
+ :class="{ selected: selectedEducation.indexOf(item.value) !== -1 }"
|
|
|
+ @click="toggleEducation(item.value)"
|
|
|
+ >
|
|
|
+ <view class="checkbox-icon">
|
|
|
+ <wd-icon
|
|
|
+ v-if="selectedEducation.indexOf(item.value) !== -1"
|
|
|
+ name="check"
|
|
|
+ size="26rpx"
|
|
|
+ color="#ffffff"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ <text class="education-label">{{ item.label }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="section-title"
|
|
|
+ ><view class="section-mark"></view><text>三、照片(证书使用)</text></view
|
|
|
+ >
|
|
|
+ <view class="form-card photo-card">
|
|
|
+ <view class="field-label photo-label"
|
|
|
+ >照片(证书使用) <text class="required">*</text></view
|
|
|
+ >
|
|
|
+ <view class="photo-hint">白底或蓝底两寸免冠照片</view>
|
|
|
+
|
|
|
+ <view class="photo-grid">
|
|
|
+ <view v-for="(image, index) in images" :key="image.remoteUrl" class="photo-item">
|
|
|
+ <image
|
|
|
+ class="photo-preview"
|
|
|
+ :src="image.localUrl"
|
|
|
+ mode="aspectFill"
|
|
|
+ @click="previewImage(index)"
|
|
|
+ />
|
|
|
+ <view class="photo-delete" @click.stop="removeImage(index)">×</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view
|
|
|
+ v-if="images.length < 2"
|
|
|
+ class="upload-box"
|
|
|
+ :class="{ disabled: uploading, 'upload-box-empty': images.length === 0 }"
|
|
|
+ @click="chooseImages"
|
|
|
+ >
|
|
|
+ <view class="upload-plus">+</view>
|
|
|
+ <view class="upload-title">{{ uploading ? '上传中...' : '添加图片' }}</view>
|
|
|
+ <view class="upload-tip">图片要求:最少1张</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="section-title"><view class="section-mark"></view><text>四、证书寄送</text></view>
|
|
|
+ <view class="form-card address-card">
|
|
|
+ <view class="field-label address-label">
|
|
|
+ 联系地址 <text class="required">*</text><text class="label-note">用于寄送证书</text>
|
|
|
+ </view>
|
|
|
+ <view class="field-input selector" @click="showAddressPicker = true">
|
|
|
+ <text :class="{ 'field-placeholder': !addressRegionText }">{{
|
|
|
+ addressRegionText || '省 / 市 / 区'
|
|
|
+ }}</text>
|
|
|
+ <wd-icon name="down" size="24rpx" color="#aeb6c2" />
|
|
|
+ </view>
|
|
|
+ <textarea
|
|
|
+ v-model="form.addressDetail"
|
|
|
+ class="address-detail"
|
|
|
+ :maxlength="100"
|
|
|
+ placeholder="请填写详细地址(小区/楼栋/门牌号)"
|
|
|
+ placeholder-class="field-placeholder"
|
|
|
+ :show-confirm-bar="false"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <button
|
|
|
+ class="submit-button"
|
|
|
+ :disabled="submitting || uploading || submitted"
|
|
|
+ :class="{ disabled: submitting || uploading || submitted }"
|
|
|
+ @click="submit"
|
|
|
+ >
|
|
|
+ {{ submitButtonText }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <view class="footer-note">
|
|
|
+ <view>教务老师将于1个工作日内与您确认报名信息并开通课程</view>
|
|
|
+ <view>信息仅用于学籍注册与证书制作,不会用于其他用途</view>
|
|
|
+ </view>
|
|
|
+ <view class="service-phone">服务热线:400-639-1811</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <wd-cascader
|
|
|
+ v-model="regionPickerValue"
|
|
|
+ v-model:visible="showRegionPicker"
|
|
|
+ title="选择所在地区"
|
|
|
+ :options="regionOptions"
|
|
|
+ root-portal
|
|
|
+ @confirm="onRegionChange"
|
|
|
+ />
|
|
|
+ <wd-cascader
|
|
|
+ v-model="addressPickerValue"
|
|
|
+ v-model:visible="showAddressPicker"
|
|
|
+ title="选择证书寄送地区"
|
|
|
+ :options="addressOptions"
|
|
|
+ root-portal
|
|
|
+ @confirm="onAddressRegionChange"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, reactive, ref } from 'vue'
|
|
|
+
|
|
|
+import { onLoad } from '@dcloudio/uni-app'
|
|
|
+import { useCascaderAreaData } from '@vant/area-data'
|
|
|
+
|
|
|
+import {
|
|
|
+ getInfoCollectionUploadUrl,
|
|
|
+ submitInfoCollectionApi,
|
|
|
+ type TrainingRegistrationPayload,
|
|
|
+} from '@/services/modules/infoCollection'
|
|
|
+
|
|
|
+interface AreaOption {
|
|
|
+ value: string
|
|
|
+ text: string
|
|
|
+ children?: AreaOption[]
|
|
|
+ isLeaf?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface SelectedRegion {
|
|
|
+ province: string
|
|
|
+ city: string
|
|
|
+ area?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface SelectedImageFile {
|
|
|
+ path: string
|
|
|
+ size?: number
|
|
|
+ name?: string
|
|
|
+ type?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface UploadedImage {
|
|
|
+ localUrl: string
|
|
|
+ remoteUrl: string
|
|
|
+}
|
|
|
+
|
|
|
+interface UploadResponse {
|
|
|
+ code: number | string
|
|
|
+ msg?: string
|
|
|
+ data?: {
|
|
|
+ url?: string
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const IMAGE_TYPES = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp']
|
|
|
+const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
|
|
+
|
|
|
+const steps = ['填写提交', '教务审核', '开通课程', '双证上岗']
|
|
|
+const genderOptions = ['男', '女']
|
|
|
+const educationOptions = [
|
|
|
+ {
|
|
|
+ label: '【专科】药学专业、护理学专业',
|
|
|
+ value: '【专科】药学专业、护理学专业',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ label: '【专科起点本科】药学专业、护理学专业',
|
|
|
+ value: '【专科起点本科】药学专业、护理学专业',
|
|
|
+ },
|
|
|
+]
|
|
|
+
|
|
|
+const form = reactive({
|
|
|
+ name: '',
|
|
|
+ gender: '',
|
|
|
+ phone: '',
|
|
|
+ idCard: '',
|
|
|
+ company: '',
|
|
|
+ jobTitle: '',
|
|
|
+ addressDetail: '',
|
|
|
+})
|
|
|
+
|
|
|
+const region = ref<SelectedRegion>()
|
|
|
+const addressRegion = ref<SelectedRegion>()
|
|
|
+const selectedEducation = ref<string[]>([])
|
|
|
+const images = ref<UploadedImage[]>([])
|
|
|
+const showRegionPicker = ref(false)
|
|
|
+const showAddressPicker = ref(false)
|
|
|
+const regionPickerValue = ref<string | number>()
|
|
|
+const addressPickerValue = ref<string | number>()
|
|
|
+const uploading = ref(false)
|
|
|
+const submitting = ref(false)
|
|
|
+const submitted = ref(false)
|
|
|
+const channel = ref('miniapp')
|
|
|
+
|
|
|
+const rawAreaOptions = useCascaderAreaData() as AreaOption[]
|
|
|
+const regionOptions = convertCityLeaf(rawAreaOptions)
|
|
|
+const addressOptions = rawAreaOptions
|
|
|
+
|
|
|
+const regionText = computed(() => {
|
|
|
+ if (!region.value) return ''
|
|
|
+ return `${region.value.province} / ${region.value.city}`
|
|
|
+})
|
|
|
+
|
|
|
+const addressRegionText = computed(() => {
|
|
|
+ if (!addressRegion.value) return ''
|
|
|
+ return `${addressRegion.value.province} / ${addressRegion.value.city} / ${addressRegion.value.area}`
|
|
|
+})
|
|
|
+
|
|
|
+const submitButtonText = computed(() => {
|
|
|
+ if (submitted.value) return '报名已提交'
|
|
|
+ if (submitting.value) return '提交中...'
|
|
|
+ return '提交报名'
|
|
|
+})
|
|
|
+
|
|
|
+const showToast = (title: string) => {
|
|
|
+ uni.showToast({
|
|
|
+ title,
|
|
|
+ icon: 'none',
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const onPhoneInput = (event: { detail: { value?: string | number } }) => {
|
|
|
+ const phone = String(event.detail.value || '')
|
|
|
+ .replace(/\D/g, '')
|
|
|
+ .slice(0, 11)
|
|
|
+ form.phone = phone
|
|
|
+ return phone
|
|
|
+}
|
|
|
+
|
|
|
+const onIdCardInput = (event: { detail: { value?: string | number } }) => {
|
|
|
+ const idCard = String(event.detail.value || '')
|
|
|
+ .replace(/[^0-9Xx]/g, '')
|
|
|
+ .slice(0, 18)
|
|
|
+ form.idCard = idCard
|
|
|
+ return idCard
|
|
|
+}
|
|
|
+
|
|
|
+const onRegionChange = (event: { selectedOptions?: AreaOption[] }) => {
|
|
|
+ const items = event.selectedOptions ?? []
|
|
|
+ if (items.length < 2) return
|
|
|
+
|
|
|
+ region.value = {
|
|
|
+ province: items[0].text,
|
|
|
+ city: items[1].text,
|
|
|
+ }
|
|
|
+ regionPickerValue.value = items[items.length - 1].value
|
|
|
+}
|
|
|
+
|
|
|
+const onAddressRegionChange = (event: { selectedOptions?: AreaOption[] }) => {
|
|
|
+ const items = event.selectedOptions ?? []
|
|
|
+ if (items.length < 3) return
|
|
|
+
|
|
|
+ addressRegion.value = {
|
|
|
+ province: items[0].text,
|
|
|
+ city: items[1].text,
|
|
|
+ area: items[2].text,
|
|
|
+ }
|
|
|
+ addressPickerValue.value = items[items.length - 1].value
|
|
|
+}
|
|
|
+
|
|
|
+const toggleEducation = (value: string) => {
|
|
|
+ const index = selectedEducation.value.indexOf(value)
|
|
|
+
|
|
|
+ if (index === -1) {
|
|
|
+ selectedEducation.value.push(value)
|
|
|
+ } else {
|
|
|
+ selectedEducation.value.splice(index, 1)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const chooseImages = () => {
|
|
|
+ if (uploading.value || images.value.length >= 2) return
|
|
|
+
|
|
|
+ uni.chooseImage({
|
|
|
+ count: 2 - images.value.length,
|
|
|
+ sizeType: ['compressed'],
|
|
|
+ sourceType: ['album'],
|
|
|
+ success: async (result) => {
|
|
|
+ const tempFiles = (result.tempFiles || []) as Array<{
|
|
|
+ path?: string
|
|
|
+ size?: number
|
|
|
+ name?: string
|
|
|
+ type?: string
|
|
|
+ }>
|
|
|
+ const tempFilePaths = Array.isArray(result.tempFilePaths) ? result.tempFilePaths : []
|
|
|
+ const files: SelectedImageFile[] = tempFilePaths.map((path, index) => ({
|
|
|
+ path,
|
|
|
+ size: tempFiles[index]?.size,
|
|
|
+ name: tempFiles[index]?.name,
|
|
|
+ type: tempFiles[index]?.type,
|
|
|
+ }))
|
|
|
+
|
|
|
+ for (const file of files) {
|
|
|
+ const errorMessage = validateImage(file)
|
|
|
+
|
|
|
+ if (errorMessage) {
|
|
|
+ showToast(errorMessage)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ uploading.value = true
|
|
|
+ const remoteUrl = await uploadImage(file.path)
|
|
|
+ images.value.push({
|
|
|
+ localUrl: file.path,
|
|
|
+ remoteUrl,
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ showToast(getErrorMessage(error, '图片上传失败,请重试'))
|
|
|
+ } finally {
|
|
|
+ uploading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const validateImage = (file: SelectedImageFile): string => {
|
|
|
+ if (file.size && file.size > MAX_IMAGE_SIZE) {
|
|
|
+ return '图片大小不能超过5MB'
|
|
|
+ }
|
|
|
+
|
|
|
+ const extension =
|
|
|
+ String(file.name || file.path)
|
|
|
+ .split('?')[0]
|
|
|
+ .split('.')
|
|
|
+ .pop()
|
|
|
+ ?.toLowerCase() || ''
|
|
|
+ const mimeType = String(file.type || '').toLowerCase()
|
|
|
+ const hasKnownExtension = IMAGE_TYPES.includes(extension)
|
|
|
+ const hasImageMimeType = mimeType.startsWith('image/')
|
|
|
+
|
|
|
+ if (!hasKnownExtension && !hasImageMimeType) {
|
|
|
+ return '仅支持jpg、jpeg、png、bmp、gif、webp格式'
|
|
|
+ }
|
|
|
+
|
|
|
+ return ''
|
|
|
+}
|
|
|
+
|
|
|
+const uploadImage = (filePath: string): Promise<string> => {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ uni.uploadFile({
|
|
|
+ url: getInfoCollectionUploadUrl(),
|
|
|
+ filePath,
|
|
|
+ name: 'file',
|
|
|
+ success(response) {
|
|
|
+ if (response.statusCode !== 200) {
|
|
|
+ reject(new Error(`图片上传失败(HTTP ${response.statusCode})`))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = (
|
|
|
+ typeof response.data === 'string' ? JSON.parse(response.data) : response.data
|
|
|
+ ) as UploadResponse
|
|
|
+
|
|
|
+ if (!result || Number(result.code) !== 0) {
|
|
|
+ reject(new Error(result?.msg || '图片上传失败,请重试'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const remoteUrl = result.data?.url
|
|
|
+
|
|
|
+ if (!remoteUrl) {
|
|
|
+ reject(new Error('图片上传接口未返回图片地址'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ resolve(remoteUrl)
|
|
|
+ } catch {
|
|
|
+ reject(new Error('图片上传响应解析失败'))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ fail() {
|
|
|
+ reject(new Error('图片上传失败,请检查网络后重试'))
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const previewImage = (index: number) => {
|
|
|
+ uni.previewImage({
|
|
|
+ current: index,
|
|
|
+ urls: images.value.map((item) => item.localUrl),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const removeImage = (index: number) => {
|
|
|
+ if (uploading.value) return
|
|
|
+ images.value.splice(index, 1)
|
|
|
+}
|
|
|
+
|
|
|
+const validate = (): boolean => {
|
|
|
+ const name = form.name.trim()
|
|
|
+ const phone = form.phone.trim()
|
|
|
+ const idCard = form.idCard.trim()
|
|
|
+ const company = form.company.trim()
|
|
|
+ const jobTitle = form.jobTitle.trim()
|
|
|
+ const addressDetail = form.addressDetail.trim()
|
|
|
+
|
|
|
+ if (!name) return showValidationError('请填写姓名')
|
|
|
+ if (name.length > 50) return showValidationError('姓名最多可填写50个字')
|
|
|
+ if (!form.gender) return showValidationError('请选择性别')
|
|
|
+ if (!phone) return showValidationError('请填写手机号')
|
|
|
+ if (!/^1[3-9]\d{9}$/.test(phone)) return showValidationError('手机号格式错误')
|
|
|
+ if (!idCard) return showValidationError('请填写身份证号')
|
|
|
+ if (!isIdCard(idCard)) return showValidationError('身份证号格式错误')
|
|
|
+ if (!region.value) return showValidationError('请选择所在地区')
|
|
|
+ if (!company) return showValidationError('请填写所属公司/单位/机构')
|
|
|
+ if (company.length > 100) {
|
|
|
+ return showValidationError('所属公司/单位/机构最多可填写100个字')
|
|
|
+ }
|
|
|
+ if (!jobTitle) return showValidationError('请填写职务')
|
|
|
+ if (jobTitle.length > 50) return showValidationError('职务最多可填写50个字')
|
|
|
+ if (!selectedEducation.value.length) return showValidationError('请选择需要同步报名的学历')
|
|
|
+ if (!images.value.length) return showValidationError('请至少上传1张照片')
|
|
|
+ if (!addressRegion.value) return showValidationError('请选择证书寄送地区')
|
|
|
+ if (!addressDetail) return showValidationError('请填写详细地址')
|
|
|
+ if (addressDetail.length > 100) return showValidationError('详细地址最多可填写100个字')
|
|
|
+
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+const showValidationError = (message: string): false => {
|
|
|
+ showToast(message)
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+const submit = async () => {
|
|
|
+ if (submitting.value || uploading.value || submitted.value || !validate()) return
|
|
|
+ if (!region.value || !addressRegion.value) return
|
|
|
+
|
|
|
+ const data: TrainingRegistrationPayload = {
|
|
|
+ source: 'A002',
|
|
|
+ channel: channel.value,
|
|
|
+ name: form.name.trim(),
|
|
|
+ gender: form.gender,
|
|
|
+ phone: form.phone.trim(),
|
|
|
+ idCard: form.idCard.trim(),
|
|
|
+ region: `${region.value.province}/${region.value.city}`,
|
|
|
+ company: form.company.trim(),
|
|
|
+ jobTitle: form.jobTitle.trim(),
|
|
|
+ applyEducation: selectedEducation.value.join('、'),
|
|
|
+ certificateImages: images.value.map((item) => item.remoteUrl).join(','),
|
|
|
+ address: `${addressRegion.value.province}/${addressRegion.value.city}/${addressRegion.value.area}/${form.addressDetail.trim()}`,
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ submitting.value = true
|
|
|
+ const result = await submitInfoCollectionApi(data)
|
|
|
+
|
|
|
+ if (result.code === 0 || result.code === 200) {
|
|
|
+ submitted.value = true
|
|
|
+ uni.showToast({
|
|
|
+ title: '报名提交成功',
|
|
|
+ icon: 'success',
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ showToast(result.msg || '提交失败,请稍后重试')
|
|
|
+ } catch (error) {
|
|
|
+ showToast(getErrorMessage(error, '提交失败,请检查网络后重试'))
|
|
|
+ } finally {
|
|
|
+ submitting.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function convertCityLeaf(list: AreaOption[]): AreaOption[] {
|
|
|
+ return list.map((item) => ({
|
|
|
+ ...item,
|
|
|
+ children: item.children?.map((city) => ({
|
|
|
+ value: city.value,
|
|
|
+ text: city.text,
|
|
|
+ isLeaf: true,
|
|
|
+ })),
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+const isIdCard = (value: string): boolean => {
|
|
|
+ const idCard = value.trim()
|
|
|
+ const pattern =
|
|
|
+ /^(^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$)|(^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])((\d{4})|\d{3}[Xx])$)$/
|
|
|
+
|
|
|
+ if (!pattern.test(idCard)) return false
|
|
|
+ if (idCard.length !== 18) return true
|
|
|
+
|
|
|
+ const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
|
|
+ const checksumMap = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
|
|
+ const sum = weights.reduce((total, weight, index) => {
|
|
|
+ return total + Number(idCard[index]) * weight
|
|
|
+ }, 0)
|
|
|
+
|
|
|
+ return idCard[17].toUpperCase() === checksumMap[sum % 11]
|
|
|
+}
|
|
|
+
|
|
|
+const getErrorMessage = (error: unknown, fallback: string): string => {
|
|
|
+ if (error instanceof Error && error.message) return error.message
|
|
|
+
|
|
|
+ if (typeof error === 'object' && error !== null) {
|
|
|
+ const target = error as { msg?: string; message?: string }
|
|
|
+ return target.msg || target.message || fallback
|
|
|
+ }
|
|
|
+
|
|
|
+ return fallback
|
|
|
+}
|
|
|
+
|
|
|
+onLoad((options?: Record<string, string>) => {
|
|
|
+ // #ifdef H5
|
|
|
+ channel.value = options?.channel || 'h5_sms'
|
|
|
+ // #endif
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.registration-page {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #f3f5f8;
|
|
|
+ color: #252b34;
|
|
|
+}
|
|
|
+
|
|
|
+.h5-page-header {
|
|
|
+ position: sticky;
|
|
|
+ top: 0;
|
|
|
+ z-index: 100;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ height: 88rpx;
|
|
|
+ border-bottom: 1rpx solid #eeeeee;
|
|
|
+ background: #ffffff;
|
|
|
+ color: #252b34;
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.hero {
|
|
|
+ padding: 43rpx 40rpx 39rpx;
|
|
|
+ background: linear-gradient(135deg, #1460c7 0%, #1979eb 72%, #3986dd 100%);
|
|
|
+ color: #ffffff;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-title {
|
|
|
+ font-size: 39rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 55rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.hero-subtitle {
|
|
|
+ margin-top: 8rpx;
|
|
|
+ color: rgba(255, 255, 255, 0.66);
|
|
|
+ font-size: 23rpx;
|
|
|
+ line-height: 34rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-list {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 27rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-step {
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
+ align-items: center;
|
|
|
+ min-width: 0;
|
|
|
+ color: rgba(255, 255, 255, 0.68);
|
|
|
+ font-size: 22rpx;
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ color: #ffffff;
|
|
|
+ font-weight: 600;
|
|
|
+
|
|
|
+ .step-number {
|
|
|
+ background: #ffdf1f;
|
|
|
+ color: #785d00;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.step-number {
|
|
|
+ display: flex;
|
|
|
+ flex: 0 0 35rpx;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 35rpx;
|
|
|
+ height: 35rpx;
|
|
|
+ border: 1rpx solid rgba(255, 255, 255, 0.2);
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(255, 255, 255, 0.14);
|
|
|
+ font-size: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.step-name {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ margin-left: 9rpx;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.step-line {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 12rpx;
|
|
|
+ height: 1rpx;
|
|
|
+ margin: 0 9rpx;
|
|
|
+ background: rgba(255, 255, 255, 0.28);
|
|
|
+}
|
|
|
+
|
|
|
+.page-content {
|
|
|
+ padding: 24rpx 24rpx 54rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.prepare-tip {
|
|
|
+ padding: 23rpx 28rpx;
|
|
|
+ border: 1rpx solid #f2b943;
|
|
|
+ border-radius: 13rpx;
|
|
|
+ background: #fffaf0;
|
|
|
+}
|
|
|
+
|
|
|
+.prepare-title {
|
|
|
+ color: #7e5415;
|
|
|
+ font-size: 27rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 38rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.prepare-text {
|
|
|
+ margin-top: 8rpx;
|
|
|
+ color: #9b722b;
|
|
|
+ font-size: 23rpx;
|
|
|
+ line-height: 34rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.section-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin: 29rpx 0 17rpx;
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 42rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.section-mark {
|
|
|
+ width: 6rpx;
|
|
|
+ height: 27rpx;
|
|
|
+ margin-right: 12rpx;
|
|
|
+ border-radius: 4rpx;
|
|
|
+ background: #2182f3;
|
|
|
+}
|
|
|
+
|
|
|
+.form-card {
|
|
|
+ padding: 33rpx 27rpx;
|
|
|
+ border-radius: 15rpx;
|
|
|
+ background: #ffffff;
|
|
|
+ box-shadow: 0 4rpx 14rpx rgba(26, 40, 60, 0.07);
|
|
|
+}
|
|
|
+
|
|
|
+.field {
|
|
|
+ margin-bottom: 31rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.field-last {
|
|
|
+ margin-bottom: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.field-label {
|
|
|
+ margin-bottom: 15rpx;
|
|
|
+ font-size: 27rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 39rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.required {
|
|
|
+ color: #f0444c;
|
|
|
+}
|
|
|
+
|
|
|
+.field-input {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 88rpx;
|
|
|
+ padding: 0 25rpx;
|
|
|
+ border: 1rpx solid #e7e9ed;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ background: #f6f7f9;
|
|
|
+ color: #333a45;
|
|
|
+ font-size: 26rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.field-placeholder {
|
|
|
+ color: #b3bac5;
|
|
|
+}
|
|
|
+
|
|
|
+.selector {
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+
|
|
|
+.gender-list {
|
|
|
+ display: flex;
|
|
|
+}
|
|
|
+
|
|
|
+.gender-option {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ height: 68rpx;
|
|
|
+ margin-right: 17rpx;
|
|
|
+ padding: 0 28rpx;
|
|
|
+ border: 1rpx solid #edf0f3;
|
|
|
+ border-radius: 36rpx;
|
|
|
+ background: #f7f8fa;
|
|
|
+ color: #58616e;
|
|
|
+ font-size: 25rpx;
|
|
|
+
|
|
|
+ &.selected {
|
|
|
+ border-color: #85bafb;
|
|
|
+ background: #eef6ff;
|
|
|
+ color: #1879e8;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.radio-icon {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 30rpx;
|
|
|
+ height: 30rpx;
|
|
|
+ margin-right: 10rpx;
|
|
|
+ border: 2rpx solid #cbd4df;
|
|
|
+ border-radius: 50%;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.radio-dot {
|
|
|
+ width: 16rpx;
|
|
|
+ height: 16rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #2182f3;
|
|
|
+}
|
|
|
+
|
|
|
+.education-card {
|
|
|
+ padding-bottom: 31rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.education-option {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 86rpx;
|
|
|
+ margin-top: 22rpx;
|
|
|
+ padding: 18rpx 24rpx;
|
|
|
+ border: 1rpx solid #eef0f3;
|
|
|
+ border-radius: 9rpx;
|
|
|
+ background: #f6f7f9;
|
|
|
+ color: #555f6d;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &.selected {
|
|
|
+ border-color: #75b1f9;
|
|
|
+ background: #eef6ff;
|
|
|
+ color: #1678e5;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.checkbox-icon {
|
|
|
+ display: flex;
|
|
|
+ flex: 0 0 31rpx;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 31rpx;
|
|
|
+ height: 31rpx;
|
|
|
+ margin-right: 14rpx;
|
|
|
+ border: 2rpx solid #cbd4df;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.education-option.selected .checkbox-icon {
|
|
|
+ border-color: #2182f3;
|
|
|
+ background: #2182f3;
|
|
|
+}
|
|
|
+
|
|
|
+.education-label {
|
|
|
+ font-size: 26rpx;
|
|
|
+ line-height: 38rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-card {
|
|
|
+ padding-bottom: 31rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-label {
|
|
|
+ margin-bottom: 3rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-hint {
|
|
|
+ color: #a2aab5;
|
|
|
+ font-size: 21rpx;
|
|
|
+ line-height: 31rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ margin-top: 10rpx;
|
|
|
+ gap: 14rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-item,
|
|
|
+.upload-box {
|
|
|
+ position: relative;
|
|
|
+ width: calc((100% - 14rpx) / 2);
|
|
|
+ height: 185rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-preview {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 10rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.photo-delete {
|
|
|
+ position: absolute;
|
|
|
+ top: -12rpx;
|
|
|
+ right: -12rpx;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 38rpx;
|
|
|
+ height: 38rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(34, 39, 47, 0.78);
|
|
|
+ color: #ffffff;
|
|
|
+ font-size: 31rpx;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-box {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ border: 2rpx dashed #c3d3e8;
|
|
|
+ border-radius: 10rpx;
|
|
|
+ background: #fbfcfe;
|
|
|
+
|
|
|
+ &.disabled {
|
|
|
+ opacity: 0.62;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.upload-box-empty {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-plus {
|
|
|
+ color: #1980ef;
|
|
|
+ font-size: 52rpx;
|
|
|
+ font-weight: 300;
|
|
|
+ line-height: 50rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-title {
|
|
|
+ margin-top: 5rpx;
|
|
|
+ color: #596575;
|
|
|
+ font-size: 24rpx;
|
|
|
+ line-height: 34rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-tip {
|
|
|
+ margin-top: 4rpx;
|
|
|
+ color: #b7bdc7;
|
|
|
+ font-size: 20rpx;
|
|
|
+ line-height: 29rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.address-card {
|
|
|
+ padding-bottom: 31rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.address-label {
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.label-note {
|
|
|
+ margin-left: 8rpx;
|
|
|
+ color: #a5acb6;
|
|
|
+ font-size: 21rpx;
|
|
|
+ font-weight: 400;
|
|
|
+}
|
|
|
+
|
|
|
+.address-detail {
|
|
|
+ width: 100%;
|
|
|
+ height: 88rpx;
|
|
|
+ min-height: 88rpx;
|
|
|
+ margin-top: 14rpx;
|
|
|
+ padding: 24rpx 25rpx;
|
|
|
+ border: 1rpx solid #e7e9ed;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ background: #f6f7f9;
|
|
|
+ color: #333a45;
|
|
|
+ font-size: 26rpx;
|
|
|
+ line-height: 38rpx;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.submit-button {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 100%;
|
|
|
+ height: 94rpx;
|
|
|
+ margin: 39rpx 0 0;
|
|
|
+ padding: 0;
|
|
|
+ border-radius: 48rpx;
|
|
|
+ background: #2180f3;
|
|
|
+ box-shadow: 0 11rpx 23rpx rgba(26, 119, 232, 0.28);
|
|
|
+ color: #ffffff;
|
|
|
+ font-size: 31rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ line-height: 94rpx;
|
|
|
+
|
|
|
+ &::after {
|
|
|
+ border: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.disabled {
|
|
|
+ background: #91bdf2;
|
|
|
+ box-shadow: none;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.footer-note {
|
|
|
+ margin-top: 25rpx;
|
|
|
+ color: #a9afb8;
|
|
|
+ font-size: 20rpx;
|
|
|
+ line-height: 33rpx;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.service-phone {
|
|
|
+ margin-top: 34rpx;
|
|
|
+ color: #8f9298;
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+</style>
|