Files
xiaotutongxue/frontend-mp/src/pages/verify/verify.vue
T
lovife 1f3c485ceb feat(role,scope)!: 角色与授权可配 + 需求收敛(4 角色 / 10 菜单 / 删两模块)
角色与授权(交接摘要 §一·三十五):
- 权限判定合并两个来源:内置角色按代码(BuiltinRole)、自定义角色按库
  (sys_role_permission)。实现放 module 层的 DbRoleGrantSource,security 只依赖
  接口 RoleGrantSource —— 否则角色服务要调 permissionChecker,两个包迟早成环
- 权限判定必须加缓存:此前是零 IO,而压测基线是"SQL 条数恒定"。缓存放 Redis,
  空结果也缓存(否则已删角色的 token 会把库打穿)、Redis 挂了退化成直读库、TTL 只兜底
- V18 建 sys_role.user_type(账号的 user_type 一直从角色反推,自定义角色不在代码映射里)
  与 sys_role_menu;8 个接口 + 错误码 40045;契约 119 -> 129
- 内置角色只读(权限码是前后端契约,做成可改 = 一次误操作就能把管理员锁在系统外);
  平台级能力两道防线(界面禁用 + 服务端 40301);防自锁;改完立即生效(写授权清缓存)
- RoleProvisioner 从"已存在就 continue"改成幂等回填 + 每日 03:20 定时任务 ——
  修的是"代码改了权限、已开通租户永远停在发放那一刻",这种漂移不报错也没测试会红
- scripts/e2e-m10-role.py 77/77

需求收敛(交接摘要 §一·三十六,本节的数字为准):
- 删副班主任 / 审核员两个内置角色(6 -> 4)、历史整理器(module/cleanup)、
  加入申请(module/join + 学生端 pages/join)、collect_task_org.deputy_user_ids 整列;
  审核范围三级回退 -> 两级,连带 6 个 mapper 的 LIKE '%,id,%' 分支
- V19 迁移处理存量:先解 sys_user_role 绑定再软删角色;账号只在"除这两个角色外没有
  别的角色"时才软删。必须迁而不能只删代码 —— 角色码删掉后 mergePermissions 返回空集,
  存量账号会"能登录、处处 40301",不抛异常不打日志
- 契约 129 -> 119、错误码 59 -> 54、权限码 30 -> 28、PC 菜单 12 -> 10、
  小程序页 10 -> 9、原型 32 -> 29 屏、E2E 39 -> 37 个(断言 1695 条)
- UserService.list 对不认识的角色码由"静默丢掉过滤条件"改成失败关闭
  (之前传个不存在的 roleCode 会返回全校账号,且看起来完全正常)
- 品牌元信息同步改名「深学在线」:pom 名称、/doc.html 标题、配置与启动类注释、
  存储自检探针内容;学生端 app 名与全局导航栏标题
2026-10-10 16:48:54 +08:00

414 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
<!--
核验失败时**只给"重新填写"这一条路**(2026-10 需求收敛:删掉「申请加入名单」)。
刻意不提任何联系方式:名单外的人怎么进名单是老师那边的事,
系统内不展示班主任/管理员的电话(客户定过的规矩)。
-->
<view v-if="error" 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 ''
}
}
/** 当前 URL 里的 scene(在 hash 路由的 query 里;H5 跳转不保留参数)。 */
function sceneFromUrl(): string {
try {
const hash = typeof location !== 'undefined' ? location.hash : ''
const q = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : ''
return new URLSearchParams(q).get('scene') || ''
} 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() {
// 带着 scene 与 taskId 跳到填报页 —— H5 的页面跳转不保留 URL 参数,
// 所以显式拼(同 index → verify 的处理)
const scene = sceneFromUrl()
const tid = ctx.value.taskId || 0
uni.navigateTo({
url: `/pages/fill/fill?taskId=${tid}&scene=${encodeURIComponent(scene)}`
})
}
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;
}
/* 次要动作("申请加入名单"):主按钮才是"核对并继续",
两个实心按钮并排会让人分不清哪个是推荐路径 */
&--ghost {
margin-top: 20rpx;
color: $primary;
background: #eaf1fd;
}
}
&__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>