From aa2f7f175ccd9b513b6e91c9790fa2a13a3f7313 Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 11:24:32 +0800
Subject: [PATCH] =?UTF-8?q?feat(h5):=20=E8=BA=AB=E4=BB=BD=E6=A0=B8?=
=?UTF-8?q?=E9=AA=8C=E9=A1=B5=EF=BC=88=E9=97=A8=E7=A6=81=EF=BC=89=E2=80=94?=
=?UTF-8?q?=E2=80=94=20=E5=AD=A6=E7=94=9F=E6=B5=81=E7=A8=8B=E9=A6=96?=
=?UTF-8?q?=E4=B8=AA=E5=8F=AF=E7=94=A8=E9=97=AD=E7=8E=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
学生填材料之前的唯一一道门:姓名 + 身份证号与老师提交的名单核对,
通过后把微信账号与名单里那一行钉死。后面所有材料都归属那个人。
交付:
· 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),
是我为验证留的演示数据。
---
.../mp/controller/MpH5AuthController.java | 37 +-
frontend-mp/src/pages.json | 8 +
frontend-mp/src/pages/index/index.vue | 63 ++-
frontend-mp/src/pages/verify/verify.vue | 382 ++++++++++++++++++
4 files changed, 472 insertions(+), 18 deletions(-)
create mode 100644 frontend-mp/src/pages/verify/verify.vue
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
index dcc2244..6bf330f 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
@@ -125,29 +125,44 @@ public class MpH5AuthController {
/**
* 302 回 H5 页面。token 放 fragment(不发给服务器),错误信息放 query。
*/
+ /**
+ * 302 回 H5 页面。
+ *
+ *
token 放在 hash 路由的 query 里,形如
+ * {@code http://host/#/pages/index/index?token=X&scene=Y}。这个格式同时满足两件事:
+ *
+ * - 安全:整段在 {@code #} 之后,不会发给服务器 ——
+ * 不进 Nginx access log、不进 Referer。
+ * - 能用:uni-app H5 的 {@code onLoad} 解析的是路由的 query。
+ * 实测写成裸 hash({@code #token=X&scene=Y})时,它既不是外层查询串、
+ * 也不是合法的路由路径 —— 两边都不认,页面读不到任何参数,
+ * 表现是"授权明明成功了,落地页却说 scene 没获取到"。
+ *
+ * 换句话说:参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query,
+ * 别放裸 hash。
+ */
private ResponseEntity 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) {
diff --git a/frontend-mp/src/pages.json b/frontend-mp/src/pages.json
index e5f5701..36be32e 100644
--- a/frontend-mp/src/pages.json
+++ b/frontend-mp/src/pages.json
@@ -8,6 +8,14 @@
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
+ },
+ {
+ "path": "pages/verify/verify",
+ "style": {
+ "navigationBarTitleText": "核对身份",
+ "navigationBarBackgroundColor": "#0052D9",
+ "navigationBarTextStyle": "white"
+ }
}
],
"globalStyle": {
diff --git a/frontend-mp/src/pages/index/index.vue b/frontend-mp/src/pages/index/index.vue
index 16ec0f9..109fc47 100644
--- a/frontend-mp/src/pages/index/index.vue
+++ b/frontend-mp/src/pages/index/index.vue
@@ -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 → 门禁核验 → 表单),当前骨架先给出明确预期,
- // 而不是静默什么都不做 —— 后者会被当成"按钮坏了"
- uni.showModal({
- title: '填报功能开发中',
- content: `已识别任务「${ctx.value.taskName ?? ''}」。填报主流程(身份核验与表单填写)属下一阶段开发。`,
- showCancel: false
- })
+ const s = scene.value || sceneFromUrl()
+ if (!s) {
+ uni.showModal({
+ 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%);
diff --git a/frontend-mp/src/pages/verify/verify.vue b/frontend-mp/src/pages/verify/verify.vue
new file mode 100644
index 0000000..c9f92a2
--- /dev/null
+++ b/frontend-mp/src/pages/verify/verify.vue
@@ -0,0 +1,382 @@
+
+
+
+
+
+ {{ ctx.tenantName || '材料填报' }}
+ {{ ctx.taskName }}
+
+
+
+
+ 核对身份
+
+ 请填写报名时用的姓名和身份证号,
+ 与老师提交的名单核对。核对通过后才能开始填报。
+
+
+
+ 姓名
+
+
+
+ 身份证号
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+ ✓ 核对通过
+
+ {{ matched.realName }}
+ {{ matched.idCardMask }}
+
+
+ 班级
+ {{ matched.orgName }}
+
+
+
+
+ 老师已填,请核对
+ (这几项由老师按报考计划确定,学生不能改)
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+
+