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 }}
+
+
+
+
+
+
+
+
+
+
+
+