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
+125
View File
@@ -0,0 +1,125 @@
/**
* 名单加入申请接口(《03-接口设计》§8.7.3 ~ §8.7.5,FR-509)。
*
* ## 这条通道的边界(界面文案要照着说)
*
* 它**只解决"进名单",不绕过门禁** —— 申请期间学生仍然不能填报。
* 所以审批通过之后,界面要告诉管理员"学生返回重新核验就能进",
* 而不是"他已经可以填了"。
*
* ## 两个刻意的设计,前端要照做
*
* 1. **没有批量通过/一键全通过接口**(契约 §8.7.5 的取舍):身份认定环节的
* 误操作代价极高 —— 放错一个人进名单,等于让一个不是这批的人拿到认定资格。
* 所以这里只有逐条通过、逐条驳回(驳回还必须填原因)。
* 2. **`notifySent` 恒为 false**:通知渠道(M5-12)还没做,
* 服务端如实返回 false,界面必须显示"请口头/群里告知学生",不能假装通知过了。
*/
import http from '@/utils/http'
export interface JoinDupHint {
type: string
taskName: string
realName: string
}
export interface JoinAdminItem {
requestId: number
taskId: number
taskName: string | null
realName: string
idCardMask: string
phoneMask: string | null
orgHint: string | null
reason: string | null
/** `0` 待审 / `1` 已通过 / `2` 已驳回 */
status: number
statusText: string
createTime: string | null
waitHours: number | null
/** 该证件号在**其他任务**的名册里出现过(帮判断是不是本校学生) */
duplicateHint: JoinDupHint | null
rosterId: number | null
}
export interface JoinAdminList {
list: JoinAdminItem[]
total: number
counts: { pending: number, approved: number, rejected: number }
alert: { level: number, text: string } | null
}
export interface JoinListQuery {
taskId?: number
/** 默认 0(待审) */
status?: number
keyword?: string
pageNum?: number
pageSize?: number
}
export function getJoinRequests(params: JoinListQuery = {}) {
return http.get<JoinAdminList>('/roster/join-requests', params)
}
export interface JoinApproveReq {
/** 指定班级;不传则用学生的自述班级匹配组织名(匹配不上会要求手选) */
orgId: number
/** 修正姓名(管理员核对后可改) */
realName?: string
/** 修正证件号 */
idCardNo?: string
}
export interface JoinApproveResult {
requestId: number
status: number
rosterId: number
rosterCreated: boolean
/** ⚠️ 恒为 false:通知没发出去,需要人工告知 */
notifySent: boolean
/** true = 已绑定,学生返回重新核验就能直接进 */
canFillNow: boolean
}
export function approveJoinRequest(id: number, data: JoinApproveReq) {
return http.post<JoinApproveResult>(`/roster/join-requests/${id}/approve`, data)
}
export interface JoinRejectReq {
/** **必填** —— 会原样给学生看 */
reason: string
reasonCode?: string
}
export function rejectJoinRequest(id: number, data: JoinRejectReq) {
return http.post<{ requestId: number, status: number, notifySent: boolean }>(
`/roster/join-requests/${id}/reject`,
data
)
}
/** 申请状态。 */
export const JOIN_STATUS: Record<number, { text: string, color: string }> = {
0: { text: '待审核', color: 'orange' },
1: { text: '已通过', color: 'green' },
2: { text: '已驳回', color: 'red' }
}
/** 驳回快捷原因码(契约 §8.7.5)。 */
export const REJECT_REASON_CODES = [
{ code: 'NOT_THIS_SCHOOL', label: '不是本校学生' },
{ code: 'INFO_MISMATCH', label: '信息与报名资料不符' },
{ code: 'NOT_IN_SCOPE', label: '不在本次认定范围' },
{ code: 'OTHER', label: '其他原因(请在说明里写清)' }
]
/** 关键错误码 → 一句"接下来怎么办"。 */
export const JOIN_ERROR_HINT: Record<number, string> = {
40040: '这条申请已经被别人处理过了(刷新列表即可看到最新状态)。',
40041: '名册里已经有这个证件号了 —— 可能刚被老师手工加进去,去「名单管理」核对一下。',
40042: '已通过的申请不能驳回。要撤销请到「名单管理」里删掉那条名册项。',
40037: '该学生申请次数已达上限(每任务 2 次)。',
40038: '该学生已有一条待审申请。',
40039: '该证件号已在名册中 —— 学生只是姓名打错了,让他回去重新核验即可。'
}
@@ -0,0 +1,253 @@
<template>
<a-modal v-model:visible="visible" :title="mode === 'approve' ? '通过加入申请' : '驳回加入申请'"
:width="560" :mask-closable="false" :ok-loading="saving"
:ok-text="mode === 'approve' ? '确认通过' : '确认驳回'" @before-ok="onOk" @close="close">
<a-alert v-if="row" type="normal" :show-icon="false" class="jd__info">
<template #title>
<strong>{{ row.realName }}</strong> · {{ row.idCardMask }}
<template v-if="row.orgHint"> · 自述班级「{{ row.orgHint }}」</template>
</template>
<template #default>
<div v-if="row.reason" class="jd__muted">申请说明:{{ row.reason }}</div>
<div v-if="row.duplicateHint" class="jd__dup">
提示:该证件号在「{{ row.duplicateHint.taskName }}」的名册里出现过
(登记姓名 {{ row.duplicateHint.realName }})—— 建议核对是不是同一个人。
</div>
</template>
</a-alert>
<!-- ── 通过 ── -->
<template v-if="mode === 'approve'">
<a-form layout="vertical" size="small" :model="approveForm">
<a-form-item label="加入哪个班级" required>
<a-select v-model="approveForm.orgId" placeholder="选择班级">
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">
{{ c.label }}
</a-option>
</a-select>
<template #extra>
<span class="jd__muted">
学生的自述班级只作参考 —— 班级写错会让他的材料挂到别的班,
将来导出时目录也会分错
</span>
</template>
</a-form-item>
<a-form-item label="姓名(如与证件不符可修正)">
<a-input v-model="approveForm.realName" />
</a-form-item>
</a-form>
<a-alert type="info" :show-icon="false" class="jd__tip">
<template #title>
通过后:系统会把他<strong>直接加入名单并绑定</strong>,
他返回重新核验即可开始填报,不用再输一遍姓名与证件号。
</template>
<template #default>
<!-- 通知没做:如实说,不假装 -->
<span class="jd__muted">
⚠️ 通知渠道还没做,<strong>通过之后要人工告知学生</strong>(群里说一声)。
</span>
</template>
</a-alert>
</template>
<!-- ── 驳回 ── -->
<template v-else>
<a-form layout="vertical" size="small" :model="rejectForm">
<a-form-item label="快捷原因">
<a-select v-model="rejectForm.reasonCode" placeholder="选择或直接在下面写" allow-clear>
<a-option v-for="r in REJECT_REASON_CODES" :key="r.code" :value="r.code">
{{ r.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item :label="textLabel" :validate-status="textError ? 'error' : undefined"
:help="textError">
<a-textarea v-model="rejectForm.reason" :rows="3" :max-length="200" show-word-limit
placeholder="如:名单里没有你的报名记录,请联系班主任核对报名信息" />
</a-form-item>
</a-form>
<a-alert type="warning" :show-icon="false" class="jd__tip">
<template #title>
这段说明会<strong>原样显示给学生</strong>,所以要说清"哪里不对、该怎么办",
不要只写"审核不通过"。
</template>
</a-alert>
</template>
</a-modal>
</template>
<script setup lang="ts">
import type { JoinAdminItem } from '@/apis/xt/join-request'
import { Message } from '@arco-design/web-vue'
import {
approveJoinRequest,
JOIN_ERROR_HINT,
REJECT_REASON_CODES,
rejectJoinRequest
} from '@/apis/xt/join-request'
import { getOrgTree } from '@/apis/xt/org'
/**
* 加入申请审批弹窗(通过 / 驳回两种模式)。
*
* ## 为什么通过时要**手选班级**
*
* 学生的"自述班级"只是他自己打的字(可能写成"数控2班"、"24数控二班"…),
* 而班级决定了:他的材料归谁审、导出时落在哪个目录。**让它自动猜**,
* 猜错的表现是"材料挂到了别的班",而导出包打开时才会发现。
* 服务端在"自述班级匹配到唯一组织"时会自动选上,匹配不上或撞名时必须手选。
*
* ## 驳回必须填原因
*
* 原因会原样给学生看 —— 这也是学生唯一能知道"哪里不对"的来源。
* 只写"审核不通过"等于让他反复提交。
*/
defineOptions({ name: 'JoinAuditModal' })
const emit = defineEmits<{
(e: 'done'): void
}>()
const visible = ref(false)
const saving = ref(false)
const mode = ref<'approve' | 'reject'>('approve')
const row = ref<JoinAdminItem | null>(null)
const classOptions = ref<Array<{ id: number, label: string }>>([])
const textError = ref('')
const approveForm = reactive({ orgId: undefined as number | undefined, realName: '' })
const rejectForm = reactive({ reasonCode: '', reason: '' })
const textLabel = computed(() =>
rejectForm.reasonCode && rejectForm.reasonCode !== 'OTHER'
? '补充说明(可选)'
: '驳回原因(必填)')
async function loadClasses() {
if (classOptions.value.length) {
return
}
const res = await getOrgTree()
const out: Array<{ id: number, label: string }> = []
const walk = (nodes: Array<Record<string, unknown>>) => {
for (const n of nodes || []) {
if (n.orgType === 3) {
out.push({ id: Number(n.id), label: String(n.orgName) })
}
walk((n.children as Array<Record<string, unknown>>) || [])
}
}
walk((res.data as unknown as Array<Record<string, unknown>>) || [])
classOptions.value = out
}
async function openApprove(r: JoinAdminItem) {
mode.value = 'approve'
row.value = r
approveForm.orgId = undefined
approveForm.realName = r.realName
textError.value = ''
visible.value = true
try {
await loadClasses()
// 自述班级能唯一匹配上就预选(服务端也会做同样的匹配,这里只是少一次点击)
const hit = classOptions.value.filter((c) => r.orgHint && c.label === r.orgHint)
if (hit.length === 1) {
approveForm.orgId = hit[0].id
}
} catch {
// 组织树取不到不阻断:他可以不选,服务端会按自述班级再试一次
}
}
function openReject(r: JoinAdminItem) {
mode.value = 'reject'
row.value = r
rejectForm.reasonCode = ''
rejectForm.reason = ''
textError.value = ''
visible.value = true
}
function close() {
visible.value = false
}
async function onOk() {
if (!row.value) {
return false
}
saving.value = true
try {
if (mode.value === 'approve') {
if (!approveForm.orgId) {
Message.warning('请选择要加入的班级')
return false
}
const res = await approveJoinRequest(row.value.requestId, {
orgId: approveForm.orgId,
realName: approveForm.realName.trim() || undefined
})
const d = res.data
// 通知没发出去是**事实**,要说出来(不能让人以为学生已经收到通知了)
Message.success({
content: `已通过并加入名单。${d?.canFillNow ? '该学生重新核验即可填报。' : ''}${
d?.notifySent === false ? '(通知未发出,请人工告知学生)' : ''}`,
duration: 5000
})
} else {
const needsText = !rejectForm.reasonCode || rejectForm.reasonCode === 'OTHER'
if (needsText && !rejectForm.reason.trim()) {
textError.value = '请写清驳回原因 —— 学生只能从这句话知道哪里不对'
return false
}
await rejectJoinRequest(row.value.requestId, {
reason: rejectForm.reason.trim() || '信息与报名资料不符',
reasonCode: rejectForm.reasonCode || undefined
})
Message.success({
content: '已驳回。(通知未发出,请人工告知学生原因)',
duration: 5000
})
}
visible.value = false
emit('done')
return true
} catch (e) {
const err = e as Error & { bizCode?: number }
const hint = err.bizCode ? JOIN_ERROR_HINT[err.bizCode] : ''
Message.error(hint ? `${err.message}(${hint})` : err.message)
return false
} finally {
saving.value = false
}
}
defineExpose({ openApprove, openReject })
</script>
<style lang="scss" scoped>
.jd {
&__info {
margin-bottom: 12px;
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.7;
}
&__dup {
margin-top: 6px;
font-size: 12px;
color: rgb(var(--orange-6));
line-height: 1.7;
}
&__tip {
margin-top: 4px;
}
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<template #title>加入申请</template>
<template #extra>
<span class="join__hint">
门禁没匹配上的人在这里申请加入;通过后他重新核验即可填报
</span>
</template>
<a-alert v-if="alert" type="warning" class="join__alert">
<template #title>{{ alert.text }}</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-button v-for="t in statusTabs" :key="String(t.value)"
:type="statusFilter === t.value ? 'primary' : 'outline'" size="small"
@click="setStatus(t.value)">
{{ t.label }}
<span v-if="t.count !== undefined" class="join__count">{{ t.count }}</span>
</a-button>
</a-space>
<a-space wrap>
<a-select v-model="query.taskId" placeholder="全部任务" allow-clear size="small"
class="join__task" :loading="taskLoading" @change="search">
<a-option v-for="t in taskOptions" :key="t.id" :value="t.id">{{ t.taskName }}</a-option>
</a-select>
<a-input-search v-model="query.keyword" placeholder="搜索姓名" allow-clear
class="join__search" @search="search" @clear="search" />
<a-button size="small" @click="load">
<template #icon><IconRefresh /></template>
刷新
</a-button>
</a-space>
</div>
<!--
⚠️ 这一屏**没有批量通过、没有全选**(契约 §8.7.5 的取舍):
放错一个人进名单 = 让一个不属于这批的人拿到认定资格,代价极高。
所以只有逐条的通过/驳回,且驳回必须填原因。
-->
<a-table :data="list" :loading="loading" row-key="requestId" :pagination="pagination"
:scroll="{ x: 1000 }" @page-change="onPageChange">
<template #columns>
<a-table-column title="申请人" :width="170">
<template #cell="{ record }">
<div class="join__name">{{ record.realName }}</div>
<div class="join__muted">{{ record.idCardMask }}</div>
</template>
</a-table-column>
<a-table-column title="手机号" :width="130">
<template #cell="{ record }">
<span class="join__muted">{{ record.phoneMask || '—' }}</span>
</template>
</a-table-column>
<a-table-column title="自述班级 / 申请说明" :width="220">
<template #cell="{ record }">
<div>{{ record.orgHint || '—' }}</div>
<div class="join__muted">{{ record.reason || '(未填写说明)' }}</div>
</template>
</a-table-column>
<a-table-column title="任务" :width="170" ellipsis tooltip>
<template #cell="{ record }">
<div>{{ record.taskName || '—' }}</div>
<div v-if="record.duplicateHint" class="join__dup">
该证件号在「{{ record.duplicateHint.taskName }}」出现过
</div>
</template>
</a-table-column>
<a-table-column title="状态 / 等待" :width="120" align="center">
<template #cell="{ record }">
<a-tag :color="JOIN_STATUS[record.status]?.color ?? 'gray'" size="small">
{{ record.statusText || JOIN_STATUS[record.status]?.text }}
</a-tag>
<div class="join__muted">{{ fmtWait(record.waitHours) }}</div>
</template>
</a-table-column>
<a-table-column title="提交时间" :width="140">
<template #cell="{ record }">
<span class="join__muted">{{ fmt(record.createTime) }}</span>
</template>
</a-table-column>
<a-table-column title="操作" :width="150" align="center" fixed="right">
<template #cell="{ record }">
<a-space v-if="record.status === 0" size="mini">
<a-link @click="onApprove(record)">通过</a-link>
<a-link status="danger" @click="onReject(record)">驳回</a-link>
</a-space>
<span v-else class="join__muted">
{{ record.rosterId ? `已入册 #${record.rosterId}` : '—' }}
</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<JoinAuditModal ref="modalRef" @done="load" />
</GiPageLayout>
</template>
<script setup lang="ts">
import type { JoinAdminItem } from '@/apis/xt/join-request'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { getJoinRequests, JOIN_STATUS } from '@/apis/xt/join-request'
import { getTaskList } from '@/apis/xt/task'
import JoinAuditModal from './JoinAuditModal.vue'
/**
* 加入申请审批(FR-509,原型 STEP 11)。
*
* ## 为什么没有批量通过
*
* 契约 §8.7.5 明确取舍:批量**通过**要求管理员先逐条看过,
* 而**驳回必须逐条填原因**;且批量通过**不提供"全选"快捷键**。
* 理由与"批量通过只带绿色项"同源 —— **身份认定环节的误操作代价极高**:
* 放错一个人进名单,等于让一个不属于这批的人拿到认定资格,而撤销只能删名册项。
* 所以这一屏干脆不做批量,只留逐条动作。
*
* ## 两个信息是用来"帮他判断"的
*
* - `waitHours`:等最久的排最前(列表本来就按提交时间正序)
* - `duplicateHint`:这个证件号在**别的任务**的名册里出现过 ——
* 这是判断"是不是本校学生"的唯一线索,比对着姓名猜可靠
*/
defineOptions({ name: 'Join' })
const loading = ref(false)
const taskLoading = ref(false)
const list = ref<JoinAdminItem[]>([])
const total = ref(0)
const counts = reactive({ pending: 0, approved: 0, rejected: 0 })
const alert = ref<{ level: number, text: string } | null>(null)
const statusFilter = ref<number>(0)
const taskOptions = ref<Array<{ id: number, taskName: string }>>([])
const query = reactive({
pageNum: 1,
pageSize: 10,
keyword: '',
taskId: undefined as number | undefined
})
const modalRef = useTemplateRef('modalRef')
const statusTabs = computed(() => [
{ label: '待审核', value: 0, count: counts.pending },
{ label: '已通过', value: 1, count: counts.approved },
{ label: '已驳回', value: 2, count: counts.rejected }
])
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
total: total.value,
showTotal: true,
showPageSize: true
}))
function fmt(v: string | null) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function fmtWait(h: number | null) {
if (h === null || h === undefined) {
return '—'
}
return h < 24 ? `已等 ${h} 小时` : `已等 ${Math.floor(h / 24)} 天`
}
async function load() {
loading.value = true
try {
const res = await getJoinRequests({
taskId: query.taskId,
status: statusFilter.value,
keyword: query.keyword.trim() || undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
})
const d = res.data
list.value = d?.list ?? []
total.value = d?.total ?? 0
Object.assign(counts, d?.counts ?? { pending: 0, approved: 0, rejected: 0 })
alert.value = d?.alert ?? null
} finally {
loading.value = false
}
}
async function loadTasks() {
taskLoading.value = true
try {
const res = await getTaskList({ pageNum: 1, pageSize: 200 })
taskOptions.value = (res.data?.list ?? []).map((t) => ({ id: t.id, taskName: t.taskName }))
} finally {
taskLoading.value = false
}
}
function search() {
query.pageNum = 1
load()
}
function setStatus(v: number) {
statusFilter.value = v
search()
}
function onPageChange(page: number) {
query.pageNum = page
load()
}
function onApprove(row: JoinAdminItem) {
modalRef.value?.openApprove(row)
}
function onReject(row: JoinAdminItem) {
modalRef.value?.openReject(row)
}
onMounted(() => {
load()
loadTasks()
})
</script>
<style lang="scss" scoped>
.join {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__alert {
margin-bottom: 12px;
}
&__count {
margin-left: 4px;
font-size: 12px;
opacity: 0.75;
}
&__task {
width: 180px;
}
&__search {
width: 180px;
}
&__name {
font-weight: 500;
color: var(--color-text-1);
}
&__muted {
font-size: 12px;
color: var(--color-text-3);
}
&__dup {
font-size: 12px;
color: rgb(var(--orange-6));
}
}
</style>