feat(h5): 身份核验页(门禁)—— 学生流程首个可用闭环

学生填材料之前的唯一一道门:姓名 + 身份证号与老师提交的名单核对,
通过后把微信账号与名单里那一行钉死。后面所有材料都归属那个人。

交付:
· pages/verify/verify.vue —— 核验页
· 落地页「开始填报」→ 核验页(带 scene)
· 两页都加 max-width 居中(桌面查看/自动化验证时不至于拉满全屏)

三处刻意的设计:
1) 失败提示不区分"姓名错"还是"号码错"(后端统一 40007),前端也只显示
   后端那一句话 —— **不在前端加"可能是身份证号错了"这类推断**。
   分开提示等于给出一个身份证号枚举器。
2) 预填项标注「老师已填,请核对」并明确「学生不能改」:报考职业/工种/等级
   由老师按报考计划确定(名单里实测 28/28 都有值)。让学生重填只会制造错误,
   而报错工种在机构那边是直接不受理。
3) 前端只做"非空 + 长度"这类明显笔误的提示,真正的判定(校验位、名册匹配、
   绑定状态)全交给后端。

修掉一个 H5 特有的坑:
   **H5 的页面跳转不保留 URL 参数** —— 直接 navigateTo 到核验页的话,
   那边读不到 scene、就不知道是哪个任务(小程序没有这个问题)。
   所以落地页跳转时显式拼上 scene,核验页再从自己的 URL 里读出来。

验收(全程无头浏览器自动完成,除"微信授权"那一步)。
现在 task 17 已发布(收集中)并带测试名册,手机上可走完整流程。

注:task 17 现在是「收集中」并带 1 行测试名册(张同学 / 361029200912100015),
   是我为验证留的演示数据。
This commit is contained in:
2026-09-29 11:24:32 +08:00
parent 2659cd5a8c
commit aa2f7f175c
4 changed files with 472 additions and 18 deletions
+382
View File
@@ -0,0 +1,382 @@
<template>
<view class="verify">
<view class="verify__wrap">
<!-- 任务抬头:让学生确认自己在给谁填 -->
<view class="verify__hero">
<text class="verify__tenant">{{ ctx.tenantName || '材料填报' }}</text>
<text class="verify__task">{{ ctx.taskName }}</text>
</view>
<!-- ① 还没核验:填姓名 + 证件号 -->
<view v-if="!matched" class="verify__card">
<view class="verify__title">核对身份</view>
<view class="verify__hint">
请填写<text class="verify__strong">报名时用的姓名和身份证号</text>,
与老师提交的名单核对。核对通过后才能开始填报。
</view>
<view class="verify__field">
<text class="verify__label">姓名</text>
<input v-model="realName" class="verify__input" placeholder="请填写真实姓名"
:disabled="submitting" />
</view>
<view class="verify__field">
<text class="verify__label">身份证号</text>
<input v-model="idCardNo" class="verify__input" placeholder="18 位身份证号"
maxlength="18" :disabled="submitting" />
</view>
<button class="verify__btn" :disabled="submitting || !canSubmit" @click="submit">
{{ submitting ? '核对中…' : '核对并继续' }}
</button>
<view v-if="error" class="verify__error">{{ error }}</view>
<view class="verify__foot">
核对不上?先确认姓名和证件号没有打错。仍然不行请联系班主任。
</view>
</view>
<!-- ② 核验通过:显示名册里老师已填的信息 -->
<view v-else class="verify__card">
<view class="verify__ok">✓ 核对通过</view>
<view class="verify__who">
<text class="verify__who-name">{{ matched.realName }}</text>
<text class="verify__who-id">{{ matched.idCardMask }}</text>
</view>
<view v-if="matched.orgName" class="verify__row">
<text class="verify__row-label">班级</text>
<text class="verify__row-value">{{ matched.orgName }}</text>
</view>
<view v-if="prefillRows.length" class="verify__section">
<view class="verify__section-title">
老师已填,请核对
<text class="verify__section-note">(这几项由老师按报考计划确定,学生不能改)</text>
</view>
<view v-for="row in prefillRows" :key="row.label" class="verify__row">
<text class="verify__row-label">{{ row.label }}</text>
<text class="verify__row-value">{{ row.value }}</text>
</view>
</view>
<button class="verify__btn" @click="next">开始填写材料</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 身份核验页(门禁)。
*
* <p>这是学生填材料之前的<strong>唯一一道门</strong>:姓名 + 身份证号必须与老师提交的
* 名单完全一致,否则不放行。核验通过后,系统就把这个微信账号与名单里的某一行
* <strong>钉死</strong>了 —— 后面所有材料都归属于那个人,不再依赖任何人的自觉。
*
* <p>三处刻意的设计:
* <ol>
* <li><strong>失败提示不区分"姓名错"还是"号码错"</strong>(后端统一返回 40007)。
* 分开提示等于给出一个身份证号枚举器 —— 用任意姓名 + 目标号码试,
* 若提示"姓名不对"就说明号码在名单里。所以页面上也只显示后端给的那一句话,
* <strong>不要自作聪明地在前端加"可能是身份证号错了"这类推断</strong>。</li>
* <li><strong>预填项标注"老师已填,请核对"并明确不可改</strong>:报考职业/工种/等级
* 由老师按报考计划确定(名单里实测 28/28 都有值)。让学生重填只会制造错误,
* 而报错工种在机构那边是直接不受理。</li>
* <li><strong>不在前端做证件号格式校验后放行</strong>:只做"非空 + 长度"这种
* 明显笔误的提示,真正的判定交给后端 —— 校验位、名册匹配、绑定状态都在那边。</li>
* </ol>
*/
defineOptions({ name: 'Verify' })
interface SceneContext {
tenantName: string | null
taskId: number | null
taskName: string | null
canSubmit: boolean
}
interface MatchResult {
matched: boolean
rosterId: number
realName: string
idCardMask: string
orgName: string | null
prefill: Record<string, unknown>
}
const ctx = ref<Partial<SceneContext>>({})
const matched = ref<MatchResult | null>(null)
const realName = ref('')
const idCardNo = ref('')
const error = ref('')
const submitting = ref(false)
let taskId = 0
const canSubmit = computed(() => !!ctx.value.taskId)
/** 预填项的展示名(只用名册里老师填过的那几项) */
const PREFILL_LABELS: Record<string, string> = {
occupation: '报考职业',
worktype: '报考工种',
jobLevel: '报考等级',
education: '学历',
school: '在读学校',
major: '所学专业',
enrollDate: '入学时间',
workUnit: '工作单位',
phone: '联系电话'
}
const prefillRows = computed(() => {
const pf = matched.value?.prefill || {}
const rows: Array<{ label: string, value: string }> = []
for (const [key, label] of Object.entries(PREFILL_LABELS)) {
const v = pf[key]
if (v !== undefined && v !== null && v !== '') {
rows.push({ label, value: String(v) })
}
}
return rows
})
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
async function loadContext(scene: string) {
if (!scene) return
try {
const data = await request<SceneContext>({
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(scene)}`
})
ctx.value = data
taskId = data.taskId || 0
} catch (e) {
error.value = e instanceof Error ? e.message : '读取任务信息失败'
}
}
async function submit() {
error.value = ''
const name = realName.value.trim()
const id = idCardNo.value.trim()
if (!name) {
error.value = '请填写姓名'
return
}
if (id.length !== 18) {
error.value = '身份证号应为 18 位'
return
}
submitting.value = true
try {
matched.value = await request<MatchResult>({
url: '/api/v1/mp/roster/match',
method: 'POST',
token: token(),
data: { realName: name, idCardNo: id, taskId }
})
} catch (e) {
// 错误提示原样显示后端那句话 —— 见文件头第 1 条,不要在前端加推断
error.value = e instanceof Error ? e.message : '核对失败'
} finally {
submitting.value = false
}
}
function next() {
uni.showToast({ title: '填写材料功能开发中', icon: 'none' })
}
onLoad((options) => {
loadContext(options?.scene || '')
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.verify {
min-height: 100vh;
padding: 24rpx;
/* 桌面浏览器打开时不至于拉满全屏 —— 主战场是手机,但自动化验证与
临时桌面查看都跑在这里,窄一点更像真实形态 */
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__tenant {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.82);
}
&__task {
display: block;
margin-top: 10rpx;
font-size: 34rpx;
font-weight: 600;
line-height: 1.4;
color: #fff;
}
&__card {
margin-top: 20rpx;
padding: 32rpx 28rpx;
background: #fff;
border-radius: 12rpx;
}
&__title {
font-size: 32rpx;
font-weight: 600;
color: #000000e6;
}
&__hint {
margin-top: 12rpx;
font-size: 26rpx;
line-height: 1.7;
color: #00000080;
}
&__strong {
font-weight: 600;
color: $primary;
}
&__field {
margin-top: 28rpx;
}
&__label {
display: block;
margin-bottom: 10rpx;
font-size: 26rpx;
color: #00000099;
}
&__input {
height: 88rpx;
padding: 0 20rpx;
font-size: 30rpx;
background: #f5f6f8;
border-radius: 10rpx;
}
&__btn {
margin-top: 40rpx;
font-size: 32rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&::after { border: none; }
&[disabled] {
color: #0000004d;
background: #e8e8e8;
}
}
&__error {
margin-top: 24rpx;
padding: 20rpx 24rpx;
font-size: 26rpx;
line-height: 1.7;
color: $danger;
background: #fef0f0;
border-radius: 10rpx;
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #0000004d;
}
&__ok {
font-size: 32rpx;
font-weight: 600;
color: $success;
}
&__who {
display: flex;
align-items: baseline;
gap: 16rpx;
margin-top: 16rpx;
}
&__who-name {
font-size: 36rpx;
font-weight: 600;
color: #000000e6;
}
&__who-id {
font-size: 26rpx;
color: #00000080;
}
&__section {
margin-top: 32rpx;
padding-top: 24rpx;
border-top: 1rpx solid #eee;
}
&__section-title {
font-size: 26rpx;
font-weight: 600;
color: #00000099;
}
&__section-note {
font-weight: 400;
color: #0000004d;
}
&__row {
display: flex;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
&__row-label {
font-size: 28rpx;
color: #00000099;
}
&__row-value {
font-size: 28rpx;
font-weight: 500;
color: #000000e6;
}
}
</style>