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:
+26
-11
@@ -125,29 +125,44 @@ public class MpH5AuthController {
|
||||
/**
|
||||
* 302 回 H5 页面。token 放 <b>fragment</b>(不发给服务器),错误信息放 query。
|
||||
*/
|
||||
/**
|
||||
* 302 回 H5 页面。
|
||||
*
|
||||
* <p>token 放在 <b>hash 路由的 query</b> 里,形如
|
||||
* {@code http://host/#/pages/index/index?token=X&scene=Y}。这个格式同时满足两件事:
|
||||
* <ul>
|
||||
* <li><b>安全</b>:整段在 {@code #} 之后,<b>不会发给服务器</b> ——
|
||||
* 不进 Nginx access log、不进 Referer。</li>
|
||||
* <li><b>能用</b>:uni-app H5 的 {@code onLoad} 解析的是<b>路由</b>的 query。
|
||||
* 实测写成裸 hash({@code #token=X&scene=Y})时,它既不是外层查询串、
|
||||
* 也不是合法的路由路径 —— 两边都不认,页面读不到任何参数,
|
||||
* 表现是"授权明明成功了,落地页却说 scene 没获取到"。</li>
|
||||
* </ul>
|
||||
* 换句话说:<b>参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query</b>,
|
||||
* 别放裸 hash。
|
||||
*/
|
||||
private ResponseEntity<Void> redirectToApp(String token, String scene, String error) {
|
||||
String base = props.mpH5() == null ? null : props.mpH5().h5BaseUrlOrDefault();
|
||||
if (base == null || base.isBlank()) {
|
||||
base = "http://127.0.0.1:5174";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(base);
|
||||
String frag = "";
|
||||
StringBuilder q = new StringBuilder();
|
||||
if (token != null) {
|
||||
frag += "token=" + enc(token);
|
||||
q.append("token=").append(enc(token));
|
||||
}
|
||||
if (scene != null && !scene.isBlank()) {
|
||||
frag += (frag.isEmpty() ? "" : "&") + "scene=" + enc(scene);
|
||||
q.append(q.isEmpty() ? "" : "&").append("scene=").append(enc(scene));
|
||||
}
|
||||
String url = base;
|
||||
if (error != null) {
|
||||
// 错误放 query:它不敏感,而且我们要让页面能读到
|
||||
sb.append("?authError=").append(enc(error));
|
||||
// 错误放外层 query:它不敏感,而且页面要能读到
|
||||
url += "?authError=" + enc(error);
|
||||
}
|
||||
if (!frag.isEmpty()) {
|
||||
sb.append("#").append(frag);
|
||||
if (!q.isEmpty()) {
|
||||
// hash 路由格式:uni-app 能解析,且整段不发给服务器
|
||||
url += "#/pages/index/index?" + q;
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", sb.toString())
|
||||
.build();
|
||||
return ResponseEntity.status(HttpStatus.FOUND).header("Location", url).build();
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/verify/verify",
|
||||
"style": {
|
||||
"navigationBarTitleText": "核对身份",
|
||||
"navigationBarBackgroundColor": "#0052D9",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -89,6 +89,25 @@ const isError = ref(false)
|
||||
const loading = ref(false)
|
||||
const title = ref('材料填报')
|
||||
|
||||
/**
|
||||
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
|
||||
*
|
||||
* ⚠️ 为什么跳转时要把它带上:**H5 的页面跳转不保留 URL 参数**,
|
||||
* 直接 navigateTo 到核验页的话,那边读不到 scene、就不知道是哪个任务。
|
||||
* 这是 H5 与小程序的一个真实差异(小程序可以靠 scene 直进,H5 得自己带)。
|
||||
* 所以这里显式拼参数,核验页再从自己的 URL 里读出来。
|
||||
*/
|
||||
function sceneFromUrl(): string {
|
||||
try {
|
||||
const hash = typeof location !== 'undefined' ? location.hash : ''
|
||||
const q = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : ''
|
||||
const params = new URLSearchParams(q)
|
||||
return params.get('scene') || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (ctx.value.taskStatus) {
|
||||
case 1: return 'land__badge--ok'
|
||||
@@ -154,18 +173,42 @@ async function load() {
|
||||
}
|
||||
|
||||
function start() {
|
||||
// 填报主流程属 M3(/mp/login → 门禁核验 → 表单),当前骨架先给出明确预期,
|
||||
// 而不是静默什么都不做 —— 后者会被当成"按钮坏了"
|
||||
const s = scene.value || sceneFromUrl()
|
||||
if (!s) {
|
||||
uni.showModal({
|
||||
title: '填报功能开发中',
|
||||
content: `已识别任务「${ctx.value.taskName ?? ''}」。填报主流程(身份核验与表单填写)属下一阶段开发。`,
|
||||
title: '无法继续',
|
||||
content: '没有取到任务信息,请重新扫班主任发的二维码进入。',
|
||||
showCancel: false
|
||||
})
|
||||
return
|
||||
}
|
||||
// 带上 scene —— 见 sceneFromUrl 的说明(H5 跳转不保留 URL 参数)
|
||||
uni.navigateTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(s)}` })
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
rawScene.value = options?.scene || options?.q || ''
|
||||
scene.value = pickScene(options)
|
||||
|
||||
// 授权回调失败时后端会把原因放在外层 query 的 authError 里(不敏感,可以进服务器日志)
|
||||
const authErr = options?.authError
|
||||
if (authErr) {
|
||||
isError.value = true
|
||||
message.value = decodeURIComponent(String(authErr))
|
||||
return
|
||||
}
|
||||
|
||||
// 授权回调会把会话 token 带回来(在 hash 路由的 query 里,不进服务器日志)。
|
||||
// 存下来供后续页面用 —— 门禁核验、提交都要靠它。
|
||||
const t = options?.token
|
||||
if (t) {
|
||||
try {
|
||||
uni.setStorageSync('token', t)
|
||||
} catch {
|
||||
// 存不了也不该让页面挂掉:落地页本身不需要 token
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
})
|
||||
|
||||
@@ -192,6 +235,12 @@ page {
|
||||
min-height: 100vh;
|
||||
padding: 32rpx;
|
||||
|
||||
/* 桌面浏览器打开时不至于拉满全屏 —— 主战场是手机,
|
||||
但自动化验证与临时桌面查看都跑在桌面浏览器里,窄一点更像真实形态 */
|
||||
max-width: 720rpx;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__hero {
|
||||
padding: 48rpx 32rpx;
|
||||
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user