Prechádzať zdrojové kódy

完成ui样式实现

yuanmingze 1 týždeň pred
rodič
commit
cb0930279e

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "format": "prettier --write src/"
   },
   "dependencies": {
+    "@vant/area-data": "^2.2.0",
     "axios": "^1.12.2",
     "nanoid": "^5.1.6",
     "normalize.css": "^8.0.1",

+ 25 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@vant/area-data':
+        specifier: ^2.2.0
+        version: 2.2.0
       axios:
         specifier: ^1.12.2
         version: 1.12.2
@@ -524,36 +527,42 @@ packages:
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm-musl@2.5.1':
     resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-arm64-glibc@2.5.1':
     resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-arm64-musl@2.5.1':
     resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
     engines: {node: '>= 10.0.0'}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-linux-x64-glibc@2.5.1':
     resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@parcel/watcher-linux-x64-musl@2.5.1':
     resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
     engines: {node: '>= 10.0.0'}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@parcel/watcher-win32-arm64@2.5.1':
     resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -621,56 +630,67 @@ packages:
     resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
     cpu: [arm]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm-musleabihf@4.52.5':
     resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
     cpu: [arm]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-arm64-gnu@4.52.5':
     resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
     cpu: [arm64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-arm64-musl@4.52.5':
     resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
     cpu: [arm64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-loong64-gnu@4.52.5':
     resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
     cpu: [loong64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-ppc64-gnu@4.52.5':
     resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
     cpu: [ppc64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-gnu@4.52.5':
     resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
     cpu: [riscv64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-riscv64-musl@4.52.5':
     resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
     cpu: [riscv64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-linux-s390x-gnu@4.52.5':
     resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
     cpu: [s390x]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-gnu@4.52.5':
     resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
     cpu: [x64]
     os: [linux]
+    libc: [glibc]
 
   '@rollup/rollup-linux-x64-musl@4.52.5':
     resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
     cpu: [x64]
     os: [linux]
+    libc: [musl]
 
   '@rollup/rollup-openharmony-arm64@4.52.5':
     resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
@@ -774,6 +794,9 @@ packages:
     resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@vant/area-data@2.2.0':
+    resolution: {integrity: sha512-H1payEoXLnUqTNPdt2zftv0yoZBP8AZF69XntMqqGOn5LGtuaZ/VeRXQnJLiBPgGuHs2sXPgZ4gUVGp4Cg154w==}
+
   '@vant/popperjs@1.3.0':
     resolution: {integrity: sha512-hB+czUG+aHtjhaEmCJDuXOep0YTZjdlRR+4MSmIFnkCQIxJaXLQdSsR90XWvAI2yvKUI7TCGqR8pQg2RtvkMHw==}
 
@@ -2538,6 +2561,8 @@ snapshots:
       '@typescript-eslint/types': 8.46.2
       eslint-visitor-keys: 4.2.1
 
+  '@vant/area-data@2.2.0': {}
+
   '@vant/popperjs@1.3.0': {}
 
   '@vant/use@1.6.0(vue@3.5.22(typescript@5.9.3))':

+ 12 - 0
src/router/routes.ts

@@ -72,6 +72,18 @@ export const routes: RouteRecordRaw[] = [
     component: () => import('@/views/wechat-redirect/index.vue'),
     meta: { title: '路由跳转中', requiresAuth: false },
   },
+  {
+    path: '/compliance-plan',
+    name: 'CompliancePlan',
+    component: () => import('@/views/compliance-plan/index.vue'),
+    meta: { title: '合规上岗 · 专属方案', requiresAuth: false },
+  },
+  {
+    path: '/compliance-registration',
+    name: 'ComplianceRegistration',
+    component: () => import('@/views/compliance-registration/index.vue'),
+    meta: { title: '医药代表合规培训报名表', requiresAuth: false },
+  },
   {
     path: '/:pathMatch(.*)*',
     redirect: '/login',

+ 18 - 0
src/services/modules/compliance-plan/index.ts

@@ -0,0 +1,18 @@
+import http from '@/services'
+
+export interface CompliancePlanRequest {
+  name: string
+  phone: string
+  company: string
+  education: string
+  interest: string
+}
+
+export const submitInfoCollection = (data: CompliancePlanRequest) => {
+  return http.post({
+    url: '/admin/info/collection',
+    data,
+    silent: true,
+    timeout: 30000,
+  })
+}

+ 602 - 0
src/views/compliance-plan/index.vue

@@ -0,0 +1,602 @@
+<template>
+  <main class="plan-page">
+    <van-nav-bar title="合规上岗 · 专属方案" safe-area-inset-top />
+
+    <header class="hero">
+      <div class="hero-glow hero-glow-top" aria-hidden="true"></div>
+      <div class="hero-glow hero-glow-bottom" aria-hidden="true"></div>
+      <div class="hero-content">
+        <div class="hero-tag">国家级高校培训中心 × 药企 联合开展</div>
+        <h1 class="hero-heading">
+          <span class="hero-kicker">医药代表</span>
+          <span class="hero-title">
+            <span>合规上岗 × 学历提升</span>
+            <span class="hero-title-tail">一站搞定</span>
+          </span>
+        </h1>
+        <p class="hero-description">考培分离 · 双证上岗 · 成果存入学分银行可换学历学分</p>
+        <p class="hero-description hero-description-last">线上学习不脱产 · 学信网可查</p>
+      </div>
+      <p class="hero-provider-note">培训服务及学历证书由国家级高校培训中心提供及颁发</p>
+    </header>
+
+    <div class="page-content">
+      <section aria-labelledby="benefits-title">
+        <h2 id="benefits-title" class="section-title">你能带走什么</h2>
+        <div class="benefit-list">
+          <article v-for="item in benefits" :key="item.title" class="benefit-card">
+            <div class="benefit-icon" aria-hidden="true">{{ item.icon }}</div>
+            <div class="benefit-content">
+              <h3 class="benefit-title">{{ item.title }}</h3>
+              <p class="benefit-description">{{ item.description }}</p>
+            </div>
+          </article>
+        </div>
+      </section>
+
+      <section class="form-section" aria-labelledby="form-title">
+        <h2 id="form-title" class="section-title">免费获取我的专属方案</h2>
+        <form class="form-card" :aria-busy="submitting" novalidate @submit.prevent="submit">
+          <fieldset class="form-fields" :disabled="submitting || submitted">
+            <legend class="visually-hidden">填写信息领取提升方案</legend>
+            <div class="field">
+              <label class="field-label" for="plan-name">
+                您的称呼<span class="required">*</span>
+              </label>
+              <input
+                id="plan-name"
+                ref="nameInput"
+                v-model="form.name"
+                class="field-input"
+                name="name"
+                type="text"
+                autocomplete="name"
+                maxlength="50"
+                placeholder="怎么称呼您"
+                required
+              />
+            </div>
+
+            <div class="field">
+              <label class="field-label" for="plan-phone">
+                手机号<span class="required">*</span>
+              </label>
+              <input
+                id="plan-phone"
+                ref="phoneInput"
+                :value="form.phone"
+                class="field-input"
+                name="phone"
+                type="tel"
+                inputmode="numeric"
+                autocomplete="tel"
+                maxlength="11"
+                placeholder="用于接收方案与开课通知"
+                required
+                @input="onPhoneInput"
+              />
+            </div>
+
+            <div class="field">
+              <label class="field-label" for="plan-company">
+                所在企业<span class="field-hint">选填 · 方便匹配企业集体报名通道</span>
+              </label>
+              <input
+                id="plan-company"
+                v-model="form.company"
+                class="field-input"
+                name="company"
+                type="text"
+                autocomplete="organization"
+                maxlength="50"
+                placeholder="请输入企业名称"
+              />
+            </div>
+
+            <fieldset ref="educationField" class="field option-field" tabindex="-1">
+              <legend class="field-label">
+                您目前的学历<span class="required">*</span><span class="field-hint">单选</span>
+              </legend>
+              <div class="option-list">
+                <label v-for="item in educationOptions" :key="item" class="option-label">
+                  <input
+                    v-model="form.education"
+                    class="option-input visually-hidden"
+                    type="radio"
+                    name="education"
+                    :value="item"
+                    required
+                  />
+                  <span class="option-pill">{{ item }}</span>
+                </label>
+              </div>
+            </fieldset>
+
+            <fieldset ref="interestField" class="field option-field interest-field" tabindex="-1">
+              <legend class="field-label">
+                我最想了解<span class="required">*</span><span class="field-hint">可多选</span>
+              </legend>
+              <div class="option-list">
+                <label v-for="item in interestOptions" :key="item" class="option-label">
+                  <input
+                    v-model="selectedInterests"
+                    class="option-input visually-hidden"
+                    type="checkbox"
+                    name="interest"
+                    :value="item"
+                  />
+                  <span class="option-pill">{{ item }}</span>
+                </label>
+              </div>
+            </fieldset>
+
+            <button class="submit-button" type="submit" :disabled="submitting || submitted">
+              {{ submitButtonText }}
+            </button>
+          </fieldset>
+          <div class="privacy-note">
+            <p>提交即视为同意我们就本培训项目与您联系</p>
+            <p>信息仅用于方案匹配,不会用于其他用途</p>
+          </div>
+          <p class="visually-hidden" role="status">{{ submitted ? '提交成功' : '' }}</p>
+        </form>
+      </section>
+    </div>
+  </main>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+import { showSuccessToast, showToast } from 'vant'
+import { submitInfoCollection } from '@/services/modules/compliance-plan'
+
+const benefits = [
+  {
+    icon: '📜',
+    title: '双证上岗',
+    description:
+      '国家级高校培训中心《医药代表合规培训结业证书》+企业《考核合格证明》,官网可查、档案留痕',
+  },
+  {
+    icon: '🎓',
+    title: '学历直达',
+    description:
+      '免试注册入学国家级高校药学专业(专科/专升本),学信网终身可查,正好满足新规学历门槛',
+  },
+  {
+    icon: '💳',
+    title: '学分银行',
+    description: '培训合格即开通终身学习账户,成果可申请学历课程免修免考——读学历少修课、早毕业',
+  },
+  {
+    icon: '🩺',
+    title: '陪诊顾问职业证书(增值可选)',
+    description: '付费增值项:按需加购《医疗陪诊顾问职业技能培训证书》,多一条职业曲线',
+  },
+]
+
+const educationOptions = ['医药相关 大专及以上', '非相关专业 大专', '大专以下 / 在读']
+const interestOptions = [
+  '合规培训双证',
+  '学历提升(高校药学)',
+  '陪诊顾问职业证书',
+  '公司集体报名咨询',
+]
+const selectedInterests = ref<string[]>([])
+const form = reactive({ name: '', phone: '', company: '', education: '' })
+const submitting = ref(false)
+const submitted = ref(false)
+const nameInput = ref<HTMLInputElement | null>(null)
+const phoneInput = ref<HTMLInputElement | null>(null)
+const educationField = ref<HTMLFieldSetElement | null>(null)
+const interestField = ref<HTMLFieldSetElement | null>(null)
+
+const submitButtonText = computed(() => {
+  if (submitted.value) return '提交成功'
+  return submitting.value ? '正在提交...' : '领取我的提升方案'
+})
+
+const onPhoneInput = (event: Event) => {
+  const input = event.target as HTMLInputElement
+  const phone = input.value.replace(/\D/g, '').slice(0, 11)
+  form.phone = phone
+  input.value = phone
+}
+
+const validate = () => {
+  if (!form.name.trim()) {
+    nameInput.value?.focus()
+    showToast('请填写您的称呼')
+    return false
+  }
+  if (!/^1[3-9]\d{9}$/.test(form.phone)) {
+    phoneInput.value?.focus()
+    showToast('请填写正确的手机号')
+    return false
+  }
+  if (!form.education) {
+    educationField.value?.focus()
+    showToast('请选择您目前的学历')
+    return false
+  }
+  if (!selectedInterests.value.length) {
+    interestField.value?.focus()
+    showToast('请选择您最想了解的内容')
+    return false
+  }
+  return true
+}
+
+const submit = async () => {
+  if (submitting.value || submitted.value || !validate()) return
+
+  submitting.value = true
+  try {
+    const res = await submitInfoCollection({
+      name: form.name.trim(),
+      phone: form.phone.trim(),
+      company: form.company.trim(),
+      education: form.education,
+      interest: selectedInterests.value.join('、'),
+    })
+    if (res && [0, 200, '0', '200'].includes(res.code)) {
+      submitted.value = true
+      showSuccessToast('提交成功')
+      return
+    }
+    showToast(res?.msg || '提交失败,请稍后重试')
+  } catch (error) {
+    showToast(error instanceof Error ? error.message : '提交失败,请稍后重试')
+  } finally {
+    submitting.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.plan-page {
+  min-height: 100vh;
+  background: #f4f5f7;
+  color: #20242b;
+  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
+
+  h1,
+  h2,
+  h3,
+  p {
+    margin: 0;
+  }
+
+  fieldset {
+    min-width: 0;
+    padding: 0;
+    border: 0;
+  }
+}
+
+.hero {
+  position: relative;
+  min-height: 190px;
+  overflow: hidden;
+  background: linear-gradient(135deg, #0b55bd 0%, #126de0 54%, #1284ff 100%);
+}
+
+.hero-glow {
+  position: absolute;
+  border-radius: 50%;
+  background: rgba(255, 213, 72, 0.2);
+  filter: blur(11px);
+  pointer-events: none;
+}
+
+.hero-glow-top {
+  top: -39px;
+  right: -29px;
+  width: 115px;
+  height: 115px;
+}
+
+.hero-glow-bottom {
+  right: 17.5px;
+  bottom: -75px;
+  width: 155px;
+  height: 125px;
+  background: rgba(27, 168, 255, 0.34);
+}
+
+.hero-content {
+  position: relative;
+  z-index: 1;
+  padding: 23.5px 20px 33px;
+  color: #fff;
+}
+
+.hero-tag {
+  display: inline-flex;
+  align-items: center;
+  min-height: 24px;
+  padding: 0 9px;
+  border: 1px solid rgba(255, 220, 63, 0.56);
+  border-radius: 12px;
+  background: rgba(255, 211, 45, 0.13);
+  color: #ffe247;
+  font-size: 12.5px;
+  font-weight: 600;
+  box-sizing: border-box;
+}
+
+.hero-kicker {
+  display: block;
+  margin-top: 12px;
+  font-size: 21px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.hero-title {
+  display: flex;
+  align-items: baseline;
+  margin-top: 7.5px;
+  color: #ffdd2f;
+  font-size: 20px;
+  font-weight: 700;
+  line-height: 1.15;
+}
+
+.hero-title-tail {
+  margin-left: 7px;
+  color: #fff;
+  font-size: 16px;
+  white-space: nowrap;
+}
+
+.plan-page .hero-description {
+  margin-top: 13px;
+  color: rgba(255, 255, 255, 0.76);
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1.4;
+}
+
+.plan-page .hero-description-last {
+  margin-top: 4px;
+}
+
+.hero-provider-note {
+  position: absolute;
+  right: 12px;
+  bottom: 7px;
+  left: 12px;
+  z-index: 1;
+  color: rgba(255, 255, 255, 0.62);
+  font-size: 9px;
+  line-height: 13px;
+  text-align: right;
+}
+
+.page-content {
+  padding: 17px 12px 22px;
+  padding-bottom: calc(22px + env(safe-area-inset-bottom, 0px));
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  font-size: 15px;
+  font-weight: 700;
+  line-height: 21px;
+
+  &::before {
+    content: '';
+    flex: 0 0 3px;
+    height: 14px;
+    margin-right: 5px;
+    border-radius: 1.5px;
+    background: #1988ff;
+  }
+}
+
+.benefit-list {
+  display: grid;
+  gap: 9px;
+  margin-top: 10px;
+}
+
+.benefit-card {
+  display: flex;
+  align-items: flex-start;
+  min-height: 61px;
+  padding: 12.5px 12px;
+  border-radius: 9px;
+  background: #fff;
+  box-shadow: 0 2.5px 8.5px rgba(25, 43, 69, 0.07);
+  box-sizing: border-box;
+}
+
+.benefit-icon {
+  display: flex;
+  flex: 0 0 31px;
+  align-items: center;
+  justify-content: center;
+  width: 31px;
+  height: 31px;
+  margin-right: 11px;
+  border-radius: 7px;
+  background: #edf6ff;
+  font-size: 15.5px;
+  line-height: 1;
+}
+
+.benefit-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.benefit-title {
+  font-size: 14.5px;
+  font-weight: 700;
+  line-height: 19px;
+}
+
+.plan-page .benefit-description {
+  margin-top: 2.5px;
+  color: #666d77;
+  font-size: 11.5px;
+  line-height: 17.5px;
+}
+
+.form-section {
+  margin-top: 17px;
+}
+
+.form-card {
+  margin-top: 10px;
+  padding: 17px 14px 18.5px;
+  border-radius: 9px;
+  background: #fff;
+  box-shadow: 0 2.5px 8.5px rgba(25, 43, 69, 0.07);
+}
+
+.form-fields {
+  margin: 0;
+}
+
+.field {
+  margin: 0 0 17.5px;
+}
+
+.field-label {
+  display: block;
+  margin-bottom: 8.5px;
+  padding: 0;
+  font-size: 13.5px;
+  font-weight: 700;
+  line-height: 19px;
+}
+
+.required {
+  color: #f0565b;
+}
+
+.field-hint {
+  margin-left: 4px;
+  color: #a1a5ad;
+  font-size: 10.5px;
+  font-weight: 400;
+}
+
+.field-input {
+  display: block;
+  width: 100%;
+  height: 44px;
+  padding: 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 4px;
+  background: #f7f8fa;
+  color: #30343a;
+  font-size: 13.5px;
+  line-height: normal;
+  box-sizing: border-box;
+  appearance: none;
+
+  &::placeholder {
+    color: #999da5;
+    opacity: 1;
+  }
+
+  &:focus-visible {
+    outline: 2px solid #1d80ef;
+    outline-offset: 2px;
+  }
+}
+
+.option-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 6px;
+}
+
+.option-label {
+  position: relative;
+  cursor: pointer;
+  -webkit-tap-highlight-color: transparent;
+}
+
+.option-pill {
+  display: flex;
+  align-items: center;
+  min-height: 31px;
+  padding: 0 12.5px;
+  border: 1px solid #eceef1;
+  border-radius: 16px;
+  background: #f8f8fa;
+  color: #626975;
+  font-size: 12px;
+  line-height: 1.2;
+  box-sizing: border-box;
+}
+
+.option-input:checked + .option-pill {
+  border-color: #1d80ef;
+  background: #eaf4ff;
+  color: #1675df;
+}
+
+.option-input:focus-visible + .option-pill,
+.option-field:focus-visible {
+  outline: 2px solid #1d80ef;
+  outline-offset: 2px;
+}
+
+.interest-field {
+  margin-bottom: 21px;
+}
+
+.submit-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 47px;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  border-radius: 23.5px;
+  background: #187df2;
+  box-shadow: 0 6px 12.5px rgba(22, 121, 242, 0.28);
+  color: #fff;
+  font-size: 16px;
+  font-weight: 700;
+  cursor: pointer;
+
+  &:active:not(:disabled) {
+    background: #0f6edc;
+  }
+
+  &:disabled {
+    background: #8ebff8;
+    box-shadow: none;
+    cursor: default;
+  }
+}
+
+.privacy-note {
+  margin-top: 11px;
+  color: #b0b3ba;
+  font-size: 10px;
+  line-height: 16px;
+  text-align: center;
+}
+
+.visually-hidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  margin: -1px;
+  padding: 0;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+  border: 0;
+}
+</style>

+ 864 - 0
src/views/compliance-registration/index.vue

@@ -0,0 +1,864 @@
+<template>
+  <main class="registration-page">
+    <van-nav-bar
+      title="医药代表合规培训报名表"
+      left-arrow
+      safe-area-inset-top
+      @click-left="goBack"
+    />
+
+    <header class="registration-hero">
+      <h1>正式报名 · 一次填完</h1>
+      <p>信息仅用于学籍注册与证书制作</p>
+      <ol class="registration-steps" aria-label="报名流程">
+        <li
+          v-for="(step, index) in steps"
+          :key="step"
+          :class="{ 'step-active': index === 0 }"
+          :aria-current="index === 0 ? 'step' : undefined"
+        >
+          <span class="step-number">{{ index + 1 }}</span>
+          <span>{{ step }}</span>
+        </li>
+      </ol>
+    </header>
+
+    <div class="registration-content">
+      <aside class="preparation-note">
+        <h2><span aria-hidden="true">📋</span> 填写前请备齐</h2>
+        <p>两寸免冠证件照1~2张(白底或蓝底,用于证书制作)。</p>
+      </aside>
+
+      <van-form
+        ref="registrationForm"
+        class="registration-form"
+        label-align="top"
+        validate-trigger="onSubmit"
+        scroll-to-error
+        :show-error="false"
+        @submit="onSubmit"
+      >
+        <section aria-labelledby="basic-title">
+          <h2 id="basic-title" class="section-title">一、基本信息</h2>
+          <div class="section-card basic-card">
+            <van-field
+              id="registration-name"
+              v-model="form.name"
+              name="name"
+              label="姓名"
+              placeholder="请输入真实姓名(用于证书印制)"
+              autocomplete="name"
+              maxlength="50"
+              required
+              :border="false"
+              :rules="registrationRules.name"
+            />
+
+            <van-field
+              id="registration-gender"
+              name="gender"
+              label="性别"
+              class="choice-field"
+              required
+              :border="false"
+              :rules="registrationRules.gender"
+            >
+              <template #input>
+                <van-radio-group v-model="form.gender" direction="horizontal" aria-label="性别">
+                  <van-radio
+                    name="男"
+                    aria-label="男"
+                    class="gender-pill"
+                    :class="{ selected: form.gender === '男' }"
+                  >
+                    男
+                  </van-radio>
+                  <van-radio
+                    name="女"
+                    aria-label="女"
+                    class="gender-pill"
+                    :class="{ selected: form.gender === '女' }"
+                  >
+                    女
+                  </van-radio>
+                </van-radio-group>
+              </template>
+            </van-field>
+
+            <van-field
+              id="registration-phone"
+              v-model="form.phone"
+              name="phone"
+              label="手机号"
+              type="tel"
+              inputmode="numeric"
+              autocomplete="tel"
+              maxlength="11"
+              placeholder="请输入手机号"
+              required
+              :border="false"
+              :formatter="formatPhone"
+              :rules="registrationRules.phone"
+            />
+
+            <van-field
+              id="registration-id-number"
+              v-model="form.idNumber"
+              name="idNumber"
+              label="身份证号"
+              maxlength="18"
+              placeholder="请输入身份证号(用于学籍与证书注册)"
+              required
+              :border="false"
+              :formatter="formatIdNumber"
+              :rules="registrationRules.idNumber"
+            />
+
+            <van-field
+              id="registration-region"
+              :model-value="form.region.text"
+              name="region"
+              label="所在地区(省/市)"
+              class="picker-field"
+              required
+              :border="false"
+              :rules="[{ required: true, message: '请选择所在省/市' }]"
+            >
+              <template #input>
+                <button
+                  type="button"
+                  class="region-trigger"
+                  :class="{ 'is-placeholder': !form.region.text }"
+                  aria-label="所在地区(省/市)"
+                  aria-haspopup="dialog"
+                  :aria-expanded="showRegionPicker"
+                  @click="openRegionPicker"
+                >
+                  <span>{{ form.region.text || '请选择省 / 市' }}</span>
+                  <van-icon name="arrow-down" aria-hidden="true" />
+                </button>
+              </template>
+            </van-field>
+
+            <van-field
+              id="registration-company"
+              v-model="form.company"
+              name="company"
+              label="所属公司 / 单位 / 机构"
+              placeholder="请输入单位全称"
+              autocomplete="organization"
+              maxlength="100"
+              required
+              :border="false"
+              :rules="registrationRules.company"
+            />
+
+            <van-field
+              id="registration-position"
+              v-model="form.position"
+              name="position"
+              label="职务"
+              placeholder="请输入职务"
+              autocomplete="organization-title"
+              maxlength="50"
+              required
+              :border="false"
+              :rules="registrationRules.position"
+            />
+          </div>
+        </section>
+
+        <section aria-labelledby="education-title">
+          <h2 id="education-title" class="section-title">二、学历报名</h2>
+          <div class="section-card education-card">
+            <van-field
+              id="registration-education"
+              name="education"
+              label="是否需要同步报名学历"
+              class="choice-field education-field"
+              :border="false"
+              :rules="registrationRules.education"
+            >
+              <template #label>
+                <span id="registration-education-label">
+                  是否需要同步报名学历<span class="required-mark">*</span>
+                </span>
+              </template>
+              <template #input>
+                <div class="education-content">
+                  <van-checkbox-group
+                    v-model="form.education"
+                    class="education-options"
+                    aria-labelledby="registration-education-label"
+                  >
+                    <van-checkbox
+                      v-for="option in educationOptions"
+                      :key="option"
+                      :name="option"
+                      class="education-pill"
+                      :class="{ selected: form.education.includes(option) }"
+                    >
+                      {{ option }}
+                    </van-checkbox>
+                  </van-checkbox-group>
+                </div>
+              </template>
+            </van-field>
+          </div>
+        </section>
+
+        <section aria-labelledby="photo-title">
+          <h2 id="photo-title" class="section-title">三、照片(证书使用)</h2>
+          <div class="section-card">
+            <van-field
+              id="registration-photo"
+              name="photo"
+              label="照片(证书使用)"
+              class="choice-field photo-field"
+              required
+              :border="false"
+              :rules="registrationRules.photo"
+            >
+              <template #input>
+                <div class="photo-content">
+                  <p class="field-note">白底或蓝底两寸免冠照片</p>
+                  <van-uploader
+                    v-model="photos"
+                    class="certificate-uploader"
+                    accept="image/*"
+                    multiple
+                    result-type="file"
+                    image-fit="contain"
+                    :preview-options="{ closeable: true }"
+                    :max-count="2"
+                    :before-read="beforeReadPhoto"
+                    aria-label="添加证书照片"
+                  >
+                    <div class="upload-placeholder">
+                      <span class="upload-plus"><van-icon name="plus" /></span>
+                      <span class="upload-label">添加图片</span>
+                      <span class="field-note">图片要求:最少 1 张,最多 2 张</span>
+                    </div>
+                  </van-uploader>
+                </div>
+              </template>
+            </van-field>
+          </div>
+        </section>
+
+        <section aria-labelledby="mailing-title">
+          <h2 id="mailing-title" class="section-title">四、证书寄送</h2>
+          <div class="section-card mailing-card">
+            <van-field
+              id="registration-mailing"
+              :model-value="form.mailingArea.text"
+              name="mailingArea"
+              label="联系地址"
+              class="picker-field"
+              :border="false"
+              :rules="[{ required: true, message: '请选择证书寄送省/市/区' }]"
+            >
+              <template #label>
+                <span id="registration-mailing-label">
+                  联系地址<span class="required-mark">*</span>
+                  <span class="field-hint">用于寄送证书</span>
+                </span>
+              </template>
+              <template #input>
+                <button
+                  type="button"
+                  class="region-trigger"
+                  :class="{ 'is-placeholder': !form.mailingArea.text }"
+                  aria-labelledby="registration-mailing-label"
+                  aria-haspopup="dialog"
+                  :aria-expanded="showMailingPicker"
+                  @click="openMailingPicker"
+                >
+                  <span>{{ form.mailingArea.text || '省 / 市 / 区' }}</span>
+                  <van-icon name="arrow-down" aria-hidden="true" />
+                </button>
+              </template>
+            </van-field>
+            <van-field
+              id="registration-address"
+              v-model="form.address"
+              name="address"
+              label="详细地址"
+              label-class="address-label"
+              placeholder="请填写详细地址(小区/楼栋/门牌号)"
+              autocomplete="street-address"
+              maxlength="100"
+              :border="false"
+              :rules="registrationRules.address"
+            />
+          </div>
+        </section>
+
+        <van-button class="submit-button" type="primary" native-type="submit" round block>
+          提交报名
+        </van-button>
+        <footer class="privacy-note">
+          <p>教务老师将于 1 个工作日内与你确认报名信息并开通课程</p>
+          <p>信息仅用于学籍注册与证书制作,不会用于其他用途</p>
+        </footer>
+      </van-form>
+    </div>
+
+    <van-popup v-model:show="showRegionPicker" position="bottom" round safe-area-inset-bottom>
+      <van-cascader
+        v-model="regionDraft"
+        title="请选择所在省 / 市"
+        :options="provinceCityOptions"
+        @close="showRegionPicker = false"
+        @finish="confirmRegion"
+      />
+    </van-popup>
+    <van-popup v-model:show="showMailingPicker" position="bottom" round safe-area-inset-bottom>
+      <van-cascader
+        v-model="mailingDraft"
+        title="请选择省 / 市 / 区"
+        :options="provinceCityCountyOptions"
+        @close="showMailingPicker = false"
+        @finish="confirmMailingArea"
+      />
+    </van-popup>
+  </main>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { showDialog, showToast } from 'vant'
+import type { CascaderOption, FormInstance, UploaderFileListItem } from 'vant'
+import { useCascaderAreaData } from '@vant/area-data'
+import { registrationRules } from './validation'
+
+interface AreaSelection {
+  value: string
+  text: string
+}
+
+interface CascaderFinishEvent {
+  value: string | number
+  selectedOptions: CascaderOption[]
+}
+
+const router = useRouter()
+const registrationForm = ref<FormInstance>()
+const provinceCityCountyOptions = useCascaderAreaData()
+const provinceCityOptions = provinceCityCountyOptions.map((province) => ({
+  text: province.text,
+  value: province.value,
+  children: province.children?.map((city) => ({ text: city.text, value: city.value })),
+}))
+const steps = ['填写提交', '教务审核', '开通课程', '双证上岗']
+const educationOptions = ['【专科】药学专业、护理学专业', '【专科起点本科】药学专业、护理学专业']
+const form = reactive({
+  name: '',
+  gender: '' as '男' | '女' | '',
+  phone: '',
+  idNumber: '',
+  region: { value: '', text: '' } as AreaSelection,
+  company: '',
+  position: '',
+  education: [] as string[],
+  mailingArea: { value: '', text: '' } as AreaSelection,
+  address: '',
+})
+const photos = ref<UploaderFileListItem[]>([])
+const showRegionPicker = ref(false)
+const showMailingPicker = ref(false)
+const regionDraft = ref('')
+const mailingDraft = ref('')
+
+const formatPhone = (value: string) => value.replace(/\D/g, '').slice(0, 11)
+const formatIdNumber = (value: string) =>
+  value
+    .replace(/[^\dXx]/g, '')
+    .toUpperCase()
+    .slice(0, 18)
+
+const openRegionPicker = () => {
+  regionDraft.value = form.region.value
+  showRegionPicker.value = true
+}
+const openMailingPicker = () => {
+  mailingDraft.value = form.mailingArea.value
+  showMailingPicker.value = true
+}
+const toAreaSelection = ({ value, selectedOptions }: CascaderFinishEvent): AreaSelection => ({
+  value: String(value),
+  text: selectedOptions.map((option) => option.text).join(' / '),
+})
+const confirmRegion = (selection: CascaderFinishEvent) => {
+  form.region = toAreaSelection(selection)
+  showRegionPicker.value = false
+  registrationForm.value?.resetValidation('region')
+}
+const confirmMailingArea = (selection: CascaderFinishEvent) => {
+  form.mailingArea = toAreaSelection(selection)
+  showMailingPicker.value = false
+  registrationForm.value?.resetValidation('mailingArea')
+}
+
+const beforeReadPhoto = (file: File | File[]) => {
+  const files = Array.isArray(file) ? file : [file]
+  if (files.some((item) => !item.type.startsWith('image/'))) {
+    showToast('请选择图片文件')
+    return false
+  }
+  if (photos.value.length + files.length > 2) {
+    showToast('最多上传2张照片')
+    return false
+  }
+  return true
+}
+
+const onSubmit = () => {
+  // 仅完成 UI 与前端校验。正式接口就绪后在此对接,不发送或持久化个人信息。
+  showDialog({ title: '提示', message: '报名暂未开放,请稍后再试。' })
+}
+
+const goBack = () => {
+  if (window.history.state?.back) router.back()
+  else router.replace({ name: 'CompliancePlan' })
+}
+</script>
+
+<style scoped lang="scss">
+.registration-page {
+  min-height: 100vh;
+  background: #f3f4f6;
+  color: #283342;
+  font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
+  --van-nav-bar-title-text-color: #283342;
+  --van-nav-bar-icon-color: #283342;
+  --van-field-placeholder-text-color: #a2abb9;
+  --van-field-label-color: #333;
+  --van-field-input-text-color: #394452;
+
+  h1,
+  h2,
+  p,
+  ol {
+    margin: 0;
+  }
+}
+
+.registration-hero {
+  padding: 20px 20px 19px;
+  background:
+    radial-gradient(ellipse at 105% 0%, rgba(255, 218, 112, 0.25), transparent 48%),
+    linear-gradient(120deg, #0b47a8 0%, #1260d2 72%, #2879e7 100%);
+  color: #fff;
+
+  h1 {
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 700;
+  }
+
+  p {
+    margin-top: 4px;
+    color: #a9c8f4;
+    font-size: 12px;
+    line-height: 18px;
+  }
+}
+
+.registration-steps {
+  display: flex;
+  align-items: center;
+  margin-top: 13px !important;
+  padding: 0;
+  list-style: none;
+
+  li {
+    display: flex;
+    flex: 1;
+    align-items: center;
+    gap: 5px;
+    color: #a8c9f8;
+    font-size: 11px;
+    white-space: nowrap;
+
+    &:not(:last-child)::after {
+      content: '';
+      flex: 1;
+      min-width: 8px;
+      height: 1px;
+      margin: 0 6px 0 1px;
+      background: rgba(183, 212, 255, 0.42);
+    }
+
+    &:last-child {
+      flex: none;
+    }
+  }
+
+  .step-active {
+    color: #fff;
+    font-weight: 600;
+
+    .step-number {
+      border-color: #ffdb29;
+      background: #ffdb29;
+      color: #765600;
+    }
+  }
+}
+
+.step-number {
+  display: flex;
+  flex: 0 0 18px;
+  align-items: center;
+  justify-content: center;
+  height: 18px;
+  border: 1px solid #8bb7fa;
+  border-radius: 50%;
+  background: rgba(209, 230, 255, 0.18);
+  color: #dceaff;
+  font-size: 11px;
+  box-sizing: border-box;
+}
+
+.registration-content {
+  padding: 12px 12px 20px;
+  padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
+}
+
+.preparation-note {
+  padding: 12px 14px;
+  border: 1px solid #ffce66;
+  border-radius: 8px;
+  background: #fff9ea;
+  color: #906109;
+
+  h2 {
+    font-size: 13px;
+    font-weight: 700;
+    line-height: 20px;
+  }
+
+  p {
+    margin-top: 4px;
+    font-size: 11.5px;
+    line-height: 18px;
+  }
+}
+
+.registration-form section {
+  margin-top: 16px;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 15px;
+  font-weight: 700;
+  line-height: 20px;
+
+  &::before {
+    content: '';
+    width: 3px;
+    height: 13px;
+    border-radius: 2px;
+    background: #1b7aff;
+  }
+}
+
+.section-hint {
+  margin-left: 4px;
+  color: #a1aab8;
+  font-size: 11px;
+  font-weight: 400;
+}
+
+.section-card {
+  margin-top: 9px;
+  padding: 16px 14px;
+  border-radius: 9px;
+  background: #fff;
+  box-shadow: 0 2px 4px rgba(31, 45, 61, 0.06);
+
+  :deep(.van-field) {
+    display: block;
+    padding: 0;
+    overflow: visible;
+    background: transparent;
+    font-size: 14px;
+    line-height: 20px;
+
+    &:not(:last-child) {
+      margin-bottom: 18px;
+    }
+  }
+
+  :deep(.van-field__label) {
+    width: auto;
+    margin: 0 0 6px;
+    font-size: 13.5px;
+    font-weight: 600;
+    line-height: 19px;
+
+    &::before {
+      display: none;
+    }
+  }
+
+  :deep(.van-field__label--required)::after {
+    content: '*';
+    margin-left: 3px;
+    color: #ff4d4f;
+  }
+
+  :deep(.van-field__body) {
+    min-height: 44px;
+    padding: 0 12px;
+    border: 1px solid #eceef1;
+    border-radius: 5px;
+    background: #f5f6f8;
+    box-sizing: border-box;
+  }
+
+  :deep(.van-field__control) {
+    font-size: 14px;
+    line-height: 22px;
+  }
+
+  :deep(.van-field__error-message) {
+    padding-top: 4px;
+    font-size: 11px;
+    line-height: 16px;
+  }
+
+  :deep(.choice-field .van-field__body),
+  :deep(.picker-field .van-field__body) {
+    min-height: 0;
+    padding: 0;
+    border: 0;
+    background: transparent;
+  }
+
+  :deep(.van-field__value) {
+    overflow: visible;
+  }
+}
+
+.gender-pill,
+.education-pill {
+  min-height: 36px;
+  border: 1px solid #eceef1;
+  border-radius: 22px;
+  background: #f5f6f8;
+  box-sizing: border-box;
+  -webkit-tap-highlight-color: transparent;
+
+  &.selected {
+    border-color: #1980ff;
+    background: #ecf4ff;
+  }
+
+  &.selected :deep(.van-radio__label),
+  &.selected :deep(.van-checkbox__label) {
+    color: #1878ed;
+  }
+}
+
+.gender-pill {
+  margin-right: 8px;
+  padding: 0 15px;
+
+  :deep(.van-radio__icon),
+  :deep(.van-radio__icon .van-icon) {
+    width: 16px;
+    height: 16px;
+    font-size: 12px;
+    line-height: 16px;
+  }
+
+  :deep(.van-radio__icon .van-icon) {
+    border-color: #cfd8e5;
+  }
+
+  :deep(.van-radio__label) {
+    margin-left: 6px;
+    color: #657181;
+    font-size: 13px;
+    line-height: 20px;
+  }
+}
+
+.region-trigger {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  width: 100%;
+  min-height: 44px;
+  padding: 10px 12px;
+  border: 1px solid #eceef1;
+  border-radius: 5px;
+  background: #f5f6f8;
+  color: #394452;
+  font-size: 14px;
+  line-height: 22px;
+  text-align: left;
+  box-sizing: border-box;
+  cursor: pointer;
+
+  &.is-placeholder {
+    color: #a2abb9;
+  }
+
+  .van-icon {
+    flex-shrink: 0;
+    color: #a2abb9;
+    font-size: 12px;
+  }
+}
+
+.required-mark {
+  margin-left: 3px;
+  color: #ff4d4f;
+}
+
+.field-hint {
+  margin-left: 5px;
+  color: #a1aab8;
+  font-size: 11px;
+  font-weight: 400;
+}
+
+.field-note {
+  color: #a1aab8;
+  font-size: 11px;
+  line-height: 17px;
+}
+
+.education-content,
+.photo-content {
+  width: 100%;
+}
+
+.education-options {
+  display: flex;
+  flex-direction: column;
+  gap: 28px;
+  margin-top: 16px;
+}
+
+.education-card {
+  padding-bottom: 28px;
+}
+
+.education-pill {
+  width: 100%;
+  min-height: 44px;
+  padding: 10px 12px;
+  border-radius: 5px;
+
+  :deep(.van-checkbox__icon) {
+    display: none;
+  }
+
+  :deep(.van-checkbox__label) {
+    margin: 0;
+    color: #657181;
+    font-size: 13px;
+    line-height: 20px;
+  }
+}
+
+.certificate-uploader {
+  display: block;
+  width: 100%;
+  margin-top: 5px;
+
+  :deep(.van-uploader__wrapper),
+  :deep(.van-uploader__input-wrapper) {
+    width: 100%;
+  }
+
+  :deep(.van-uploader__wrapper) {
+    gap: 8px;
+  }
+
+  :deep(.van-uploader__preview) {
+    width: 100%;
+    margin: 0;
+  }
+
+  :deep(.van-uploader__preview-image) {
+    width: 100%;
+    height: 160px;
+    border-radius: 5px;
+    background: #f5f6f8;
+  }
+}
+
+.upload-placeholder {
+  display: flex;
+  min-height: 95px;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 3px;
+  border: 1px dashed #b4ccec;
+  border-radius: 5px;
+  background: #f5f6f8;
+  box-sizing: border-box;
+}
+
+.upload-plus {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #e5efff;
+  color: #428cff;
+  font-size: 22px;
+}
+
+.upload-label {
+  color: #657181;
+  font-size: 13px;
+  line-height: 20px;
+}
+
+.mailing-card {
+  :deep(.van-field:not(:last-child)) {
+    margin-bottom: 6px;
+  }
+
+  :deep(.address-label) {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    overflow: hidden;
+    clip-path: inset(50%);
+  }
+}
+
+.submit-button {
+  height: 55px;
+  margin-top: 20px;
+  border: 0;
+  background: #1977ff;
+  box-shadow: 0 5px 12px rgba(25, 119, 255, 0.26);
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.privacy-note {
+  margin-top: 12px;
+  color: #a1aab8;
+  font-size: 10px;
+  line-height: 18px;
+  text-align: center;
+}
+</style>

+ 46 - 0
src/views/compliance-registration/validation.ts

@@ -0,0 +1,46 @@
+import type { FieldRule } from 'vant'
+
+const requiredText = (label: string, maxLength: number): FieldRule[] => [
+  {
+    required: true,
+    validator: (value: unknown) => typeof value === 'string' && Boolean(value.trim()),
+    message: `请输入${label}`,
+  },
+  {
+    validator: (value: unknown) => typeof value === 'string' && [...value].length <= maxLength,
+    message: `${label}最多${maxLength}个字`,
+  },
+]
+
+export const registrationRules: Record<string, FieldRule[]> = {
+  name: requiredText('真实姓名', 50),
+  gender: [
+    {
+      required: true,
+      validator: (value: unknown) => value === '男' || value === '女',
+      message: '请选择性别(男或女)',
+    },
+  ],
+  phone: [
+    { required: true, message: '请输入手机号' },
+    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误' },
+  ],
+  idNumber: [
+    { required: true, message: '请输入身份证号' },
+    {
+      pattern: /^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dX]$/,
+      message: '身份证号格式错误',
+    },
+  ],
+  company: requiredText('单位全称', 100),
+  position: requiredText('职务', 50),
+  education: [{ required: true, message: '请至少选择一项学历报名' }],
+  photo: [
+    { required: true, message: '请添加证书使用的照片' },
+    {
+      validator: (value: unknown) => Array.isArray(value) && value.length >= 1 && value.length <= 2,
+      message: '照片数量须为1~2张',
+    },
+  ],
+  address: requiredText('详细地址', 100),
+}

+ 1 - 0
vite.config.ts

@@ -38,6 +38,7 @@ export default defineConfig(({ mode }): UserConfig => {
           // 使用 Vite 原生 manualChunks 替代 chunk-split 插件
           manualChunks(id: string) {
             if (id.includes('node_modules')) {
+              if (id.includes('@vant/area-data')) return 'area-data'
               if (id.includes('lodash-es')) return 'vendor-lodash'
               if (id.includes('echarts')) return 'vendor-echarts'
               return 'vendor'