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
@@ -0,0 +1,72 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理侧的加入申请审批(《03-接口设计》§8.7.3 ~ §8.7.5)。
*
* <p>权限码是现成的 {@code join:audit}({@code PermissionDef.JOIN_REQUEST_AUDIT})——
* 不需要新加:它就是为这条通道定义的。
*
* <p>⚠️ <b>没有"批量通过"接口</b>,这是刻意的(契约 §8.7.5 的取舍):
* 批量通过要求管理员先逐条看过(列表已提供信息),而**驳回必须逐条填原因**;
* 且批量通过**不提供"全选"快捷键**。理由与"批量通过只带绿色项"同源 ——
* 身份认定环节的误操作代价极高。
*/
@Tag(name = "管理端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/roster/join-requests")
@RequiredArgsConstructor
public class JoinRequestController {
private final JoinService joinService;
@GetMapping
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "申请待办列表",
description = "默认只看待审(status=0)。每条带 waitHours(等最久的排最前)与 "
+ "duplicateHint(该证件号在**其他任务**的名册里出现过,帮判断是不是本校学生)")
public ApiResult<JoinDtos.AdminListVO> list(@RequestParam(required = false) Long taskId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) Long orgId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long pageNum,
@RequestParam(required = false) Long pageSize) {
return ApiResult.ok(joinService.list(taskId, status, orgId, keyword, pageNum, pageSize));
}
@PostMapping("/{id}/approve")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "通过(建名册项 + 自动绑定)",
description = "事务内做三件事:建名册项、把申请人绑上去(免他重输)、回写申请。"
+ "不传 orgId 时用学生的自述班级匹配组织名,匹配不上(或撞名)会要求手选。"
+ "notifySent 恒为 false(通知渠道未实现),需要人工告知学生")
public ApiResult<JoinDtos.ApproveVO> approve(@PathVariable Long id,
@RequestBody(required = false) JoinDtos.ApproveReq req) {
return ApiResult.ok(joinService.approve(id, req));
}
@PostMapping("/{id}/reject")
@RequirePerm(PermissionDef.JOIN_REQUEST_AUDIT)
@Operation(summary = "驳回(原因必填)",
description = "原因会原样给学生看,所以要说清「哪里不对、该怎么办」。"
+ "已通过的申请不能驳回(40042)—— 要撤销请删名册项")
public ApiResult<JoinDtos.RejectVO> reject(@PathVariable Long id,
@RequestBody JoinDtos.RejectReq req) {
return ApiResult.ok(joinService.reject(id, req));
}
}
@@ -0,0 +1,48 @@
package cn.tianbaotong.fill.module.join.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.service.JoinService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 学生侧的名单加入申请(《03-接口设计》§8.7.1 / §8.7.2)。
*
* <p>权限:登录即可(学生)。**不额外挂权限码** —— 这条通道就是给
* "门禁没匹配上、什么权限都没有"的人用的,要求权限码等于把门关上。
* 限次(同 openid 每任务 ≤2 次)在服务层做,那才是这条通道的闸门。
*/
@Tag(name = "填报端 · 名单加入申请")
@RestController
@RequestMapping("/api/v1/mp/roster/join-request")
@RequiredArgsConstructor
public class MpJoinRequestController {
private final JoinService joinService;
@PostMapping
@Operation(summary = "提交加入申请",
description = "**只解决「进名单」,不绕过门禁** —— 申请期间学生仍不能填报。"
+ "证件号立刻转 HMAC + 脱敏,明文不落库。"
+ "已在名册(40039)/ 已有待审申请(40038)/ 超过 2 次(40037)都会被拒")
public ApiResult<JoinDtos.SubmitVO> submit(@RequestBody JoinDtos.SubmitReq req) {
return ApiResult.ok(joinService.submit(req));
}
@GetMapping
@Operation(summary = "我的申请(含状态与驳回原因)",
description = "通过之后 canFillNow=true:说明已经自动绑定,学生返回重新核验就能直接进")
public ApiResult<Map<String, Object>> mine(@RequestParam(required = false) Long taskId) {
return ApiResult.ok(joinService.myRequests(taskId));
}
}
@@ -0,0 +1,252 @@
package cn.tianbaotong.fill.module.join.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 名单加入申请(《03-接口设计》§8.7.1 ~ §8.7.5)。
*
* <p>学生侧与管理侧各有一份形状:学生只看得到自己的申请与状态,
* 管理侧要看到"谁、什么证件号、等多久、有没有重复线索"。
*/
public final class JoinDtos {
private JoinDtos() {
}
// ── 学生侧 ────────────────────────────────────
/** 提交申请(§8.7.1)。 */
@Getter
@Setter
@Schema(description = "提交加入申请")
public static class SubmitReq {
private Long taskId;
/** 姓名(明文存储,业务必需)。 */
private String realName;
/** 身份证号 —— **服务端立刻转 HMAC + 脱敏,明文不落库**。 */
private String idCardNo;
private String phone;
/** 自述班级。 */
private String orgHint;
private String reason;
}
/** 提交结果。 */
@Getter
@Setter
public static class SubmitVO {
private Long requestId;
private Integer status;
private String statusText;
/** 预计多久处理(给个量级,不是承诺)。 */
private Integer estimatedHours;
/** 还剩几次申请机会 —— 说清上限,比等他撞上 40037 再报错友好。 */
private Integer remainAttempts;
}
/** 学生看自己的申请(§8.7.2)。 */
@Getter
@Setter
public static class MyRequestVO {
private Long requestId;
private Long taskId;
private String taskName;
private Integer status;
private String statusText;
private String realName;
private String idCardMask;
private String orgHint;
private LocalDateTime createTime;
private LocalDateTime auditTime;
/** 驳回原因(学生需要知道为什么) */
private String rejectReason;
/** 通过后写入的名册项 */
private Long rosterId;
/** 通过之后能不能马上填 —— 界面据此给"返回重新核验"的按钮 */
private Boolean canFillNow;
}
// ── 管理侧 ────────────────────────────────────
/** 待办列表里的一条。 */
@Getter
@Setter
public static class AdminItemVO {
private Long requestId;
private Long taskId;
private String taskName;
private String realName;
private String idCardMask;
private String phoneMask;
private String orgHint;
private String reason;
private Integer status;
private String statusText;
private LocalDateTime createTime;
/** 已等待小时数(保留 1 位)—— 列表按它倒序,等最久的在最前 */
private Double waitHours;
/** 该证件号在**其他任务**的名册里出现过(帮管理员判断是不是本校学生) */
private DupHint duplicateHint;
private Long rosterId;
}
/** 重复线索。 */
@Getter
@Setter
public static class DupHint {
/** 一期只有 {@code IN_OTHER_TASK} */
private String type;
private String taskName;
private String realName;
}
/** 待办列表。 */
@Getter
@Setter
public static class AdminListVO {
private List<AdminItemVO> list = new ArrayList<>();
private long total;
private Counts counts;
/** 待审太多时的提醒(FR-509c:单任务待审 > 50 条) */
private Alert alert;
}
/** 三个计数。 */
@Getter
@Setter
public static class Counts {
private long pending;
private long approved;
private long rejected;
}
/** 提醒。 */
@Getter
@Setter
public static class Alert {
/** {@code 2} = 警告级 */
private Integer level;
private String text;
}
/** 审批通过(§8.7.4)。 */
@Getter
@Setter
@Schema(description = "通过加入申请")
public static class ApproveReq {
/** 指定班级。不传则取学生的自述班级(匹配不上就报错让他手选)。 */
private Long orgId;
/** 修正姓名(管理员核对后可改) */
private String realName;
/** 修正证件号 */
private String idCardNo;
/** 序号(默认追加到末尾) */
private Integer seqNo;
}
/** 通过结果。 */
@Getter
@Setter
public static class ApproveVO {
private Long requestId;
private Integer status;
private Long rosterId;
private Boolean rosterCreated;
/** ⚠️ 一期恒为 false(通知渠道 M5-12 还没做)—— **不写 true** */
private Boolean notifySent;
/** true = 该 openid 已绑定,学生再次进入直接放行 */
private Boolean canFillNow;
}
/** 驳回(§8.7.5)。 */
@Getter
@Setter
@Schema(description = "驳回加入申请")
public static class RejectReq {
/** **必填** —— 会原样推送给学生 */
private String reason;
/** 快捷原因码:{@code NOT_THIS_SCHOOL} / {@code INFO_MISMATCH} / {@code NOT_IN_SCOPE} / {@code OTHER} */
private String reasonCode;
}
/** 驳回结果。 */
@Getter
@Setter
public static class RejectVO {
private Long requestId;
private Integer status;
/** 一期恒为 false,见 {@link ApproveVO#getNotifySent()} */
private Boolean notifySent;
}
}
@@ -0,0 +1,100 @@
package cn.tianbaotong.fill.module.join.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 名单加入申请(对应 {@code collect_join_request},FR-509 · 客户确认后新增)。
*
* <h3>这条通道解决什么、不解决什么</h3>
*
* <p>门禁不通过的人原先只能"联系老师",老师再手工加名单 —— 几百人的批次下老师负担极重。
* 这里给一条<b>受控通道</b>:学生自助申请 → 管理员审批 → 自动入册。
*
* <p>⚠️ <b>关键边界:它只解决"进名单",不绕过门禁。</b>
* 申请期间学生仍然不能填报 —— 否则门禁就不再是准入门槛,
* 而是"填个表就能过"的形式主义,那整个产品的根基(凭证件号认人)就没了。
*
* <h3>证件号与手机号都只有 HMAC 与脱敏值</h3>
*
* <p>与 {@code collect_roster} 同一口径:{@code id_card_hash} 用于等值查重与审批后写名册,
* {@code id_card_mask} 用于展示。<b>明文不落库</b> —— 申请来自未核验的人,
* 更不该把明文留在库里。
*
* <p>唯一索引 {@code uk_join_task_idcard} 的作用:一个任务下同一证件号
* 只能有一条"待审或已通过"的申请,避免同一个人反复堆申请。
*/
@Getter
@Setter
@TableName("collect_join_request")
public class CollectJoinRequest extends TenantEntity {
/** {@code 0} 待审核 / {@code 1} 已通过 / {@code 2} 已驳回。 */
public static final int STATUS_PENDING = 0;
public static final int STATUS_APPROVED = 1;
public static final int STATUS_REJECTED = 2;
/** 来源:{@code 1} 学生自助申请(一期只有这一种)。 */
public static final int SOURCE_STUDENT = 1;
/** 同任务最多申请几次(契约 §8.7.1:同 openid 每任务 ≤2 次)。 */
public static final int MAX_ATTEMPTS = 2;
private Long taskId;
private String realName;
/** HMAC 的证件号索引(无法反推,用于查重与写名册时定位)。 */
private String idCardHash;
/**
* 证件号 <b>AES-GCM 密文</b>(V7 迁移新增)。
*
* <p>为什么申请表也要存密文:审批通过要往 {@code collect_roster} 插一行,
* 而那一行的 {@code id_card_no} 是 <b>NOT NULL</b> 的密文列。
* 只存 hash 的话,审批那一步会直接撞
* {@code null value in column "id_card_no" violates not-null constraint}(500)。
*
* <p>三条替代路都被排除:让学生先过门禁(先有鸡还是先有蛋)、
* 让管理员重输(等于把学生写错一位的风险复制给管理员)、
* 不写这一列(DDL 不允许,且审核看完整号时正是解这一列)。
*
* <p>⚠️ <b>是密文,不是明文</b> —— 明文只在提交请求的内存里存在过。
*/
private String idCardCipher;
private String idCardMask;
private String phoneHash;
private String phoneMask;
/** 自述班级(老师核对时用)。 */
private String orgHint;
private String reason;
private Integer status;
/** 驳回原因:<b>会原样推送给学生</b>,所以要写得能看懂。 */
private String rejectReason;
/** 通过后写入的名册项 id。 */
private Long rosterId;
private Long auditBy;
private LocalDateTime auditTime;
/** 申请人 openid(这条通道按 openid 限次与去重)。 */
private String applicantOpenid;
/** 申请人 uid(通过后写入名册的 {@code bind_user_id})。 */
private Long applicantUid;
private Integer source;
}
@@ -0,0 +1,106 @@
package cn.tianbaotong.fill.module.join.mapper;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 加入申请。
*
* <p>带 SQL 的地方一律**显式写 {@code tenant_id}**:这些查询会在
* "还没登录"或"跨任务核对"的路径上用到,而多租户拦截器只能保证
* "有登录上下文时"注入条件 —— 显式写一遍是给自己留的证据。
* (对照 {@code MpSceneMapper}:匿名接口那一族也是这个口径。)
*/
public interface CollectJoinRequestMapper extends BaseMapper<CollectJoinRequest> {
/** 同一个人(openid)在这个任务下已经申请过几次 —— 含已驳回的(驳回后重填是允许的)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND applicant_openid = #{openid} AND deleted = 0
""")
int countByApplicant(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("openid") String openid);
/** 某个证件号在本任务下有没有"待审或已通过"的申请(唯一索引同口径)。 */
@Select("""
SELECT COUNT(*) FROM collect_join_request
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND id_card_hash = #{hash} AND status IN (0, 1) AND deleted = 0
""")
int countActiveByIdCard(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/**
* 这个证件号在**其他任务**的名册里出现过吗(帮管理员判断是不是本校学生)。
*
* <p>返回 taskId 与姓名;没有就返回空列表。**只查同一租户** ——
* 跨租户的"是不是本校学生"是别人的数据,不能拿来做判断依据。
*/
@Select("""
SELECT r.task_id AS taskId, r.real_name AS realName
FROM collect_roster r
WHERE r.tenant_id = #{tenantId} AND r.id_card_hash = #{hash}
AND r.task_id <> #{taskId} AND r.deleted = 0
ORDER BY r.task_id DESC
LIMIT 1
""")
List<DupHint> findInOtherTasks(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("hash") String hash);
/** 跨任务核对的返回行。 */
class DupHint {
private Long taskId;
private String realName;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long v) {
this.taskId = v;
}
public String getRealName() {
return realName;
}
public void setRealName(String v) {
this.realName = v;
}
}
/**
* 审批通过后回写申请单。
*
* <p>条件里带 {@code status = 0}:**"查"与"写"之间可能有人已经处理过**,
* 影响行数为 0 就说明被别人抢先了(并发下两个管理员同时点通过)。
* 这比"先查后写"可靠 —— 后者在并发时会给同一个人建两条名册。
*/
@Update("""
UPDATE collect_join_request
SET status = 1, roster_id = #{rosterId}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markApproved(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("rosterId") Long rosterId, @Param("auditor") Long auditor);
/** 驳回的回写(同样带状态条件)。 */
@Update("""
UPDATE collect_join_request
SET status = 2, reject_reason = #{reason}, audit_by = #{auditor},
audit_time = NOW(), update_time = NOW()
WHERE id = #{id} AND tenant_id = #{tenantId} AND status = 0 AND deleted = 0
""")
int markRejected(@Param("tenantId") Long tenantId, @Param("id") Long id,
@Param("reason") String reason, @Param("auditor") Long auditor);
}
@@ -0,0 +1,460 @@
package cn.tianbaotong.fill.module.join.service;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.common.util.MaskUtil;
import cn.tianbaotong.fill.module.join.dto.JoinDtos;
import cn.tianbaotong.fill.module.join.entity.CollectJoinRequest;
import cn.tianbaotong.fill.module.join.mapper.CollectJoinRequestMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.task.entity.CollectTask;
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
import cn.tianbaotong.fill.support.IdentityNormalizer;
import cn.tianbaotong.fill.support.SensitiveCodec;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 名单加入申请(FR-509,《03-接口设计》§8.7)。
*
* <h3>这条通道的四条边界(都是刻意的,不要"顺手放宽")</h3>
*
* <ol>
* <li><b>只解决"进名单",不绕过门禁。</b>申请期间学生仍然不能填报 ——
* 否则门禁就变成"填个表就能过"的形式主义,整套"凭证件号认人"的根基就没了。</li>
* <li><b>每人每任务最多 2 次</b>({@code 40037})。不设上限的话,
* 一个输错号码的学生能刷出几十条申请,管理员的待办里全是同一个人。</li>
* <li><b>证件号已在名册 → {@code 40039} 让他回去重新核验</b>,而不是受理申请。
* 这种情况说明他<b>只是姓名输错了</b>,受理申请等于让管理员去纠正一个
* 本来自己就能改对的问题。这一步不违反"统一失败提示":
* 学生已经主动提供了正确证件号,此时说"你已在名单中"**不泄露任何新信息**。</li>
* <li><b>审批通过时自动绑定</b>({@code bind_user_id} = 申请人)——
* 不然学生还得再输一遍姓名与证件号,而这两项刚刚才由他提供过。</li>
* </ol>
*
* <h3>明文只在内存里存在</h3>
*
* <p>证件号与手机号在这里<b>立刻</b>转成 HMAC + 脱敏值,
* 落库的只有 hash 与 mask(与 {@code collect_roster} 同一口径)。
* 申请来自**尚未核验**的人,更不该把明文留在库里。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JoinService {
/** 与账号模块同一口径的手机号校验。 */
private static final Pattern PHONE = Pattern.compile("^1[3-9]\\d{9}$");
/** 预计处理时长(给个量级,不是承诺)。 */
private static final int ESTIMATED_HOURS = 24;
/** 待审超过这个数就在列表上提醒(FR-509c)。 */
private static final int ALERT_PENDING = 50;
private final CollectJoinRequestMapper joinMapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final UserMapper userMapper;
private final SensitiveCodec codec;
// ── 学生侧 ─────────────────────────────────────
/** 提交申请(§8.7.1)。 */
public JoinDtos.SubmitVO submit(JoinDtos.SubmitReq req) {
if (req == null || req.getTaskId() == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 taskId");
}
String name = IdentityNormalizer.name(req.getRealName());
if (name == null || name.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写姓名");
}
String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
if (idCard == null || !idCard.matches("^\\d{17}[\\dXx]$")) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写 18 位身份证号");
}
String phone = req.getPhone() == null ? "" : req.getPhone().trim();
if (!PHONE.matcher(phone).matches()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写正确的手机号(老师要能联系到你)");
}
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectTask task = taskMapper.selectById(req.getTaskId());
if (task == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
}
if (task.getStatus() == null || task.getStatus() != CollectTask.STATUS_COLLECTING) {
throw new BizException(ErrorCode.TASK_NOT_OPEN, "任务当前不在收集期,暂时不能申请加入");
}
SysUser me = userMapper.selectById(uid);
String openid = me == null || me.getOpenId() == null ? "" : me.getOpenId();
if (openid.isBlank()) {
// 没有 openid 就没法按"人"限次(契约是"同 openid 每任务 ≤2 次")。
// 与其放宽成"不限次",不如明确拒绝 —— 放宽的口子会被反复利用。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"当前登录方式不支持提交申请,请从微信里重新进入");
}
int used = joinMapper.countByApplicant(tenantId, req.getTaskId(), openid);
if (used >= CollectJoinRequest.MAX_ATTEMPTS) {
throw new BizException(ErrorCode.JOIN_REQUEST_LIMIT);
}
String hash = codec.hash(idCard);
// ① 已在名册 → 让他回去重新核验(不是受理申请)
List<CollectRoster> inRoster = rosterMapper.selectByHash(tenantId, req.getTaskId(), hash);
if (!inRoster.isEmpty()) {
throw new BizException(ErrorCode.ALREADY_IN_ROSTER);
}
// ② 同证件号已有待审或已通过的申请
if (joinMapper.countActiveByIdCard(tenantId, req.getTaskId(), hash) > 0) {
throw new BizException(ErrorCode.JOIN_REQUEST_DUPLICATE);
}
CollectJoinRequest r = new CollectJoinRequest();
r.setTaskId(req.getTaskId());
r.setRealName(name);
r.setIdCardHash(hash);
// ★ 密文也要存:审批通过时要原样写进名册的 id_card_no(NOT NULL 的密文列)。
// 只存 hash 的话审批那一步会撞非空约束 —— 这正是本轮发现并修掉的 F-19。
r.setIdCardCipher(codec.encrypt(idCard));
r.setIdCardMask(MaskUtil.maskIdCard(idCard));
r.setPhoneHash(codec.hash(phone));
r.setPhoneMask(MaskUtil.maskPhone(phone));
r.setOrgHint(req.getOrgHint() == null ? null
// ⚠️ 自述班级**不能**用 IdentityNormalizer.name() 规范化:那个方法会去掉空格
// (姓名要"张 三"→"张三"),而班级名里的空格是**有意义**的
// ("24中技数控1班 645451" 去掉空格就再也匹配不上组织了)。
// 这里只做全角转半角 + 去首尾空白。
: IdentityNormalizer.toHalfWidth(req.getOrgHint()).trim());
r.setReason(req.getReason() == null ? null : req.getReason().trim());
r.setStatus(CollectJoinRequest.STATUS_PENDING);
r.setApplicantOpenid(openid);
r.setApplicantUid(uid);
r.setSource(CollectJoinRequest.SOURCE_STUDENT);
joinMapper.insert(r);
log.info("加入申请已提交:task={} request={} name={}", req.getTaskId(), r.getId(), name);
JoinDtos.SubmitVO vo = new JoinDtos.SubmitVO();
vo.setRequestId(r.getId());
vo.setStatus(CollectJoinRequest.STATUS_PENDING);
vo.setStatusText("已提交,等待学校审核");
vo.setEstimatedHours(ESTIMATED_HOURS);
vo.setRemainAttempts(CollectJoinRequest.MAX_ATTEMPTS - used - 1);
return vo;
}
/** 学生看自己的申请(§8.7.2)。 */
public Map<String, Object> myRequests(Long taskId) {
Long uid = UserContext.uid();
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getApplicantUid, uid)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.orderByDesc(CollectJoinRequest::getId);
List<CollectJoinRequest> rows = joinMapper.selectList(q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = rows.stream().map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
List<JoinDtos.MyRequestVO> list = new ArrayList<>();
for (CollectJoinRequest r : rows) {
JoinDtos.MyRequestVO vo = new JoinDtos.MyRequestVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setOrgHint(r.getOrgHint());
vo.setCreateTime(r.getCreateTime());
vo.setAuditTime(r.getAuditTime());
vo.setRejectReason(r.getRejectReason());
vo.setRosterId(r.getRosterId());
// 通过 = 已经绑好了 → 学生重新核验就能直接进
vo.setCanFillNow(r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED
&& r.getRosterId() != null);
list.add(vo);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("list", list);
out.put("total", list.size());
return out;
}
private String statusText(Integer status) {
return switch (status == null ? 0 : status) {
case CollectJoinRequest.STATUS_PENDING -> "待审核";
case CollectJoinRequest.STATUS_APPROVED -> "已通过,可以开始填报了";
case CollectJoinRequest.STATUS_REJECTED -> "未通过";
default -> "未知";
};
}
// ── 管理侧 ─────────────────────────────────────
/** 待办列表(§8.7.3)。 */
public JoinDtos.AdminListVO list(Long taskId, Integer status, Long orgId, String keyword,
Long pageNum, Long pageSize) {
Long tenantId = UserContext.tenantId();
int st = status == null ? CollectJoinRequest.STATUS_PENDING : status;
long pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
long ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
// orgId 筛的是"自述班级":申请还没有正式班级,只能按学生自己写的筛
var q = Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(CollectJoinRequest::getStatus, st)
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.like(keyword != null && !keyword.isBlank(), CollectJoinRequest::getRealName,
keyword == null ? "" : keyword.trim())
.orderByAsc(CollectJoinRequest::getCreateTime);
var page = joinMapper.selectPage(
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(pn, ps), q);
Map<Long, String> taskNames = new HashMap<>();
List<Long> taskIds = page.getRecords().stream()
.map(CollectJoinRequest::getTaskId).distinct().toList();
if (!taskIds.isEmpty()) {
for (CollectTask t : taskMapper.selectByIds(taskIds)) {
taskNames.put(t.getId(), t.getTaskName());
}
}
JoinDtos.AdminListVO out = new JoinDtos.AdminListVO();
List<JoinDtos.AdminItemVO> list = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (CollectJoinRequest r : page.getRecords()) {
JoinDtos.AdminItemVO vo = new JoinDtos.AdminItemVO();
vo.setRequestId(r.getId());
vo.setTaskId(r.getTaskId());
vo.setTaskName(taskNames.get(r.getTaskId()));
vo.setRealName(r.getRealName());
vo.setIdCardMask(r.getIdCardMask());
vo.setPhoneMask(r.getPhoneMask());
vo.setOrgHint(r.getOrgHint());
vo.setReason(r.getReason());
vo.setStatus(r.getStatus());
vo.setStatusText(statusText(r.getStatus()));
vo.setCreateTime(r.getCreateTime());
vo.setRosterId(r.getRosterId());
if (r.getCreateTime() != null) {
long mins = Duration.between(r.getCreateTime(), now).toMinutes();
vo.setWaitHours(Math.round(mins / 6.0) / 10.0);
}
// 重复线索:这个证件号在别的任务的名册里出现过吗
List<CollectJoinRequestMapper.DupHint> dup =
joinMapper.findInOtherTasks(tenantId, r.getTaskId(), r.getIdCardHash());
if (!dup.isEmpty()) {
JoinDtos.DupHint hint = new JoinDtos.DupHint();
hint.setType("IN_OTHER_TASK");
hint.setTaskName(taskNames.getOrDefault(dup.get(0).getTaskId(),
"任务 #" + dup.get(0).getTaskId()));
hint.setRealName(dup.get(0).getRealName());
vo.setDuplicateHint(hint);
}
list.add(vo);
}
out.setList(list);
out.setTotal(page.getTotal());
JoinDtos.Counts counts = new JoinDtos.Counts();
counts.setPending(countByStatus(taskId, CollectJoinRequest.STATUS_PENDING));
counts.setApproved(countByStatus(taskId, CollectJoinRequest.STATUS_APPROVED));
counts.setRejected(countByStatus(taskId, CollectJoinRequest.STATUS_REJECTED));
out.setCounts(counts);
if (counts.getPending() > ALERT_PENDING) {
JoinDtos.Alert alert = new JoinDtos.Alert();
alert.setLevel(2);
alert.setText("待审申请已达 " + counts.getPending() + " 条,建议集中处理");
out.setAlert(alert);
}
return out;
}
private long countByStatus(Long taskId, int status) {
return joinMapper.selectCount(Wrappers.<CollectJoinRequest>lambdaQuery()
.eq(taskId != null, CollectJoinRequest::getTaskId, taskId)
.eq(CollectJoinRequest::getStatus, status));
}
/**
* 审批通过(§8.7.4):建名册项 + 自动绑定 + 回写申请。
*
* <p><b>事务</b>:名册建了而申请没回写(或反过来),学生会看到"申请还在待审"
* 但名册里已经有他了 —— 他接着再申请一次就撞 {@code 40038}。两件事必须同生同死。
*/
@Transactional(rollbackFor = Exception.class)
public JoinDtos.ApproveVO approve(Long id, JoinDtos.ApproveReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
// 班级:优先用管理员指定的;没指定就用学生的自述班级去匹配组织名(匹配不上必须手选)
Long orgId = req == null ? null : req.getOrgId();
if (orgId == null) {
orgId = matchOrgByName(r.getOrgHint());
}
if (orgId == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"请指定这个学生要加入的班级"
+ (r.getOrgHint() == null ? "" : "(他自述是「" + r.getOrgHint() + "」)"));
}
if (rosterMapper.countOrg(tenantId, orgId) == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
}
// 证件号:允许管理员核对后修正(学生可能把号码写错了)
String idCard = req != null && req.getIdCardNo() != null && !req.getIdCardNo().isBlank()
? IdentityNormalizer.idCard(req.getIdCardNo()) : null;
String hash = r.getIdCardHash();
if (idCard != null) {
hash = codec.hash(idCard);
}
String name = req != null && req.getRealName() != null && !req.getRealName().isBlank()
? IdentityNormalizer.name(req.getRealName()) : r.getRealName();
// 名册里这个证件号被占了吗(可能刚被老师手工加进去)
if (!rosterMapper.selectByHash(tenantId, r.getTaskId(), hash).isEmpty()) {
throw new BizException(ErrorCode.ROSTER_ID_CARD_TAKEN);
}
CollectRoster row = new CollectRoster();
row.setTaskId(r.getTaskId());
row.setOrgId(orgId);
row.setSeqNo(req == null ? null : req.getSeqNo());
row.setRealName(name);
row.setIdCardMask(MaskUtil.maskIdCard(idCard != null ? idCard : r.getIdCardMask()));
row.setIdCardHash(hash);
// 名册的 id_card_no 是 **NOT NULL 的密文列**:管理员修正了就用新的,否则搬申请里那份
if (idCard != null) {
row.setIdCardNo(codec.encrypt(idCard));
} else if (r.getIdCardCipher() != null && !r.getIdCardCipher().isBlank()) {
row.setIdCardNo(r.getIdCardCipher());
} else {
// V7 之前提交的申请没有密文列。**不能静默建一条没有证件号的名册行** ——
// 那条行以后永远过不了名册校验,而且没人知道为什么。
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这条申请缺少证件号信息,请让申请人在小程序里重新提交一次");
}
row.setPhone(r.getPhoneMask());
row.setPhoneHash(r.getPhoneHash());
// 来源留痕:老师日后问"这个人是哪来的",能答得上来
row.setExtJson(JSONUtil.createObj()
.set("source", "JOIN_REQUEST")
.set("joinRequestId", r.getId())
.set("orgHint", r.getOrgHint())
.toString());
// ★ 自动绑定(FR-509g):学生刚提供过姓名与证件号,不该让他再输一遍
row.setBindUserId(r.getApplicantUid());
row.setBindTime(LocalDateTime.now());
// bind_type = 3(来自申请)—— 与契约 §8.7.4 一致;1 门禁 / 2 管理员指定
row.setBindType(3);
row.setSubmitStatus(CollectRoster.SUBMIT_NONE);
rosterMapper.insert(row);
int affected = joinMapper.markApproved(tenantId, id, row.getId(), uid);
if (affected == 0) {
// 并发下别人先处理了 —— 整笔回滚(名册那一行也不能留)
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请通过:request={} roster={} org={} by={}", id, row.getId(), orgId, uid);
// ⚠️ 通知(M5-12)还没做:只能靠老师口头/群里说一声。
// 这里如实回 false,**不为了让契约好看而写 true**(同驳回接口的口径)。
log.warn("加入申请已通过但**没有发出通知**(通知渠道未实现):request={} 需人工告知学生", id);
JoinDtos.ApproveVO vo = new JoinDtos.ApproveVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_APPROVED);
vo.setRosterId(row.getId());
vo.setRosterCreated(true);
vo.setNotifySent(false);
vo.setCanFillNow(r.getApplicantUid() != null);
return vo;
}
/** 自述班级 → 组织 id(按名字精确或包含匹配;命中多个就不猜,返回 null 让管理员手选)。 */
private Long matchOrgByName(String orgHint) {
if (orgHint == null || orgHint.isBlank()) {
return null;
}
Long tenantId = UserContext.tenantId();
var rows = orgNameToId(tenantId, orgHint.trim());
return rows.size() == 1 ? rows.get(0) : null;
}
private List<Long> orgNameToId(Long tenantId, String name) {
// 直接查 sys_org:几十个节点,一次取回来在内存里匹配
return rosterMapper.selectOrgIdsByName(tenantId, name);
}
/** 驳回(§8.7.5)。 */
@Transactional(rollbackFor = Exception.class)
public JoinDtos.RejectVO reject(Long id, JoinDtos.RejectReq req) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
if (req == null || req.getReason() == null || req.getReason().isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请填写驳回原因(学生要知道为什么)");
}
CollectJoinRequest r = joinMapper.selectById(id);
if (r == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "申请不存在或已删除");
}
if (r.getStatus() != null && r.getStatus() == CollectJoinRequest.STATUS_APPROVED) {
// 已通过不能"驳回" —— 要撤销请删名册项(否则名册里那个人就成了没人认领的行)
throw new BizException(ErrorCode.JOIN_ALREADY_APPROVED);
}
if (r.getStatus() == null || r.getStatus() != CollectJoinRequest.STATUS_PENDING) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
String reason = req.getReasonCode() == null || req.getReasonCode().isBlank()
? req.getReason().trim()
: "[" + req.getReasonCode() + "] " + req.getReason().trim();
int affected = joinMapper.markRejected(tenantId, id, reason, uid);
if (affected == 0) {
throw new BizException(ErrorCode.JOIN_STATUS_CHANGED);
}
log.info("加入申请驳回:request={} by={} reason={}", id, uid, reason);
log.warn("加入申请已驳回但**没有发出通知**(通知渠道未实现):request={}", id);
JoinDtos.RejectVO vo = new JoinDtos.RejectVO();
vo.setRequestId(id);
vo.setStatus(CollectJoinRequest.STATUS_REJECTED);
vo.setNotifySent(false);
return vo;
}
}
@@ -0,0 +1,33 @@
-- ============================================================================
-- V7:加入申请补一列「证件号密文」
--
-- 为什么需要它(F-19,本轮发现)
-- ---------------------------------------------------------------------------
-- 审批通过时要往 `collect_roster` 插一行,而那一行的 `id_card_no` 是
-- **NOT NULL**(AES-GCM 密文,见列注释"不可用于等值查询")。
--
-- 原设计(契约 §8.7.4)只说"服务端转 HMAC,不落明文",于是申请表里
-- **只有 hash 与 mask,没有密文** → 审批到写名册这一步必然
-- `null value in column "id_card_no" violates not-null constraint`(500)。
--
-- 三条路都不行:
-- · 让申请人先过门禁再建名册 —— 那就变成"先有鸡还是先有蛋"(门禁靠名册判人);
-- · 让管理员在审批时重新输入证件号 —— 他手里只有学生自述的号码,
-- 重新输入等于把"学生写错一位"的风险复制到管理员身上;
-- · 干脆不写这一列 —— DDL 有 NOT NULL,而且审核要看完整号时正是解的这一列。
--
-- 所以补一列 **AES-GCM 密文**(不是明文):
-- `id_card_cipher` —— 与 `collect_roster.id_card_no` **同一把密钥、同一个算法**,
-- 审批时原样搬过去即可,明文全程只在提交请求的内存里存在过。
-- 它与 hash 的分工不变:hash 用于等值查重,cipher 用于"需要时解密"。
--
-- ⚠️ 可空是刻意的:本迁移之前提交的申请没有这一列的值,
-- 审批时会明确提示"这条申请缺少证件号密文,请让管理员核对后重新提交"
-- (**不能**静默建一条没有证件号的名册行 —— 那条行以后永远过不了名册校验)。
-- ============================================================================
ALTER TABLE collect_join_request
ADD COLUMN IF NOT EXISTS id_card_cipher VARCHAR(256);
COMMENT ON COLUMN collect_join_request.id_card_cipher IS
'证件号 AES-GCM 密文(与 collect_roster.id_card_no 同算法同密钥);审批通过时原样写入名册,避免管理员重输';
+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 {
+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>