feat(join): B1 名单加入申请 —— 学生提交 + 管理端审批(V7 存 AES 密文)

按契约「服务端转 HMAC、不落明文」设计会写不进名册:collect_roster.id_card_no 是
NOT NULL 的密文列,而申请表只有 hash 与 mask(三条替代路都被排除)。
V7 给 collect_join_request 补 id_card_cipher(与名册同一把密钥/算法),审批时原样搬进名册,
明文全程只在提交请求的内存里存在过。V7 之前的老申请明确提示重新提交,不静默建一条
永远过不了校验的名册行。

另修 F-20:自述班级不能借用姓名规范化(姓名要「张 三」→「张三」,班级名里的空格有意义)。

验证:scripts/e2e-m2-join.py 50/50。
This commit is contained in:
2026-09-30 09:02:52 +08:00
parent ad86e2d521
commit bb91515590
12 changed files with 2221 additions and 1 deletions
+466
View File
@@ -0,0 +1,466 @@
<template>
<view class="join">
<view class="join__wrap">
<view class="join__hero">
<text class="join__tenant">{{ ctx.tenantName || '材料填报' }}</text>
<text class="join__task">{{ ctx.taskName }}</text>
</view>
<!--
① 已经申请过:把状态摆在最上面。
这条通道最容易让人误解的就是"我提交了,是不是就能填了?" ——
所以三个状态各有一句话直说结果,尤其待审时要说清**还不能填**。
-->
<view v-if="mine" class="join__card">
<view class="join__status" :class="`join__status--${mine.status}`">
{{ mine.statusText }}
</view>
<view class="join__row">
<text class="join__row-label">申请编号</text>
<text class="join__row-value">#{{ mine.requestId }}</text>
</view>
<view class="join__row">
<text class="join__row-label">姓名</text>
<text class="join__row-value">{{ mine.realName }}</text>
</view>
<view class="join__row">
<text class="join__row-label">身份证号</text>
<text class="join__row-value">{{ mine.idCardMask }}</text>
</view>
<view class="join__row">
<text class="join__row-label">提交时间</text>
<text class="join__row-value">{{ fmt(mine.createTime) }}</text>
</view>
<!-- 待审:说清"还不能填",并给出他能做的事 -->
<view v-if="mine.status === 0" class="join__note">
老师审核通过后,你<text class="join__strong">返回重新核验</text>就能开始填材料。
审核前暂时还不能填 —— 名单是准入依据,这一步不能跳过。
</view>
<!-- 通过:直接给"去核验"的入口 -->
<view v-else-if="mine.status === 1" class="join__note join__note--ok">
已经加入名单了,点下面的按钮重新核验一次即可开始填报。
</view>
<!-- 驳回:原因原样显示(这是学生唯一能知道"哪里不对"的来源) -->
<view v-else class="join__note join__note--bad">
<text class="join__strong">未通过原因:</text>{{ mine.rejectReason || '(老师未填写)' }}
</view>
<button v-if="mine.status === 1" class="join__btn" @click="reverify">
重新核验,开始填报
</button>
<button v-if="mine.status === 2" class="join__btn join__btn--ghost"
:disabled="submitting" @click="showForm = true">
重新填写申请(还剩 {{ remainHint }})
</button>
</view>
<!-- ② 填申请 -->
<view v-if="!mine || mine.status === 2" class="join__card">
<template v-if="showForm">
<view class="join__title">申请加入名单</view>
<view class="join__hint">
核对不上,通常是因为<text class="join__strong">老师名单里还没有你</text>,
或者姓名写得和报名时不一样。填一次申请,老师核对后会把你加进名单。
</view>
<view class="join__field">
<text class="join__label">姓名</text>
<input v-model="form.realName" class="join__input" placeholder="与报名时一致的姓名"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">身份证号</text>
<input v-model="form.idCardNo" class="join__input" placeholder="18 位身份证号"
maxlength="18" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">手机号</text>
<input v-model="form.phone" class="join__input" placeholder="老师要能联系到你"
maxlength="11" type="number" :disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">班级(选填)</text>
<input v-model="form.orgHint" class="join__input" placeholder="如 24中技数控2班"
:disabled="submitting" />
</view>
<view class="join__field">
<text class="join__label">说明(选填)</text>
<input v-model="form.reason" class="join__input" placeholder="如:我是转班过来的"
:disabled="submitting" />
</view>
<button class="join__btn" :disabled="submitting || !canSubmit" @click="submit">
{{ submitting ? '提交中…' : '提交申请' }}
</button>
<view v-if="error" class="join__error">{{ error }}</view>
<view class="join__foot">
身份证号只用于与名单核对,系统<text class="join__strong">不会保存明文</text>。
</view>
</template>
<template v-else>
<view class="join__title">还没有加入名单?</view>
<view class="join__hint">
如果你确认姓名和身份证号没填错,但仍然核对不上,可以在这里提交申请,
由老师核对后加入名单。
</view>
<button class="join__btn" @click="showForm = true">申请加入名单</button>
</template>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 申请加入名单(FR-509 / 客户确认后新增,原型 STEP 03B)。
*
* ## 三条必须说清的事(文案不是装饰,是这段流程的一部分)
*
* 1. **申请期间仍然不能填报。** 这条通道只解决"进名单",不绕过门禁 ——
* 学生在待审状态最容易以为"我交了就能填",所以状态卡上直说"暂时还不能填"。
* 2. **通过后要重新核验一次。** 通过时服务端已经把他绑到名册行上了,
* 所以重进就能过;说清这一步,他就不会以为"通过了还要再填一遍资料"。
* 3. **身份证号不存明文。** 未核验的人最该被告知这一点 ——
* 否则"填身份证号"这一步会让人犹豫。
*/
defineOptions({ name: 'JoinRequest' })
interface SceneContext {
tenantName?: string
taskName?: string
taskId?: number
}
interface MineRequest {
requestId: number
taskId: number
taskName?: string
status: number
statusText: string
realName: string
idCardMask: string
orgHint?: string
createTime?: string
auditTime?: string
rejectReason?: string
rosterId?: number
canFillNow?: boolean
}
const ctx = ref<SceneContext>({})
const mine = ref<MineRequest | null>(null)
const showForm = ref(false)
const submitting = ref(false)
const error = ref('')
let taskId = 0
let scene = ''
const form = ref({
realName: '',
idCardNo: '',
phone: '',
orgHint: '',
reason: ''
})
const canSubmit = computed(() => {
const f = form.value
return f.realName.trim().length > 0
&& f.idCardNo.trim().length === 18
&& /^1[3-9]\d{9}$/.test(f.phone.trim())
})
const remainHint = computed(() => '最多 2 次机会')
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
async function loadContext(s: string) {
if (!s) return
try {
const data = await request<SceneContext>({
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(s)}`
})
ctx.value = data
taskId = data.taskId || 0
} catch (e) {
error.value = e instanceof Error ? e.message : '读取任务信息失败'
}
}
/** 拉"我的申请":决定显示状态卡还是表单 */
async function loadMine() {
try {
const data = await request<{ list: MineRequest[], total: number }>({
url: `/api/v1/mp/roster/join-request${taskId ? `?taskId=${taskId}` : ''}`,
token: token()
})
// 只看最近一条:一个人在同一任务下的申请按时间倒序,最新那条决定他现在的处境
mine.value = (data.list || [])[0] || null
} catch (e) {
// 拉不到不算致命:最多是少显示状态,仍可提交
error.value = e instanceof Error ? e.message : ''
}
}
async function submit() {
error.value = ''
const f = form.value
if (!canSubmit.value) {
error.value = '请把姓名、18 位身份证号、手机号填完整'
return
}
submitting.value = true
try {
await request({
url: '/api/v1/mp/roster/join-request',
method: 'POST',
token: token(),
data: {
taskId,
realName: f.realName.trim(),
idCardNo: f.idCardNo.trim(),
phone: f.phone.trim(),
orgHint: f.orgHint.trim() || undefined,
reason: f.reason.trim() || undefined
}
})
uni.showToast({ title: '已提交,等待老师审核', icon: 'none', duration: 2500 })
showForm.value = false
await loadMine()
} catch (e) {
// 后端的话原样显示("你已在名单中,请直接返回重新核验"这类提示必须让学生看到)
error.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
/** 通过之后回核验页 —— 服务端已绑定,重进就能过 */
function reverify() {
uni.redirectTo({ url: `/pages/verify/verify?scene=${encodeURIComponent(scene)}` })
}
onLoad(async (options) => {
scene = options?.scene || ''
taskId = Number(options?.taskId || 0)
// 从核验页带过来的姓名/证件号:他刚打过一遍,不必再打
form.value.realName = options?.name || ''
form.value.idCardNo = options?.idCardNo || ''
await loadContext(scene)
await loadMine()
// 带过来的是"门禁没匹配上"的那份输入 —— 直接展开表单,少一次点击
if (!mine.value && (form.value.realName || form.value.idCardNo)) {
showForm.value = true
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.join {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__tenant {
display: block;
font-size: 24rpx;
color: rgb(255 255 255 / 80%);
}
&__task {
display: block;
margin-top: 8rpx;
font-size: 34rpx;
font-weight: 600;
color: #fff;
}
&__card {
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
font-size: 34rpx;
font-weight: 600;
color: #1b2430;
}
&__status {
display: inline-block;
padding: 6rpx 18rpx;
margin-bottom: 20rpx;
font-size: 26rpx;
border-radius: 8rpx;
&--0 {
color: $warn;
background: #fdf2e9;
}
&--1 {
color: $success;
background: #e8f6f0;
}
&--2 {
color: $danger;
background: #fdecee;
}
}
&__hint {
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #4a5768;
}
&__strong {
font-weight: 600;
color: #1b2430;
}
&__row {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
&__row-label {
font-size: 26rpx;
color: #8b95a3;
}
&__row-value {
font-size: 26rpx;
color: #1b2430;
}
&__note {
padding: 20rpx;
margin-top: 20rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
&--ok {
color: #2f6b4f;
background: #e8f6f0;
}
&--bad {
color: #a03a44;
background: #fdecee;
}
}
&__field {
margin-top: 24rpx;
}
&__label {
display: block;
margin-bottom: 10rpx;
font-size: 26rpx;
color: #4a5768;
}
&__input {
width: 100%;
height: 84rpx;
padding: 0 20rpx;
font-size: 30rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__btn {
height: 88rpx;
margin-top: 36rpx;
font-size: 32rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&[disabled] {
color: #fff;
background: #b9c6dd;
}
&--ghost {
color: $primary;
background: #eaf1fd;
&[disabled] {
color: #8b95a3;
background: #f2f3f5;
}
}
}
&__error {
padding: 18rpx 20rpx;
margin-top: 24rpx;
font-size: 26rpx;
line-height: 1.6;
color: $danger;
background: #fdecee;
border-radius: 12rpx;
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8b95a3;
}
}
</style>
+34 -1
View File
@@ -32,8 +32,19 @@
<view v-if="error" class="verify__error">{{ error }}</view>
<!--
核验失败时给一条**受控通道**(FR-509 / 客户确认后新增)。
注意两件事:
1) 这条通道**不绕过门禁** —— 申请期间仍然不能填,去申请页会说清这一点;
2) 它只在真失败过之后才出现(`error` 非空),不是一进来就摆个"申请"按钮。
否则会有学生绕过核验直接去申请,把管理员的待办当成人工客服。
-->
<button v-if="error" class="verify__btn verify__btn--ghost" @click="gotoJoin">
申请加入名单
</button>
<view class="verify__foot">
核对不上?先确认姓名和证件号没有打错。仍然不行请联系班主任。
核对不上?先确认姓名和证件号没有打错。仍然不行可点上面的「申请加入名单」。
</view>
</view>
@@ -214,6 +225,20 @@ function next() {
})
}
/** 去申请加入名单(FR-509 的入口)—— 带上已填的姓名与证件号,省他再输一遍。 */
function gotoJoin() {
const s = sceneFromUrl()
const params = new URLSearchParams({
scene: s,
taskId: String(ctx.value.taskId || 0),
// 只把**已经打过的字**带过去。这不是"预填答案":门禁刚判定它不匹配,
// 带过去是为了让他少打一遍,而不是暗示它是对的。
name: realName.value.trim(),
idCardNo: idCardNo.value.trim()
})
uni.navigateTo({ url: `/pages/join/join?${params.toString()}` })
}
onLoad((options) => {
loadContext(options?.scene || '')
})
@@ -318,6 +343,14 @@ page {
color: #0000004d;
background: #e8e8e8;
}
/* 次要动作("申请加入名单"):主按钮才是"核对并继续",
两个实心按钮并排会让人分不清哪个是推荐路径 */
&--ghost {
margin-top: 20rpx;
color: $primary;
background: #eaf1fd;
}
}
&__error {