1f3c485ceb
角色与授权(交接摘要 §一·三十五): - 权限判定合并两个来源:内置角色按代码(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 名与全局导航栏标题
414 lines
11 KiB
Vue
414 lines
11 KiB
Vue
<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>
|