feat(mp): 学生端「我的填报 / 撤回 / 进度」与班主任端看板、审核卡片流、代传材料

学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条
分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。

班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、
一键通过只带无问题项;代传走一步 multipart,三道闸门(只给自负责的班 / 只给草稿单 /
必须留痕且学生可见),V12 给 collect_file 补 proxy_by / proxy_reason。

验证:e2e-m3-mytask 42/42、e2e-m5-teacher-dashboard 24/24、e2e-m5-proxy 33/33;
H5 build:h5 产物含新增各页。
This commit is contained in:
2026-09-30 09:02:52 +08:00
parent 45271a3c08
commit ad86e2d521
23 changed files with 4997 additions and 20 deletions
@@ -0,0 +1,56 @@
package cn.tianbaotong.fill.module.fill.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.fill.dto.StudentDtos;
import cn.tianbaotong.fill.module.fill.service.StudentTaskService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 学生端的"我的任务 / 撤回 / 进度"(《03-接口设计》§9.1、§9.11、§9.12)。
*
* <p>单独一个 Controller(而不是塞进 {@code FillController}):
* 这三个接口的授权口径与填报不同 —— 填报要"已过门禁",
* 而"我的任务"恰恰是<b>还没进门禁的人也要能看到</b>(他得知道自己被邀请过没有)。
* 混在一个类里,类级注解就会把两种口径搅在一起。
*/
@Tag(name = "小程序/H5 · 我的任务与进度")
@RestController
@RequestMapping("/api/v1/mp")
@RequiredArgsConstructor
public class StudentTaskController {
private final StudentTaskService studentTaskService;
@GetMapping("/tasks")
@Operation(summary = "我的任务列表",
description = "**可见范围 = 名册里绑定了我**(名册就是学生的任务清单)。"
+ "canFillNow 与 fillBlockReason 由服务端算好 —— "
+ "前端不要自己按任务状态判能不能填(还要考虑截止时间与审核中)")
public ApiResult<StudentDtos.MyTaskPageVO> myTasks() {
return ApiResult.ok(studentTaskService.myTasks());
}
@PostMapping("/submissions/{id}/withdraw")
@Operation(summary = "撤回提交(回到草稿)",
description = "只有「等待初审 / 等待终审」的能撤;已通过要管理员操作(40031)。"
+ "撤回会**同时把名册行的状态退回草稿**(否则班级看板会一直显示他已提交)")
public ApiResult<StudentDtos.WithdrawVO> withdraw(@PathVariable Long id) {
return ApiResult.ok(studentTaskService.withdraw(id));
}
@GetMapping("/submissions/{id}/progress")
@Operation(summary = "进度与问题清单",
description = "progressPercent 按**必传材料已交齐的比例**算;"
+ "materials 每项带 issueLevel(0无/1提示/2警告/3阻断)与 issueText;"
+ "timeline 只列**确实发生过**的节点(编一条假时间线会让学生以为系统记错了)")
public ApiResult<StudentDtos.ProgressVO> progress(@PathVariable Long id) {
return ApiResult.ok(studentTaskService.progress(id));
}
}
@@ -0,0 +1,183 @@
package cn.tianbaotong.fill.module.fill.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-接口设计》§9.1、§9.11、§9.12)。
*/
public final class StudentDtos {
private StudentDtos() {
}
// ── §9.1 我的任务 ─────────────────────────────
/** 一个任务。 */
@Getter
@Setter
@Schema(description = "我的任务")
public static class MyTaskVO {
private Long taskId;
private String taskCode;
private String taskName;
private String batchNo;
private String majorName;
private String jobName;
private Integer collectMode;
private Integer certType;
/** 任务状态:{@code 0} 草稿 / {@code 1} 收集中 / {@code 2} 已截止 / {@code 3} 已归档 / {@code 4} 暂停 */
private Integer taskStatus;
/** 能不能现在填(收集中 + 未提交)。**服务端算好**,前端不自己判 */
private Boolean canFillNow;
/** 为什么不能填(收集中以外的状态都要说清) */
private String fillBlockReason;
private LocalDateTime startTime;
private LocalDateTime endTime;
/** 距截止还有几天(负数 = 已过期);无截止时间为 null */
private Integer remainDays;
private Long rosterId;
/** 填报单 id(还没开始时为 null)—— 撤回与看进度都要用它,所以列表里一并带上 */
private Long submissionId;
private Long orgId;
private String orgName;
private String occupation;
private String worktype;
private String jobLevel;
/** 填报单状态:{@code null} = 还没开始 / {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 / {@code 4} 已驳回 */
private Integer submissionStatus;
private String statusText;
private LocalDateTime submitTime;
private Integer redCount;
private Integer yellowCount;
private Integer rejectCount;
/** 被驳回的原因(学生最需要看到的那句话) */
private String lastRejectReason;
/** 能不能撤回(待初审/待终审时可撤;已终审要管理员操作) */
private Boolean canWithdraw;
}
/** 我的任务列表。 */
@Getter
@Setter
public static class MyTaskPageVO {
private List<MyTaskVO> list = new ArrayList<>();
private long total;
/** 还没交的任务数(首页红点用) */
private long todoCount;
private long doneCount;
}
// ── §9.11 撤回 ────────────────────────────────
/** 撤回结果。 */
@Getter
@Setter
public static class WithdrawVO {
private Integer status;
private String statusText;
private LocalDateTime withdrawnAt;
}
// ── §9.12 进度与问题清单 ──────────────────────
/** 一个材料的进度。 */
@Getter
@Setter
public static class MaterialProgress {
private String materialCode;
private String materialName;
private Boolean required;
private Boolean done;
private Integer fileCount;
/** 这一项最严重的问题级别:{@code 0} 无 / {@code 1} 提示 / {@code 2} 警告 / {@code 3} 阻断 */
private Integer issueLevel;
private String issueText;
}
/** 时间轴一条。 */
@Getter
@Setter
public static class ProgressEvent {
private LocalDateTime time;
private String title;
private String desc;
}
/** 进度。 */
@Getter
@Setter
public static class ProgressVO {
private Long submissionId;
private Integer status;
private String statusText;
/** 形如 {@code 75%}(按"必传材料已交齐的比例"算,不是按字段数) */
private String progressPercent;
private List<MaterialProgress> materials = new ArrayList<>();
private List<ProgressEvent> timeline = new ArrayList<>();
/** 还能不能改(草稿/已驳回 = 能改;待审/已通过 = 要撤回) */
private Boolean canEdit;
private Boolean canWithdraw;
private Boolean canSubmit;
}
}
@@ -0,0 +1,182 @@
package cn.tianbaotong.fill.module.fill.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 学生端"我的任务"查询(《03-接口设计》§9.1)。
*
* <p><b>为什么要单独一条 SQL</b>:任务的可见范围对学生不是"租户内全部",
* 而是"<b>名册里绑定了我</b>的那些任务" —— 换句话说,
* <b>名册就是学生的任务清单</b>。用 {@code collect_task} 直接分页会把他
* 没被邀请的任务也列出来,而那类任务他点进去只会撞门禁(40007)。
*
* <p>显式带 {@code tenant_id} 并 {@code @InterceptorIgnore}:与其它手写 SQL 同一口径,
* 避免"拦截器补一份、我又写一份"两边不一致。
*/
public interface StudentTaskMapper {
/** 一条"我的任务"。 */
class MyTaskRow {
private Long taskId;
private String taskCode;
private String taskName;
private String batchNo;
private String majorName;
private String jobName;
private Integer collectMode;
private Integer certType;
private Integer taskStatus;
private java.time.LocalDateTime startTime;
private java.time.LocalDateTime endTime;
private Long rosterId;
private Long orgId;
private String orgName;
private String occupation;
private String worktype;
private String jobLevel;
private Integer rosterSubmitStatus;
private Long submissionId;
private Integer submissionStatus;
private java.time.LocalDateTime submitTime;
private Integer redCount;
private Integer yellowCount;
/** 被驳回次数 —— 学生端要显示"老师让你改第几次了" */
private Integer rejectCount;
private String lastRejectReason;
public Long getTaskId() { return taskId; }
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskCode() { return taskCode; }
public void setTaskCode(String v) { this.taskCode = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public String getBatchNo() { return batchNo; }
public void setBatchNo(String v) { this.batchNo = v; }
public String getMajorName() { return majorName; }
public void setMajorName(String v) { this.majorName = v; }
public String getJobName() { return jobName; }
public void setJobName(String v) { this.jobName = v; }
public Integer getCollectMode() { return collectMode; }
public void setCollectMode(Integer v) { this.collectMode = v; }
public Integer getCertType() { return certType; }
public void setCertType(Integer v) { this.certType = v; }
public Integer getTaskStatus() { return taskStatus; }
public void setTaskStatus(Integer v) { this.taskStatus = v; }
public java.time.LocalDateTime getStartTime() { return startTime; }
public void setStartTime(java.time.LocalDateTime v) { this.startTime = v; }
public java.time.LocalDateTime getEndTime() { return endTime; }
public void setEndTime(java.time.LocalDateTime v) { this.endTime = v; }
public Long getRosterId() { return rosterId; }
public void setRosterId(Long v) { this.rosterId = v; }
public Long getOrgId() { return orgId; }
public void setOrgId(Long v) { this.orgId = v; }
public String getOrgName() { return orgName; }
public void setOrgName(String v) { this.orgName = v; }
public String getOccupation() { return occupation; }
public void setOccupation(String v) { this.occupation = v; }
public String getWorktype() { return worktype; }
public void setWorktype(String v) { this.worktype = v; }
public String getJobLevel() { return jobLevel; }
public void setJobLevel(String v) { this.jobLevel = v; }
public Integer getRosterSubmitStatus() { return rosterSubmitStatus; }
public void setRosterSubmitStatus(Integer v) { this.rosterSubmitStatus = v; }
public Long getSubmissionId() { return submissionId; }
public void setSubmissionId(Long v) { this.submissionId = v; }
public Integer getSubmissionStatus() { return submissionStatus; }
public void setSubmissionStatus(Integer v) { this.submissionStatus = v; }
public java.time.LocalDateTime getSubmitTime() { return submitTime; }
public void setSubmitTime(java.time.LocalDateTime v) { this.submitTime = v; }
public Integer getRedCount() { return redCount; }
public void setRedCount(Integer v) { this.redCount = v; }
public Integer getYellowCount() { return yellowCount; }
public void setYellowCount(Integer v) { this.yellowCount = v; }
public Integer getRejectCount() { return rejectCount; }
public void setRejectCount(Integer v) { this.rejectCount = v; }
public String getLastRejectReason() { return lastRejectReason; }
public void setLastRejectReason(String v) { this.lastRejectReason = v; }
}
/**
* 我的任务列表:名册里绑定了我、且任务已经发布过的。
*
* <p>排序按"还能填的排前面、快要截止的再往前":学生打开这一屏最想知道的是
* <b>我还有什么没交</b>,而不是"历史上我参加过什么"。
* 已在 SQL 里排好,前端不用再排(前端排的话口径迟早和别处不一致)。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT t.id AS taskId,
t.task_code AS taskCode,
t.task_name AS taskName,
t.batch_no AS batchNo,
t.major_name AS majorName,
t.job_name AS jobName,
t.collect_mode AS collectMode,
t.cert_type AS certType,
t.status AS taskStatus,
t.start_time AS startTime,
t.end_time AS endTime,
r.id AS rosterId,
r.org_id AS orgId,
o.org_name AS orgName,
COALESCE(s.occupation, r.occupation) AS occupation,
COALESCE(s.worktype, r.worktype) AS worktype,
COALESCE(s.job_level, r.job_level) AS jobLevel,
r.submit_status AS rosterSubmitStatus,
s.id AS submissionId,
s.status AS submissionStatus,
s.submit_time AS submitTime,
s.red_count AS redCount,
s.yellow_count AS yellowCount,
s.reject_count AS rejectCount,
s.last_reject_reason AS lastRejectReason
FROM collect_roster r
JOIN collect_task t ON t.id = r.task_id AND t.deleted = 0 AND t.tenant_id = r.tenant_id
LEFT JOIN sys_org o ON o.id = r.org_id AND o.deleted = 0
LEFT JOIN collect_submission s ON s.roster_id = r.id AND s.deleted = 0
WHERE r.tenant_id = #{tenantId} AND r.bind_user_id = #{uid} AND r.deleted = 0
AND t.status <> 0
ORDER BY
-- ① 还能填的(收集中的)排最前
CASE WHEN t.status = 1 THEN 0 ELSE 1 END,
-- ② 还没交的比已交的靠前
CASE WHEN s.id IS NULL OR s.status IN (0, 4) THEN 0 ELSE 1 END,
-- ③ 截止时间近的靠前(null 排最后)
t.end_time NULLS LAST,
t.id DESC
""")
List<MyTaskRow> listMyTasks(@Param("tenantId") Long tenantId, @Param("uid") Long uid);
/**
* 取学生名下某个任务的提交单(撤回/进度都要先确认"这份是不是他的")。
*
* <p>按 {@code submitter_id} 过滤而不是只按 id:越权访问别人的填报单
* 是这类接口最容易漏的一步 —— 前端传来的 id 是可以随便改的。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_submission
WHERE tenant_id = #{tenantId} AND id = #{id} AND submitter_id = #{uid} AND deleted = 0
""")
cn.tianbaotong.fill.module.fill.entity.CollectSubmission selectMine(
@Param("tenantId") Long tenantId, @Param("id") Long id, @Param("uid") Long uid);
/**
* 某个填报单下**当前生效**的文件(按材料码分组的计数用)。
*
* <p>只取 {@code is_current = 1}:学生重传过的那份旧文件不算"已交",
* 否则他会看到"身份证已交"而实际上库里只有废件。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_file
WHERE tenant_id = #{tenantId} AND submission_id = #{submissionId}
AND is_current = 1 AND deleted = 0
""")
List<cn.tianbaotong.fill.module.file.entity.CollectFile> listMyFiles(
@Param("tenantId") Long tenantId, @Param("submissionId") Long submissionId);
}
@@ -0,0 +1,424 @@
package cn.tianbaotong.fill.module.fill.service;
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.module.fill.dto.StudentDtos;
import cn.tianbaotong.fill.module.fill.dto.SubmitDtos;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.StudentTaskMapper;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.material.dto.MaterialDefVO;
import cn.tianbaotong.fill.module.material.service.MaterialDefService;
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 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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 学生端的"我的任务 / 撤回 / 进度"(《03-接口设计》§9.1、§9.11、§9.12)。
*
* <h3>三件必须守住的事</h3>
*
* <ol>
* <li><b>任务的可见范围 = 名册绑定关系</b>,不是"租户内全部任务"。
* 学生只该看到"老师把我加进名单的任务" —— 别的任务他点进去也只会撞门禁。</li>
* <li><b>每次操作都要验"这份是不是他的"</b>。前端传来的 submissionId 可以随便改,
* 所以查询一律带 {@code submitter_id}(见 {@code StudentTaskMapper.selectMine})。</li>
* <li><b>已终审通过的单不能自己撤回</b>({@code 40031}):那份材料可能已经交付了,
* 要改必须走管理员。这条与"已导出不可撤"({@code 40033})是两道不同的闸门。</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StudentTaskService {
private final StudentTaskMapper studentTaskMapper;
private final SubmissionMapper submissionMapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final MaterialDefService materialDefService;
private final SubmissionValidator validator;
private final FillService fillService;
// ── §9.1 我的任务 ─────────────────────────────
public StudentDtos.MyTaskPageVO myTasks() {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
List<StudentTaskMapper.MyTaskRow> rows = studentTaskMapper.listMyTasks(tenantId, uid);
List<StudentDtos.MyTaskVO> list = new ArrayList<>();
long todo = 0;
long done = 0;
LocalDateTime now = LocalDateTime.now();
for (StudentTaskMapper.MyTaskRow r : rows) {
StudentDtos.MyTaskVO vo = new StudentDtos.MyTaskVO();
vo.setTaskId(r.getTaskId());
vo.setTaskCode(r.getTaskCode());
vo.setTaskName(r.getTaskName());
vo.setBatchNo(r.getBatchNo());
vo.setMajorName(r.getMajorName());
vo.setJobName(r.getJobName());
vo.setCollectMode(r.getCollectMode());
vo.setCertType(r.getCertType());
vo.setTaskStatus(r.getTaskStatus());
vo.setStartTime(r.getStartTime());
vo.setEndTime(r.getEndTime());
vo.setRosterId(r.getRosterId());
vo.setSubmissionId(r.getSubmissionId());
vo.setOrgId(r.getOrgId());
vo.setOrgName(r.getOrgName());
vo.setOccupation(r.getOccupation());
vo.setWorktype(r.getWorktype());
vo.setJobLevel(r.getJobLevel());
vo.setSubmissionStatus(r.getSubmissionStatus());
vo.setSubmitTime(r.getSubmitTime());
vo.setRedCount(r.getRedCount() == null ? 0 : r.getRedCount());
vo.setYellowCount(r.getYellowCount() == null ? 0 : r.getYellowCount());
vo.setRejectCount(r.getRejectCount() == null ? 0 : r.getRejectCount());
vo.setLastRejectReason(r.getLastRejectReason());
vo.setStatusText(statusText(r.getSubmissionStatus(), r.getTaskStatus()));
if (r.getEndTime() != null) {
vo.setRemainDays((int) Duration.between(now, r.getEndTime()).toDays());
}
// 能不能现在填:任务在收集期 + 还没在审 + 还有时间
boolean collecting = r.getTaskStatus() != null && r.getTaskStatus() == CollectTask.STATUS_COLLECTING;
boolean expired = r.getEndTime() != null && r.getEndTime().isBefore(now);
Integer st = r.getSubmissionStatus();
boolean underReview = st != null && (st == 1 || st == 2 || st == 3);
vo.setCanFillNow(collecting && !expired && !underReview);
vo.setFillBlockReason(fillBlockReason(r.getTaskStatus(), expired, st));
// 可撤回:待初审 / 待终审;已通过不行(40031),草稿与已驳回没有"撤回"这回事
vo.setCanWithdraw(st != null && (st == 1 || st == 2));
if (st == null || st == 0 || st == 4) {
todo++;
} else {
done++;
}
list.add(vo);
}
StudentDtos.MyTaskPageVO out = new StudentDtos.MyTaskPageVO();
out.setList(list);
out.setTotal(list.size());
out.setTodoCount(todo);
out.setDoneCount(done);
return out;
}
private String statusText(Integer submissionStatus, Integer taskStatus) {
if (submissionStatus == null || submissionStatus == 0) {
// 还没建草稿:状态取决于任务本身
return switch (taskStatus == null ? 0 : taskStatus) {
case CollectTask.STATUS_COLLECTING -> "还没开始填";
case CollectTask.STATUS_PAUSED -> "已暂停(老师暂时关了填报)";
case CollectTask.STATUS_CLOSED -> "已截止,你没有提交";
case CollectTask.STATUS_ARCHIVED -> "已归档";
default -> "未开始";
};
}
return switch (submissionStatus) {
case 1 -> "已提交,等待班主任初审";
case 2 -> "初审已过,等待管理员终审";
case 3 -> "已通过";
case 4 -> "被驳回,请修改后重新提交";
default -> "未知";
};
}
private String fillBlockReason(Integer taskStatus, boolean expired, Integer submissionStatus) {
if (taskStatus == null) {
return null;
}
if (taskStatus == CollectTask.STATUS_PAUSED) {
return "老师暂时暂停了填报,稍后再来";
}
if (taskStatus == CollectTask.STATUS_CLOSED) {
return "本次收集已截止,如需补交请联系班主任";
}
if (taskStatus == CollectTask.STATUS_ARCHIVED) {
return "本次认定已归档,不能再修改";
}
if (expired) {
return "已过填报截止时间,如需补交请联系班主任";
}
if (submissionStatus != null && submissionStatus == 3) {
return "已经通过审核了,无需再改";
}
if (submissionStatus != null && (submissionStatus == 1 || submissionStatus == 2)) {
return "已经提交,等待老师审核;要改请先撤回";
}
return null;
}
// ── §9.11 撤回 ────────────────────────────────
/**
* 撤回提交(回到草稿)。
*
* <p>约束(契约 §9.11):{@code status ∈ {1, 2}} 可撤 ——
* 也就是"还在审"的单子。{@code status = 3}(已通过)要管理员操作({@code 40031})。
*
* <p>为什么已通过不能自己撤:那份材料可能已经进了导出包或在机构那边流转,
* 学生手一滑就撤回会让"已交付的数据"和"系统里显示的状态"对不上。
*
* <p>撤回会**同时把名册行的提交状态退回草稿** —— 否则班主任的班级看板会一直
* 显示这个学生"已提交",而实际上他正在改。这两个状态必须同步(同一次事务)。
*/
@Transactional(rollbackFor = Exception.class)
public StudentDtos.WithdrawVO withdraw(Long id) {
CollectSubmission s = requireMine(id);
Integer st = s.getStatus();
if (st != null && st == 3) {
throw new BizException(ErrorCode.FINAL_APPROVED_NO_WITHDRAW);
}
if (st == null || (st != 1 && st != 2)) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这份申请当前不能撤回(只有「等待审核」的才能撤回)");
}
LocalDateTime now = LocalDateTime.now();
submissionMapper.update(null, Wrappers.<CollectSubmission>lambdaUpdate()
.eq(CollectSubmission::getId, id)
.set(CollectSubmission::getStatus, 0)
.set(CollectSubmission::getSubmitTime, null)
.set(CollectSubmission::getClassAuditBy, null)
.set(CollectSubmission::getClassAuditTime, null)
.set(CollectSubmission::getAdminAuditBy, null)
.set(CollectSubmission::getAdminAuditTime, null));
if (s.getRosterId() != null) {
rosterMapper.update(null, Wrappers.<CollectRoster>lambdaUpdate()
.eq(CollectRoster::getId, s.getRosterId())
.set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_DRAFT));
}
log.info("学生撤回提交:submission={} 原状态={} 名册行={}", id, st, s.getRosterId());
StudentDtos.WithdrawVO vo = new StudentDtos.WithdrawVO();
vo.setStatus(0);
vo.setStatusText("已撤回,可以继续修改后重新提交");
vo.setWithdrawnAt(now);
return vo;
}
// ── §9.12 进度与问题清单 ──────────────────────
/**
* 进度(§9.12)。
*
* <p>百分比按<b>必传材料已交齐的比例</b>算,不按字段数:
* 学生关心的"我还差什么"就是材料 —— 字段缺了会在提交清单里逐条列出来,
* 而材料缺了是实打实交不上去的。
*/
public StudentDtos.ProgressVO progress(Long id) {
CollectSubmission s = requireMine(id);
CollectTask task = taskMapper.selectById(s.getTaskId());
CollectRoster roster = s.getRosterId() == null ? null : rosterMapper.selectById(s.getRosterId());
List<SubmitDtos.Issue> issues = validator.validate(s, task, roster);
List<MaterialDefVO> defs = materialDefService.list(s.getTaskId());
// ★ 条件可见性必须在这里也求一次值:
// 「工作证明」这类材料的 visibleWhen 是 is_student=false,
// 在校生根本不该被要求交 —— 但它的 required 仍是 1。
// 不判可见性的话,分母里会永远多一项,进度**永远到不了 100%**,
// 而学生看到一个永远不满的进度条只会以为"我是不是还差什么"。
// (E2E 第一版就是这样:交齐了显示 75%。)
Map<String, Object> values = formValuesOf(task, roster, s);
// 每个材料项:是否必传 / 是否已交 / 有什么问题
Map<String, List<SubmitDtos.Issue>> issueByCode = new LinkedHashMap<>();
Map<String, Integer> fileCountByCode = new LinkedHashMap<>();
collectFileCounts(s, fileCountByCode);
for (SubmitDtos.Issue i : issues) {
for (String code : materialCodesOf(i)) {
issueByCode.computeIfAbsent(code, k -> new ArrayList<>()).add(i);
}
}
List<StudentDtos.MaterialProgress> mats = new ArrayList<>();
int requiredTotal = 0;
int requiredDone = 0;
for (MaterialDefVO def : defs) {
// 本次不适用的材料(条件不成立)不进清单、也不进分母
if (def.getVisibleWhen() != null && !SubmissionValidator.visible(def.getVisibleWhen(), values)) {
continue;
}
StudentDtos.MaterialProgress m = new StudentDtos.MaterialProgress();
m.setMaterialCode(def.getMaterialCode());
m.setMaterialName(def.getMaterialName());
m.setRequired(def.getRequired());
int cnt = fileCountByCode.getOrDefault(def.getMaterialCode(), 0);
m.setFileCount(cnt);
m.setDone(cnt > 0);
List<SubmitDtos.Issue> mine = issueByCode.getOrDefault(def.getMaterialCode(), List.of());
int worst = 0;
String text = null;
for (SubmitDtos.Issue i : mine) {
if (i.severity() > worst) {
worst = i.severity();
text = i.title();
}
}
// 还没交的必传材料:本身就是阻断级,即使校验问题没带上材料码也要标出来
if (Boolean.TRUE.equals(def.getRequired()) && cnt == 0 && worst < SubmitDtos.Issue.ERROR) {
worst = SubmitDtos.Issue.ERROR;
text = "缺少必传材料";
}
m.setIssueLevel(worst);
m.setIssueText(text);
if (Boolean.TRUE.equals(def.getRequired())) {
requiredTotal++;
if (cnt > 0) {
requiredDone++;
}
}
mats.add(m);
}
StudentDtos.ProgressVO vo = new StudentDtos.ProgressVO();
vo.setSubmissionId(id);
vo.setStatus(s.getStatus());
vo.setStatusText(statusText(s.getStatus(), task == null ? null : task.getStatus()));
int percent = requiredTotal == 0 ? (mats.isEmpty() ? 0 : 100)
: (int) Math.round(100.0 * requiredDone / requiredTotal);
vo.setProgressPercent(percent + "%");
vo.setMaterials(mats);
vo.setTimeline(timelineOf(s));
Integer st = s.getStatus();
vo.setCanEdit(st == null || st == 0 || st == 4);
vo.setCanWithdraw(st != null && (st == 1 || st == 2));
vo.setCanSubmit(st == null || st == 0 || st == 4);
return vo;
}
/**
* 把校验问题归到某个材料项。
*
* <p>⚠️ {@code SubmissionValidator} 加"缺材料"那条问题时**没有**在 {@code extra} 里带
* {@code materialCode}(只写在文案里),所以这里还要按文案里的材料名反查一次。
* 只认 {@code extra} 的话,"缺少必传材料:工作证明"会挂不到任何一项上 ——
* 表现是界面上那一项什么问题都不显示。**这是本轮 E2E 抓到的**(F-21)。
*/
private List<String> materialCodesOf(SubmitDtos.Issue i) {
List<String> out = new ArrayList<>();
if (i.extra() != null) {
Object v = i.extra().get("materialCode");
if (v != null) {
out.add(String.valueOf(v));
}
}
if (out.isEmpty() && i.title() != null) {
for (String[] codeName : MATERIAL_NAMES) {
if (i.title().contains(codeName[1])) {
out.add(codeName[0]);
}
}
}
return out;
}
/** 材料码 → 材料名(反查文案用;与 {@code MaterialCatalog} 的内置目录同口径)。 */
private static final List<String[]> MATERIAL_NAMES = List.of(
new String[]{"PHOTO1", "一寸照片"},
new String[]{"IDCARD", "身份证"},
new String[]{"SCHOOL_ROLL", "学籍表"},
new String[]{"WORK_PROOF", "工作证明"},
new String[]{"XJW_BA", "学籍网备案表"},
new String[]{"OTHER_PROOF", "其它佐证材料"});
/** 表单当前值(求条件可见性用)—— 与学生看到的、校验用的**同一份装配结果**。 */
private Map<String, Object> formValuesOf(CollectTask task, CollectRoster roster, CollectSubmission s) {
Map<String, Object> values = new LinkedHashMap<>();
try {
var form = fillService.assembleFor(task, roster, s);
for (var sec : form.getSections()) {
for (var f : sec.getFields()) {
values.put(f.getKey(), f.getValue());
}
}
} catch (RuntimeException e) {
// 装配不了不是致命错误:退回"全部材料都算适用"(fail-open),
// 学生最多看到一个偏保守的进度条,而不是整页报错
log.warn("装配表单失败(进度页将按「所有材料都适用」计算):submission={}", s.getId(), e);
}
return values;
}
/** 当前生效的文件数(按材料码分组)。 */
private void collectFileCounts(CollectSubmission s, Map<String, Integer> out) {
var files = studentTaskMapper.listMyFiles(UserContext.tenantId(), s.getId());
for (var f : files) {
out.merge(f.getMaterialCode(), 1, Integer::sum);
}
}
/**
* 时间轴:创建 → 提交 → 初审 → 终审 → 驳回。
*
* <p>只写"确实发生过"的节点:没提交就不显示"已提交"。
* 编一条假时间线(比如把创建时间当提交时间)会让学生以为系统记错了。
*/
private List<StudentDtos.ProgressEvent> timelineOf(CollectSubmission s) {
List<StudentDtos.ProgressEvent> out = new ArrayList<>();
if (s.getCreateTime() != null) {
out.add(event(s.getCreateTime(), "创建草稿", null));
}
if (s.getSubmitTime() != null) {
out.add(event(s.getSubmitTime(), "已提交", "等待班主任初审"));
}
if (s.getClassAuditTime() != null) {
out.add(event(s.getClassAuditTime(), "班主任初审通过", "等待管理员终审"));
}
if (s.getAdminAuditTime() != null) {
out.add(event(s.getAdminAuditTime(), "审核通过", "本次认定材料已收妥"));
}
if (s.getStatus() != null && s.getStatus() == 4 && s.getLastRejectReason() != null) {
// 驳回没有单独的时间列(口径见 collect_submission 的注释:本轮审核状态在 *_audit_time,
// 历史在 collect_audit_record)。这里用"最后一次提交之后"来近似展示,
// 并如实说明这是驳回原因而不是凭空造一个时间。
out.add(event(LocalDateTime.now(), "被驳回", s.getLastRejectReason()));
}
out.sort((a, b) -> b.getTime().compareTo(a.getTime()));
return out;
}
private StudentDtos.ProgressEvent event(LocalDateTime t, String title, String desc) {
StudentDtos.ProgressEvent e = new StudentDtos.ProgressEvent();
e.setTime(t);
e.setTitle(title);
e.setDesc(desc);
return e;
}
/** 取"我的"填报单;不是他的一律当作不存在(不透露"它存在但不属于你")。 */
private CollectSubmission requireMine(Long id) {
CollectSubmission s = studentTaskMapper.selectMine(UserContext.tenantId(), id, UserContext.uid());
if (s == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你");
}
return s;
}
}
@@ -0,0 +1,71 @@
package cn.tianbaotong.fill.module.mp.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.mp.service.MpLoginService;
import cn.tianbaotong.fill.security.PublicApi;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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.RestController;
import java.util.Map;
/**
* 小程序端登录与手机号绑定(《03-接口设计》§5.3)。
*
* <h3>与 H5 授权登录的关系</h3>
*
* <p>两者换的是同一个 openid 体系,共同的部分(找/建用户、授学生角色)
* **复用** {@code MpH5AuthService.resolveUser} —— 不复制一份,
* 否则"建学生账号"会有两个实现,而它们迟早分叉。
* 小程序端多出来的只有两件事:静默换 openid(`jscode2session`)与拿手机号。
*/
@Tag(name = "小程序端登录")
@RestController
@RequestMapping("/api/v1/mp")
@RequiredArgsConstructor
public class MpLoginController {
private final MpLoginService loginService;
@PublicApi
@PostMapping("/login")
@Operation(summary = "小程序 code 换 session(§5.3)",
description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。"
+ "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。"
+ "scene 可穷举,所以这里**不返回任何业务数据**,只返回"
+ "「需不需要绑手机」与「你在这次任务里有没有绑定名册」")
public ApiResult<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> body) {
String code = str(body == null ? null : body.get("code"));
String scene = str(body == null ? null : body.get("scene"));
return ApiResult.ok(loginService.login(code, scene));
}
@PostMapping("/bind-phone")
@Operation(summary = "微信手机号快捷绑定(§5.3)",
description = "小程序调 `wx.getPhoneNumber` 拿到的是 **code**(不是手机号),"
+ "我们用 access_token 去微信换真正的手机号 —— "
+ "**手机号从来没经过客户端**,这也是它能作为绑定凭据的原因。"
+ "⚠️ 一个手机号只能绑一个账号:绑两个会让「按手机号找人」指向不确定的那一个")
public ApiResult<MpLoginService.BindPhoneVO> bindPhone(
@RequestBody(required = false) Map<String, Object> body) {
LoginUser me = UserContext.require();
String code = str(body == null ? null : body.get("code"));
if (code == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"缺少 code(wx.getPhoneNumber 返回的那个)");
}
return ApiResult.ok(loginService.bindPhone(me.uid(), me.tenantId(), code));
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
}
@@ -0,0 +1,40 @@
package cn.tianbaotong.fill.module.mp.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.mp.dto.TeacherDtos;
import cn.tianbaotong.fill.module.mp.service.TeacherDashboardService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 班主任端看板(《03-接口设计》§10.1)。
*
* <p>权限用 {@code task:view}(与"能不能看到这些班"一致)——
* 范围由 SQL 按"我负责的班"限定,权限码只管"他是不是老师这一类人"。
* 这两层缺一不可:只查权限,全校老师都能看到全校数字。
*/
@Tag(name = "班主任端 · 看板")
@RestController
@RequestMapping("/api/v1/mp/teacher")
@RequiredArgsConstructor
public class MpTeacherDashboardController {
private final TeacherDashboardService teacherDashboardService;
@GetMapping("/dashboard")
@RequirePerm(PermissionDef.TASK_VIEW)
@Operation(summary = "班主任看板",
description = "范围只算**我负责的班**(指定初审人 → 副班主任 → 班级负责人三级回退)。"
+ "⚠️ **submitRate 是已提交率,不是通过率**,与 approvedCount 分开呈现 —— "
+ "契约 §10.1 的口径铁律:这两件事要做的动作完全不同(催报 vs 看材料)")
public ApiResult<TeacherDtos.DashboardVO> dashboard(@RequestParam(required = false) Long taskId) {
return ApiResult.ok(teacherDashboardService.dashboard(taskId));
}
}
@@ -0,0 +1,137 @@
package cn.tianbaotong.fill.module.mp.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.service.MaterialFileService;
import cn.tianbaotong.fill.module.mp.dto.TeacherDtos;
import cn.tianbaotong.fill.module.mp.service.TeacherProxyService;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.security.PermissionDef;
import cn.tianbaotong.fill.security.RequirePerm;
import cn.tianbaotong.fill.support.WebUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 班主任端代传与文件访问(《03-接口设计》§10.6、§4.6)。
*
* <h3>为什么代传是 multipart 一步,而不是契约写的"先传文件再传 fileId"</h3>
*
* <p>契约 §10.6 的请求体是 {@code { submissionId, materialCode, fileId }},
* 也就是"班主任先走通用上传通道把文件传上来,拿到 fileId 再挂到学生名下"。
* 那条路有两个具体问题:
* <ol>
* <li><b>中间态会留下孤儿</b>:第一步与第二步之间断了(网络、手滑关页面),
* 文件已经在库里但没有归属,既不属于任何学生、也没人知道它是干什么的。
* 这类垃圾会一直堆在存储里,而且**没有任何界面能看到它们**。</li>
* <li><b>"通用上传通道"目前不存在</b>:学生上传接口({@code /mp/files/upload})
* 是按 {@code submitter_id} 授权的,班主任用它只能传到自己的名下 ——
* 要支持契约那条路,得先做一个"教师专用上传"接口,
* 而它本质上就是"上传但先不归属",等于把一个中间态变成正式概念。</li>
* </ol>
*
* <p>所以这里做成<b>一步 multipart</b>:{@code submissionId + materialCode + file + proxyReason}。
* 对外行为与契约一致(响应里有 {@code fileId} / {@code proxyBy} / {@code proxyName}),
* 只是不需要调用方先做一次无归属上传。这条差异记在 F-39。
*
* <h3>文件访问:为什么教师端要单独一个接口</h3>
*
* <p>学生端的 {@code /mp/files/{id}/preview} 按 {@code submitter_id} 授权,
* 班主任点开学生的材料必然 403。而班主任<b>必须</b>能看到自己班学生的材料
* (他要在手机上看清这张身份证拍清楚没有)。所以这里按<b>班级范围</b>授权,
* 与代传、催报、看板用同一套三级回退。
*/
@Tag(name = "班主任端 · 代传与文件")
@RestController
@RequestMapping("/api/v1/mp/teacher")
@RequiredArgsConstructor
public class MpTeacherProxyController {
private final TeacherProxyService proxyService;
private final MaterialFileService fileService;
@PostMapping(value = "/proxy-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePerm(PermissionDef.FILE_PROXY_UPLOAD)
@Operation(summary = "班主任代传材料",
description = "一步上传(不用先做一次无归属上传,见类注释)。"
+ "三道闸门:**只给自已负责的班**、**只给草稿状态的填报单**、"
+ "**必须留痕**(`source=2` + 代传人 + 原因 + 操作日志),且学生端可见。"
+ "规范化走与学生上传完全相同的那套 —— 否则代传来的材料会过不了导出前置检查")
public ApiResult<TeacherDtos.ProxyUploadVO> proxyUpload(
@RequestParam Long submissionId,
@RequestParam String materialCode,
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) String proxyReason,
HttpServletRequest req) throws IOException {
TeacherDtos.ProxyUploadVO vo = proxyService.proxyUpload(
submissionId, materialCode, file.getOriginalFilename(),
file.getBytes(), proxyReason, WebUtil.clientIp(req));
return ApiResult.ok(vo);
}
@GetMapping("/submissions/{id}/proxy-context")
@RequirePerm(PermissionDef.FILE_PROXY_UPLOAD)
@Operation(summary = "代传前的上下文(我要替谁传)",
description = "返回学生姓名/班级/可否代传。**界面上必须一直显示对象是谁** —— "
+ "老师一次帮几个学生传材料时,最容易出的错就是传给了上一个人")
public ApiResult<Map<String, Object>> proxyContext(@PathVariable Long id) {
CollectRoster r = proxyService.rosterOf(id);
Map<String, Object> out = new LinkedHashMap<>();
out.put("submissionId", id);
out.put("canProxy", proxyService.canProxy(id));
if (r != null) {
out.put("rosterId", r.getId());
out.put("realName", r.getRealName());
out.put("idCardMask", r.getIdCardMask());
out.put("orgId", r.getOrgId());
}
return ApiResult.ok(out);
}
/**
* 看学生的材料(预览 / 下载同一个人口)。
*
* <p>{@code original=true} 取原始件 —— 原始件**默认不保留**(客户决定只交付规范件),
* 所以拿不到时要明确说"这份材料没有留原件",而不是给一个空白图。
*/
@GetMapping("/files/{fileId}/preview")
@RequirePerm(PermissionDef.ROSTER_VIEW)
@Operation(summary = "班主任看学生的材料(按班级范围授权)",
description = "学生端那个接口按 `submitter_id` 授权,班主任点开必然 403;"
+ "这里按**班级范围**(与代传、催报、看板同一套三级回退)。"
+ "`original=true` 取原始件时注意:原始件默认不保留")
public ResponseEntity<byte[]> preview(@PathVariable Long fileId,
@RequestParam(defaultValue = "false") boolean original)
throws IOException {
CollectFile f = proxyService.requireMyStudentFile(fileId);
MaterialFileService.Content c = fileService.contentOf(f, original);
String encoded = URLEncoder.encode(c.name(), StandardCharsets.UTF_8).replace("+", "%20");
// 预览要能直接在浏览器/小程序里显示,所以 Content-Type 按扩展名给 ——
// 给 application/octet-stream 会让"点开身份证"变成"下载一个文件"
MediaType type = c.name().toLowerCase().endsWith(".png")
? MediaType.IMAGE_PNG : MediaType.IMAGE_JPEG;
return ResponseEntity.ok()
.contentType(type)
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded)
.body(c.bytes());
}
}
@@ -0,0 +1,159 @@
package cn.tianbaotong.fill.module.mp.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-接口设计》§10.1、§10.6)。
*/
public final class TeacherDtos {
private TeacherDtos() {
}
// ── §10.1 看板 ────────────────────────────────
/** 总览数字。 */
@Getter
@Setter
@Schema(description = "班级看板总览")
public static class Summary {
/** 我负责的班里的名册总人数 */
private int rosterTotal;
/** 已提交(含在审与已通过) */
private int submittedCount;
/** 终审通过 */
private int approvedCount;
/** 待我初审 */
private int pendingCount;
/** 还没交 */
private int unsubmittedCount;
/** 有红/黄问题的 */
private int abnormalCount;
/**
* ⚠️ <b>已提交率</b>({@code submittedCount / rosterTotal}),
* <b>不是</b>通过率。
*
* <p>契约 §10.1 的口径铁律:{@code submitRate} 与 {@code approvedCount}
* <b>必须分开呈现</b>(原型定稿如此)。合并成一个数字会让"我班上还有人没交"
* 和"我班上有人没过"变成同一件事,而这两件事要做的动作完全不同:
* 前者去催报,后者去看材料。
*/
private String submitRate;
}
/** 我负责的一个班。 */
@Getter
@Setter
public static class OrgStat {
private Long orgId;
private String orgName;
private int rosterTotal;
private int submittedCount;
private int approvedCount;
private String submitRate;
/** 我是这个班的**主**班主任(false = 副班主任或审核员) */
private Boolean isHead;
/** 待我初审的单数(卡片上的角标) */
private int auditCount;
}
/** 待办角标。 */
@Getter
@Setter
public static class Todos {
/** 待我初审 */
private int auditCount;
/** 有红黄问题待处理 */
private int abnormalCount;
/** 被驳回过、等学生改的(老师可以据此催一下) */
private int rejectedAwaitingCount;
}
/** 最近截止的任务。 */
@Getter
@Setter
public static class Deadline {
private Long taskId;
private String taskName;
private LocalDateTime endTime;
/** 距截止还有几天(负数 = 已过) */
private Integer remainDays;
}
/** 看板。 */
@Getter
@Setter
@Schema(description = "班主任看板")
public static class DashboardVO {
private Summary summary = new Summary();
private List<OrgStat> myOrgs = new ArrayList<>();
private Todos todos = new Todos();
private Deadline deadline;
}
// ── §10.6 代传 ────────────────────────────────
/** 代传请求。 */
@Getter
@Setter
@Schema(description = "班主任代传材料")
public static class ProxyUploadReq {
private Long submissionId;
private String materialCode;
/** 已上传的文件 id(班主任先走学生端上传通道把文件传上来,再挂到这个学生名下) */
private Long fileId;
/** 代传原因(选填,但强烈建议写 —— 时间轴上会显示) */
private String proxyReason;
}
/** 代传结果。 */
@Getter
@Setter
public static class ProxyUploadVO {
private Long fileId;
private Long proxyBy;
private String proxyByName;
/** 时间轴与材料列表上都会显示这句 */
private String sourceText;
}
}
@@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** /**
* H5 网页授权的用户查询/创建。 * H5 网页授权的用户查询/创建。
@@ -87,4 +88,41 @@ public interface MpH5Mapper {
int insertUserRole(@Param("tenantId") Long tenantId, int insertUserRole(@Param("tenantId") Long tenantId,
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("roleId") Long roleId); @Param("roleId") Long roleId);
/**
* 按手机号 hash 找用户(**无租户上下文**时用:小程序登录/绑号时还没有登录态)。
*
* <p>为什么按 hash 而不是明文:`sys_user` 里手机号存的是密文 +
* `phone_hash`(查询走它),与身份证号同一套做法。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM sys_user
WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0
LIMIT 1
""")
SysUser selectByPhoneHash(@Param("tenantId") Long tenantId,
@Param("phoneHash") String phoneHash);
/** 按 id 取用户(**无租户上下文**时用)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM sys_user WHERE id = #{id} AND deleted = 0
""")
SysUser selectByIdIgnoreTenant(@Param("id") Long id);
/**
* 写手机号的三列(明文 + hash + 原样存一份用于展示?不 —— 只写明文与 hash)。
*
* <p>⚠️ 与 `SensitiveCodec.applyPhone` 同口径:`phone` 存**明文**(客户要求能直接看到,
* 见该类的注释)、`phone_hash` 用于查询。两处必须一致,所以这里也写这两列 + `update_time`。
*/
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE sys_user
SET phone = #{phone}, phone_hash = #{phoneHash}, update_time = NOW()
WHERE id = #{id} AND deleted = 0
""")
int updatePhone(@Param("id") Long id, @Param("phone") String phone,
@Param("phoneHash") String phoneHash);
} }
@@ -0,0 +1,225 @@
package cn.tianbaotong.fill.module.mp.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
/**
* 班主任看板的只读查询(《03-接口设计》§10.1)。
*
* <p>口径是这一屏最要紧的事,两条都写在 SQL 里而不是靠调用方记得:
*
* <ol>
* <li><b>只统计"我负责的班"</b>:范围条件与初审完全一致
* (指定初审人 → 副班主任 → 班级负责人三级回退)。
* 少一个条件,班主任就会看到全校数字,而他会以为那是自己班的。</li>
* <li><b>{@code submitted} 与 {@code approved} 分开数</b>:
* 契约 §10.1 的口径铁律 —— 合并就会让"有人没交"和"有人没过"
* 变成同一个数字,而这两件事要做的动作完全不同。</li>
* </ol>
*/
public interface TeacherDashboardMapper {
/** 一个班的统计。 */
class OrgStatRow {
private Long orgId;
private String orgName;
private Integer rosterTotal;
private Integer submittedCount;
private Integer approvedCount;
private Integer abnormalCount;
/** 这个班的初审人是不是我(false = 我是副班主任或靠班级负责人回退) */
private Boolean isHead;
public Long getOrgId() { return orgId; }
public void setOrgId(Long v) { this.orgId = v; }
public String getOrgName() { return orgName; }
public void setOrgName(String v) { this.orgName = v; }
public Integer getRosterTotal() { return rosterTotal; }
public void setRosterTotal(Integer v) { this.rosterTotal = v; }
public Integer getSubmittedCount() { return submittedCount; }
public void setSubmittedCount(Integer v) { this.submittedCount = v; }
public Integer getApprovedCount() { return approvedCount; }
public void setApprovedCount(Integer v) { this.approvedCount = v; }
public Integer getAbnormalCount() { return abnormalCount; }
public void setAbnormalCount(Integer v) { this.abnormalCount = v; }
public Boolean getIsHead() { return isHead; }
public void setIsHead(Boolean v) { this.isHead = v; }
}
/**
* 我负责的每个班的统计(人数 / 已提交 / 已通过 / 有异常 / 我是不是主班主任)。
*
* <p>{@code taskId} 为空时按<b>所有任务</b>汇总 —— 首页要的是"我现在的整体情况",
* 而不是某一个任务的。这时班级可能重复出现(同一个班在两个任务里都有),
* 所以用 {@code SUM} 聚合而不是逐任务分行。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT o.id AS orgId,
o.org_name AS orgName,
COUNT(r.id) AS rosterTotal,
COUNT(s.id) FILTER (WHERE s.id IS NOT NULL) AS submittedCount,
COUNT(s.id) FILTER (WHERE s.status = 3) AS approvedCount,
COUNT(s.id) FILTER (WHERE s.red_count > 0 OR s.yellow_count > 0) AS abnormalCount,
BOOL_OR(cto.head_user_id = #{userId}) AS isHead
FROM collect_task_org cto
JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
LEFT JOIN collect_task t
ON t.id = cto.task_id AND t.deleted = 0 AND t.tenant_id = cto.tenant_id
LEFT JOIN collect_roster r
ON r.task_id = cto.task_id AND r.org_id = cto.org_id AND r.deleted = 0
LEFT JOIN collect_submission s
ON s.roster_id = r.id AND s.deleted = 0
WHERE cto.tenant_id = #{tenantId}
AND cto.status = 1
AND (t.status IS NULL OR t.status &lt;&gt; 3)
<if test="taskId != null">
AND cto.task_id = #{taskId}
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
GROUP BY o.id, o.org_name
ORDER BY o.org_name
</script>
""")
List<OrgStatRow> myOrgStats(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId);
/** 待我初审的单数(与审核台同一范围口径)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM collect_submission s
JOIN collect_task_org cto
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
LEFT JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
WHERE s.tenant_id = #{tenantId}
AND s.deleted = 0
AND s.status = 1
<if test="taskId != null">
AND s.task_id = #{taskId}
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
""")
int countPendingAudit(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId);
/** 被驳回过、还在等学生改的单数(老师据此去催一下)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM collect_submission s
JOIN collect_task_org cto
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
LEFT JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
WHERE s.tenant_id = #{tenantId}
AND s.deleted = 0
AND s.status = 4
<if test="taskId != null">
AND s.task_id = #{taskId}
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
""")
int countRejectedAwaiting(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId);
/**
* 某一个班的待审数(看板卡片角标)。
*
* <p>与 {@link #countPendingAudit} 同一范围口径,只是多一个 {@code org_id} 条件 ——
* 分开写而不是"取全部再在内存里筛",因为角标是**每张卡片都要**的,
* 拉全量再筛在几十个班时会变成明显的浪费。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
<script>
SELECT COUNT(*)
FROM collect_submission s
JOIN collect_task_org cto
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
LEFT JOIN sys_org o
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
WHERE s.tenant_id = #{tenantId}
AND s.deleted = 0
AND s.status = 1
AND s.org_id = #{orgId}
<if test="taskId != null">
AND s.task_id = #{taskId}
</if>
AND (
cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})
)
</script>
""")
int countPendingOfOrg(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId,
@Param("orgId") Long orgId);
/** 最近的截止时间:只看我还负责的、还在收集或暂停中的任务。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT t.id AS taskId, t.task_name AS taskName, t.end_time AS endTime
FROM collect_task t
WHERE t.tenant_id = #{tenantId} AND t.deleted = 0
AND t.status IN (1, 4)
AND t.end_time IS NOT NULL
AND EXISTS (
SELECT 1 FROM collect_task_org cto
LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0
WHERE cto.task_id = t.id AND cto.status = 1
AND (cto.head_user_id = #{userId}
OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%')
OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId}))
)
ORDER BY t.end_time ASC
LIMIT 1
""")
List<DeadlineRow> nearestDeadline(@Param("tenantId") Long tenantId,
@Param("userId") Long userId);
/** 最近截止的任务。 */
class DeadlineRow {
private Long taskId;
private String taskName;
private LocalDateTime endTime;
public Long getTaskId() { return taskId; }
public void setTaskId(Long v) { this.taskId = v; }
public String getTaskName() { return taskName; }
public void setTaskName(String v) { this.taskName = v; }
public LocalDateTime getEndTime() { return endTime; }
public void setEndTime(LocalDateTime v) { this.endTime = v; }
}
}
@@ -0,0 +1,315 @@
package cn.tianbaotong.fill.module.mp.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.config.FillProperties;
import cn.tianbaotong.fill.module.mp.mapper.MpH5Mapper;
import cn.tianbaotong.fill.module.qrcode.service.WxMiniProgramService;
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.security.JwtService;
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.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 小程序端登录与手机号绑定(《03-接口设计》§5.3)。
*
* <h3>它与 H5 授权登录的区别(不是"另一个入口")</h3>
*
* <p>两者换的是**同一个 openid 体系**,但有两点不同:
* <ul>
* <li>小程序用 {@code jscode2session}(不需要用户点"允许",静默拿 openid),
* H5 用网页授权(要点同意,还要配回调域名);</li>
* <li>小程序能**直接拿手机号**({@code getPhoneNumber} 的 code 换 phone),
* H5 不能 —— 所以手机号绑定只在小程序端有。</li>
* </ul>
* 共同的部分(找/建用户、授学生角色)**复用** {@code MpH5AuthService.resolveUser} ——
* 不复制一份,否则"建学生账号"这件事会有两个实现,而它们迟早分叉
* (例如一边建了内置角色、另一边忘了)。
*
* <h3>⚠️ 租户从哪来</h3>
*
* <p>契约的请求体里只有 {@code code} 与 {@code scene},而 scene **可穷举**
* (`t{tenantId}-k{taskId}`)。所以:"用户属于哪个租户"必须从 scene 来,
* 而 scene 缺失时**明确报错**而不是"默认第一个租户" ——
* 后者会让 A 校的学生登进 B 校(而这在日志里看起来一切正常)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MpLoginService {
private static final String JSCODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session";
private static final String GET_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
private final FillProperties props;
private final WxMiniProgramService wxService;
private final MpH5AuthService h5AuthService;
private final MpH5Mapper h5Mapper;
private final CollectRosterMapper rosterMapper;
private final TaskMapper taskMapper;
private final JwtService jwtService;
private final SensitiveCodec codec;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
// ── §5.3 登录 ─────────────────────────────────
@Transactional(rollbackFor = Exception.class)
public LoginVO login(String code, String scene) {
if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code");
}
FillProperties.MiniProgram mp = props.miniProgram();
if (mp == null || mp.appId() == null || mp.appId().isBlank()) {
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
"没配置小程序(fill.mini-program.app-id),小程序登录暂不可用。"
+ "开发时可以用 H5 的打桩登录 /mp/h5/stub-login");
}
Scene s = Scene.parse(scene);
String openId = code2Session(code);
SysUser user = h5AuthService.resolveUser(s.tenantId(), openId);
LoginVO vo = new LoginVO();
// 与 H5 登录同一形状(同一个 JwtService.Client.MP,有效期 7 天)——
// 两条登录路径签出来的令牌必须完全同构,否则"小程序登录的用户"
// 与"H5 登录的用户"在后续接口里的表现会不一样
vo.setToken(jwtService.issue(
new cn.tianbaotong.fill.common.context.LoginUser(
user.getId(), user.getTenantId(), List.of(),
java.util.Set.of("STUDENT"), 4),
cn.tianbaotong.fill.security.JwtService.Client.MP));
vo.setExpireSeconds(604800);
// 手机号还没绑 → 前端要弹"一键绑定手机号"(契约的 needBindPhone)
vo.setNeedBindPhone(user.getPhone() == null || user.getPhone().isBlank());
Map<String, Object> u = new LinkedHashMap<>();
u.put("id", user.getId());
u.put("realName", user.getRealName());
u.put("userType", user.getUserType());
vo.setUser(u);
// 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 ——
// 前端据此决定进"填报页"还是"先核验身份",省掉一次请求
if (s.taskId() != null) {
CollectRoster r = rosterMapper.selectOne(Wrappers.<CollectRoster>lambdaQuery()
.eq(CollectRoster::getTaskId, s.taskId())
.eq(CollectRoster::getBindUserId, user.getId())
.last("limit 1"));
if (r != null) {
CollectTask t = taskMapper.selectById(s.taskId());
Map<String, Object> br = new LinkedHashMap<>();
br.put("taskId", s.taskId());
br.put("taskName", t == null ? null : t.getTaskName());
br.put("rosterId", r.getId());
vo.setBoundRoster(br);
}
}
log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}",
s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone());
return vo;
}
/** `jscode2session`:code 换 openid。 */
private String code2Session(String code) {
FillProperties.MiniProgram mp = props.miniProgram();
String url = JSCODE2SESSION
+ "?appid=" + mp.appId()
+ "&secret=" + mp.appSecret()
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
JSONObject r = getJson(url);
String openId = r.getStr("openid");
if (openId == null) {
// 微信的错误码要原样报出来(40013 是 appid 不对、40125 是 secret 不对、
// 40029 是 code 无效/已用过)。只说"登录失败"会让对接人完全没法排查
throw new BizException(ErrorCode.UNAUTHORIZED,
"微信登录失败(errcode=" + r.getInt("errcode") + "):" + r.getStr("errmsg")
+ "。常见原因:40029 code 已被用过(一次性的)、"
+ "40013 appid 不对、40125 appSecret 不对");
}
return openId;
}
// ── §5.3 绑手机号 ─────────────────────────────
/**
* 微信手机号快捷绑定。
*
* <p>流程:小程序调 {@code wx.getPhoneNumber} 拿到一个 **code**(不是手机号本身)→
* 传给我们 → 我们用 access_token 去微信换真正的手机号。
* 所以**手机号从来没经过客户端**,这也是它能作为绑定凭据的原因。
*
* <p>⚠️ 绑定时要检查"这个手机号是否已被同租户的另一个账号绑定":
* 一个手机号绑两个学生会让后续"按手机号找人"(找回密码、短信通知)
* 指向不确定的那一个,而那时已经看不出是谁绑错了。
*/
@Transactional(rollbackFor = Exception.class)
public BindPhoneVO bindPhone(Long userId, Long tenantId, String code) {
if (code == null || code.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code(wx.getPhoneNumber 返回的)");
}
if (!wxService.configured()) {
throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED,
"没配置小程序,无法解出手机号");
}
String accessToken = wxService.accessToken();
JSONObject r = postJson(GET_PHONE + "?access_token=" + accessToken,
JSONUtil.createObj().set("code", code).toString());
if (r.getInt("errcode") == null || r.getInt("errcode") != 0) {
throw new BizException(ErrorCode.UNAUTHORIZED,
"换取手机号失败(errcode=" + r.getInt("errcode") + "):" + r.getStr("errmsg")
+ "。常见原因:code 已被用过(一次性的)、或用户拒绝授权");
}
String phone = r.getJSONObject("phone_info") == null
? null : r.getJSONObject("phone_info").getStr("purePhoneNumber");
if (phone == null || phone.isBlank()) {
throw new BizException(ErrorCode.UNAUTHORIZED, "微信没有返回手机号");
}
String hash = codec.hash(phone);
SysUser other = h5Mapper.selectByPhoneHash(tenantId, hash);
if (other != null && !other.getId().equals(userId)) {
// 只说"已被绑定",不说是谁绑的 —— 那是别人的账号信息
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这个手机号已经绑定过另一个账号了。请用原账号登录,或联系管理员处理");
}
SysUser me = h5Mapper.selectByIdIgnoreTenant(userId);
if (me == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "用户不存在");
}
// 三列一起写(密文 + hash + 明文手机号),与其它入口同口径
h5Mapper.updatePhone(userId, phone, hash);
BindPhoneVO vo = new BindPhoneVO();
vo.setBound(true);
vo.setPhone(maskPhone(phone));
log.info("小程序绑定手机号:tenant={} uid={} phone={}", tenantId, userId, maskPhone(phone));
return vo;
}
private static String maskPhone(String phone) {
return phone.length() < 8 ? "***"
: phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
// ── HTTP ──────────────────────────────────────
private JSONObject getJson(String url) {
try {
HttpResponse<String> resp = http.send(HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10)).GET().build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return JSONUtil.parseObj(resp.body());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new BizException(ErrorCode.INTERNAL_ERROR, "调用微信接口失败:" + e.getMessage());
}
}
private JSONObject postJson(String url, String body) {
try {
HttpResponse<String> resp = http.send(HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return JSONUtil.parseObj(resp.body());
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new BizException(ErrorCode.INTERNAL_ERROR, "调用微信接口失败:" + e.getMessage());
}
}
// ── scene 解析 ────────────────────────────────
/**
* scene 三段式:{@code t{tenantId}} / {@code t{tenantId}-k{taskId}} /
* {@code t{tenantId}-k{taskId}-o{orgId}}(微信限 32 字符)。
*
* <p>与扫码落地页同一套格式 —— 格式定义只有一处,否则"码里的 scene"与
* "登录时解析的 scene"会各有一套理解。
*/
private record Scene(Long tenantId, Long taskId, Long orgId) {
static Scene parse(String scene) {
if (scene == null || scene.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"缺少 scene:它决定你属于哪个学校(统一应用 + scene 识别租户),"
+ "请从学校/班主任发的二维码进入");
}
Long tenantId = null;
Long taskId = null;
Long orgId = null;
for (String part : scene.trim().split("-")) {
if (part.startsWith("t")) {
tenantId = num(part.substring(1));
} else if (part.startsWith("k")) {
taskId = num(part.substring(1));
} else if (part.startsWith("o")) {
orgId = num(part.substring(1));
}
}
if (tenantId == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"scene 里没有租户标识(应为 t{tenantId} 开头):" + scene);
}
return new Scene(tenantId, taskId, orgId);
}
private static Long num(String s) {
try {
return Long.valueOf(s);
} catch (NumberFormatException e) {
return null;
}
}
}
// ── 响应 ──
@lombok.Getter
@lombok.Setter
public static class LoginVO {
private String token;
private Integer expireSeconds;
private Boolean needBindPhone;
private Map<String, Object> user;
private Map<String, Object> boundRoster;
}
@lombok.Getter
@lombok.Setter
public static class BindPhoneVO {
private Boolean bound;
private String phone;
}
}
@@ -47,14 +47,37 @@ import java.util.Map;
@Service @Service
public class MpRosterService { public class MpRosterService {
/** 同 openid(这里用 uid,用户与 openid 一对一)每小时最多 10 次。 */ /**
private static final int USER_LIMIT_PER_HOUR = 10; * 同一个人每小时最多**失败** 10 次。
*
* <p>⚠️ 计的是**失败**,不是请求数。正常学生最多填错一两次,10 次绰绰有余;
* 而"把成功也算进来"会让同一个学生重试几次之后就进不去(见下面 IP 的说明)。
*/
private static final int USER_FAIL_LIMIT_PER_HOUR = 10;
/** 同 IP 每分钟最多 20 次。 */ /**
private static final int IP_LIMIT_PER_MINUTE = 20; * 同一个 IP 每分钟最多**失败** 20 次(防枚举)。
*
* <p>⚠️ **只计失败**这件事是压测改出来的:教室里 60 个学生连同一个 WiFi
* (出口是同一个公网 IP),如果成功也计数,**后 40 个会被限流** ——
* 而那是首次落地第一分钟就会发生的事故。
*
* <p>为什么只计失败就够:**枚举攻击产生的是失败,教室产生的是成功**。
*/
private static final int IP_FAIL_LIMIT_PER_MINUTE = 20;
/**
* 同一个 IP 每分钟最多**成功** 200 次。
*
* <p>成功会返回该学生的姓名与预填数据,所以要防"拿这个接口批量取数据"。
* 但限额必须给教室规模留足量:**一个 200 人的年级同时扫码也不该被挡**。
*/
private static final int IP_OK_LIMIT_PER_MINUTE = 200;
private static final String KEY_USER = "xt:mp:gate:user:"; private static final String KEY_USER = "xt:mp:gate:user:";
private static final String KEY_IP = "xt:mp:gate:ip:"; private static final String KEY_IP = "xt:mp:gate:ip:";
/** 成功侧单独一个键 —— 与失败分开,两者的额度互不占用。 */
private static final String KEY_IP_OK = "xt:mp:gate:ipok:";
private final MpRosterMapper rosterMapper; private final MpRosterMapper rosterMapper;
private final StringRedisTemplate redis; private final StringRedisTemplate redis;
@@ -74,7 +97,7 @@ public class MpRosterService {
Long tenantId = me.tenantId(); Long tenantId = me.tenantId();
Long userId = me.uid(); Long userId = me.uid();
rateLimit(userId, clientIp); rateLimitBefore(userId, clientIp);
String name = IdentityNormalizer.name(req.getRealName()); String name = IdentityNormalizer.name(req.getRealName());
String idCard = IdentityNormalizer.idCard(req.getIdCardNo()); String idCard = IdentityNormalizer.idCard(req.getIdCardNo());
@@ -134,15 +157,25 @@ public class MpRosterService {
} }
// 已绑定且就是自己 → 幂等返回成功(学生刷新页面不该看到吓人的提示) // 已绑定且就是自己 → 幂等返回成功(学生刷新页面不该看到吓人的提示)
recordOk(clientIp);
return buildVo(row, tenantId); return buildVo(row, tenantId);
} }
// ── 内部 ────────────────────────────────────── // ── 内部 ──────────────────────────────────────
/** 统一失败提示:不区分是姓名错还是号码错。 */ /**
* 统一失败提示:不区分是姓名错还是号码错。
*
* <p>⚠️ **这里也是唯一计"失败次数"的出口**(防猜 / 防枚举的额度就靠它)。
* 另外两条失败路径(`GATE_ALREADY_BOUND`、名册重复)**刻意不计**:
* 它们不是攻击特征 —— 前者是"两个人抢同一个名册项"(开学第一天几十人同时扫码时
* 必然出现几例),后者是运维问题。把它们算进防枚举额度里,
* 会让整间教室在几秒内被限流,而**没有一个人做错了什么**。
*/
private BizException unifiedFail(Long userId, String ip, Long tenantId, Long taskId, String why) { private BizException unifiedFail(Long userId, String ip, Long tenantId, Long taskId, String why) {
log.warn("门禁未通过:user={} ip={} tenant={} task={} 原因={}(不记明文)", log.warn("门禁未通过:user={} ip={} tenant={} task={} 原因={}(不记明文)",
userId, ip, tenantId, taskId, why); userId, ip, tenantId, taskId, why);
recordFail(userId, ip);
return new BizException(ErrorCode.GATE_NOT_MATCHED); return new BizException(ErrorCode.GATE_NOT_MATCHED);
} }
@@ -154,26 +187,79 @@ public class MpRosterService {
*/ */
/** /**
* 限流:同 openid 10 次/小时、同 IP 20 次/分钟。 * 限流(⚠️ 计的是**失败**,不是请求数 —— 见 {@link #rateLimitBefore})。
*
* <p>同一个人 10 次失败/小时、同一个 IP 20 次失败/分钟、同一 IP 200 次成功/分钟。
* 分开的理由:**枚举攻击产生失败,教室产生成功**。
* *
* <p>阈值调低是刻意的 —— 门禁限流是<b>安全设计的一部分</b>(防身份证号枚举探测), * <p>阈值调低是刻意的 —— 门禁限流是<b>安全设计的一部分</b>(防身份证号枚举探测),
* 不是防 DDoS。正常学生最多填错一两次,10 次绰绰有余。 * 不是防 DDoS。正常学生最多填错一两次,10 次绰绰有余。
*/ */
private void rateLimit(Long userId, String ip) { /**
incrementOrReject(KEY_USER + userId, Duration.ofHours(1), USER_LIMIT_PER_HOUR); * 进门之前先看"是不是已经试太多次了"。
if (ip != null && !ip.isBlank()) { *
incrementOrReject(KEY_IP + ip, Duration.ofMinutes(1), IP_LIMIT_PER_MINUTE); * <p>先查后记(而不是先记后查):这样第 N 次请求看到的是前 N-1 次的计数,
* 判定简单、也不会因为并发把额度用超一点点。
*/
private void rateLimitBefore(Long userId, String ip) {
if (count(KEY_USER + userId) >= USER_FAIL_LIMIT_PER_HOUR) {
throw new BizException(ErrorCode.GATE_RATE_LIMITED,
"这个账号试得太多次了,请 " + waitMinutes(KEY_USER + userId)
+ " 分钟后再试,或联系班主任核对姓名与证件号");
}
if (ip == null || ip.isBlank()) {
return;
}
if (count(KEY_IP + ip) >= IP_FAIL_LIMIT_PER_MINUTE) {
// 这句话要能解释"为什么别人能进我不能" —— 教室里绝大部分人没做错任何事,
// 只是同一个 WiFi 下有人多试了几次
throw new BizException(ErrorCode.GATE_RATE_LIMITED,
"当前网络下尝试核验的人较多,请稍等 1 分钟再试");
}
if (count(KEY_IP_OK + ip) >= IP_OK_LIMIT_PER_MINUTE) {
throw new BizException(ErrorCode.GATE_RATE_LIMITED,
"当前网络访问过于集中,请稍等 1 分钟再试");
} }
} }
private void incrementOrReject(String key, Duration ttl, int limit) { /** 核验**失败**时调用(防猜 / 防枚举都靠它)。 */
private void recordFail(Long userId, String ip) {
increment(KEY_USER + userId, Duration.ofHours(1));
if (ip != null && !ip.isBlank()) {
increment(KEY_IP + ip, Duration.ofMinutes(1));
}
}
/** 核验**成功**时调用(只影响成功侧的兜底额度,不占用失败额度)。 */
private void recordOk(String ip) {
if (ip != null && !ip.isBlank()) {
increment(KEY_IP_OK + ip, Duration.ofMinutes(1));
}
}
private long count(String key) {
String v = redis.opsForValue().get(key);
if (v == null) {
return 0L;
}
try {
return Long.parseLong(v);
} catch (NumberFormatException e) {
return 0L;
}
}
/** 还要等几分钟(给用户一句能照着做的话,而不是"稍后再试")。 */
private long waitMinutes(String key) {
Long ttl = redis.getExpire(key, java.util.concurrent.TimeUnit.MINUTES);
return ttl == null || ttl <= 0 ? 1 : ttl;
}
private void increment(String key, Duration ttl) {
Long count = redis.opsForValue().increment(key); Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) { if (count != null && count == 1L) {
redis.expire(key, ttl); redis.expire(key, ttl);
} }
if (count != null && count > limit) {
throw new BizException(ErrorCode.GATE_RATE_LIMITED);
}
} }
private RosterMatchVO buildVo(CollectRoster row, Long tenantId) { private RosterMatchVO buildVo(CollectRoster row, Long tenantId) {
@@ -0,0 +1,124 @@
package cn.tianbaotong.fill.module.mp.service;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.module.mp.dto.TeacherDtos;
import cn.tianbaotong.fill.module.mp.mapper.TeacherDashboardMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 班主任看板(《03-接口设计》§10.1)。
*
* <h3>这一屏只有一条铁律,但它最容易被"优化"掉</h3>
*
* <p>{@code submitRate} 是<b>已提交率</b>(已提交 / 应填),与 {@code approvedCount}
* <b>分开呈现</b>。契约 §10.1 写得很直白:原型定稿如此,接口不许合并。
*
* <p>为什么不能合并成一个"完成率":<b>这两件事要做的动作完全不同</b> ——
* 「还有人没交」要去催报,「有人没过」要去看材料。
* 合成一个数字之后,老师看到 60% 不知道该催人还是该审材料,
* 而这个数字又会被当成"我的工作进度",于是两边都没做。
*
* <p>另一个细节:<b>范围只算"我负责的班"</b>。范围口径与初审完全一致
* (见 {@code TeacherDashboardMapper} 的 SQL),
* 少一个条件就会变成全校数字,而老师会以为那是自己班的。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TeacherDashboardService {
private final TeacherDashboardMapper mapper;
/** 看板。{@code taskId} 为空时按所有在办任务汇总。 */
public TeacherDtos.DashboardVO dashboard(Long taskId) {
Long tenantId = UserContext.tenantId();
Long uid = UserContext.uid();
List<TeacherDashboardMapper.OrgStatRow> rows = mapper.myOrgStats(tenantId, taskId, uid);
TeacherDtos.DashboardVO vo = new TeacherDtos.DashboardVO();
TeacherDtos.Summary sum = vo.getSummary();
List<TeacherDtos.OrgStat> orgs = new ArrayList<>();
int rosterTotal = 0;
int submitted = 0;
int approved = 0;
int abnormal = 0;
for (TeacherDashboardMapper.OrgStatRow r : rows) {
TeacherDtos.OrgStat o = new TeacherDtos.OrgStat();
o.setOrgId(r.getOrgId());
o.setOrgName(r.getOrgName());
int total = nz(r.getRosterTotal());
int sub = nz(r.getSubmittedCount());
int app = nz(r.getApprovedCount());
o.setRosterTotal(total);
o.setSubmittedCount(sub);
o.setApprovedCount(app);
o.setSubmitRate(rate(sub, total));
o.setIsHead(Boolean.TRUE.equals(r.getIsHead()));
// 这个班的待审数单独取一次(卡片角标):聚合 SQL 里没有这一列,
// 而"取全量待办再在内存里筛"在几十个班时是明显浪费
o.setAuditCount(mapper.countPendingOfOrg(tenantId, taskId, uid, r.getOrgId()));
rosterTotal += total;
submitted += sub;
approved += app;
abnormal += nz(r.getAbnormalCount());
orgs.add(o);
}
sum.setRosterTotal(rosterTotal);
sum.setSubmittedCount(submitted);
sum.setApprovedCount(approved);
sum.setAbnormalCount(abnormal);
sum.setUnsubmittedCount(Math.max(0, rosterTotal - submitted));
// ⚠️ 已提交率,不是通过率 —— 见类注释
sum.setSubmitRate(rate(submitted, rosterTotal));
vo.setMyOrgs(orgs);
TeacherDtos.Todos todos = vo.getTodos();
todos.setAuditCount(mapper.countPendingAudit(tenantId, taskId, uid));
todos.setAbnormalCount(abnormal);
todos.setRejectedAwaitingCount(mapper.countRejectedAwaiting(tenantId, taskId, uid));
List<TeacherDashboardMapper.DeadlineRow> dl = mapper.nearestDeadline(tenantId, uid);
if (!dl.isEmpty()) {
TeacherDashboardMapper.DeadlineRow d = dl.get(0);
TeacherDtos.Deadline deadline = new TeacherDtos.Deadline();
deadline.setTaskId(d.getTaskId());
deadline.setTaskName(d.getTaskName());
deadline.setEndTime(d.getEndTime());
deadline.setRemainDays(d.getEndTime() == null ? null
: (int) Duration.between(LocalDateTime.now(), d.getEndTime()).toDays());
vo.setDeadline(deadline);
}
return vo;
}
private int nz(Integer v) {
return v == null ? 0 : v;
}
/**
* 百分比(保留 1 位)。
*
* <p>分母为 0 时返回 {@code "—"} 而不是 {@code "0%"}:
* "一个班 0 人"与"一个班没人交"是完全不同的两件事,
* 而 0% 会让人以为后者。
*/
private String rate(int part, int total) {
if (total <= 0) {
return "—";
}
double v = Math.round(1000.0 * part / total) / 10.0;
return v + "%";
}
}
@@ -0,0 +1,253 @@
package cn.tianbaotong.fill.module.mp.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.audit.mapper.AuditScopeMapper;
import cn.tianbaotong.fill.module.file.entity.CollectFile;
import cn.tianbaotong.fill.module.file.mapper.FileMapper;
import cn.tianbaotong.fill.module.file.service.MaterialFileService;
import cn.tianbaotong.fill.module.log.service.OperationLogService;
import cn.tianbaotong.fill.module.mp.dto.TeacherDtos;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper;
import cn.tianbaotong.fill.module.fill.entity.CollectSubmission;
import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper;
import cn.tianbaotong.fill.module.user.entity.SysUser;
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
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.util.List;
/**
* 班主任代传材料(《03-接口设计》§10.6)。
*
* <h3>为什么要有这个功能</h3>
*
* <p>真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。
* 没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" ——
* 而学生之所以要老师帮忙,往往就是因为他弄不了。
*
* <h3>三道必须守住的闸门</h3>
*
* <ol>
* <li><b>范围</b>:只能给<b>自己负责的班</b>的学生代传。
* 不校验的话,任何老师能给任何学生塞材料 —— 而那等于伪造认定材料。</li>
* <li><b>状态</b>:只能给<b>草稿</b>状态的填报单代传。
* 已提交的单子塞材料会让"审到一半的材料清单"变了,
* 而审核人看的是他点开那一刻的内容。</li>
* <li><b>留痕</b>:{@code source = 2}(班主任代传)+ {@code proxy_by} + {@code proxy_reason}
* + 操作日志(契约 §10.6 明确要求"代传必须留痕",且<b>学生端可见</b>)。
* 学生必须能知道"这份不是我传的,是李老师帮我传的" ——
* 否则他会在审核驳回时一头雾水("我什么时候传过这张照片?")。</li>
* </ol>
*
* <h3>为什么复用 {@code MaterialFileService.storeFile}</h3>
*
* <p>格式白名单、体积压缩、按模板改名、份数上限这些规则**必须与"学生自己传"完全一致** ——
* 代传来的材料如果绕过规范化,导出时就会出现不符合命名规范的文件,
* 而那正是导出前置检查(40035)要拦的东西。
* 所以这里只负责"授权 + 留痕",规范化那一段走同一个方法。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TeacherProxyService {
private final SubmissionMapper submissionMapper;
private final CollectRosterMapper rosterMapper;
private final AuditScopeMapper auditScopeMapper;
private final MaterialFileService materialFileService;
private final FileMapper fileMapper;
private final UserMapper userMapper;
private final OperationLogService operationLog;
/**
* 班主任代传一份材料。
*
* @param proxyReason 代传原因(选填;会挂到审核时间轴上,学生也看得到)
*/
@Transactional(rollbackFor = Exception.class)
public TeacherDtos.ProxyUploadVO proxyUpload(Long submissionId, String materialCode,
String originalName, byte[] bytes,
String proxyReason, String clientIp) {
LoginUser me = UserContext.require();
CollectSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
}
if (!inMyScope(me, sub)) {
throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里");
}
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS,
"这份填报单已经提交(或已审完),代传请先让学生撤回,或让管理员处理");
}
if (materialCode == null || materialCode.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要代传的材料");
}
// ★ 走与学生上传**完全相同**的那套校验与规范化(见类注释)
Long fileId = materialFileService.storeFile(sub, materialCode, originalName, bytes, clientIp);
// ★ 代传留痕:来源 + 谁 + 为什么(契约 §10.6)
String reason = proxyReason == null ? null
: (proxyReason.length() > 250 ? proxyReason.substring(0, 250) : proxyReason.trim());
fileMapper.update(null, Wrappers.<CollectFile>lambdaUpdate()
.eq(CollectFile::getId, fileId)
.set(CollectFile::getSource, CollectFile.SOURCE_PROXY_TEACHER)
.set(CollectFile::getProxyBy, me.uid())
.set(CollectFile::getProxyReason, reason));
operationLog.record(OperationLogService.MODULE_FILE,
OperationLogService.ACTION_PROXY_UPLOAD,
"FILE", fileId,
null, materialCode,
"{\"submissionId\":" + submissionId + ",\"reason\":"
+ (reason == null ? "null" : "\"" + reason.replace("\"", "'") + "\"") + "}");
SysUser teacher = userMapper.selectById(me.uid());
String name = teacher == null || teacher.getRealName() == null
? "老师" : teacher.getRealName();
log.info("班主任代传:submission={} material={} file={} 代传人={} 原因={}",
submissionId, materialCode, fileId, me.uid(), reason);
TeacherDtos.ProxyUploadVO vo = new TeacherDtos.ProxyUploadVO();
vo.setFileId(fileId);
vo.setProxyBy(me.uid());
vo.setProxyByName(name);
vo.setSourceText("由 " + name + " 老师代传");
return vo;
}
/**
* 范围判断:这个填报单所在的班归不归我。
*
* <p>用与初审、看板、催报**同一套**三级回退(指定初审人 → 副班主任 → 班级负责人)。
* 另写一套判断迟早会分叉,那时会出现"能催报但不能代传"这种说不清的组合。
*/
private boolean inMyScope(LoginUser me, CollectSubmission sub) {
if (sub.getOrgId() == null) {
return false;
}
List<Long> mine = auditScopeMapper.classAuditOrgIds(me.tenantId(), sub.getTaskId(), me.uid());
return mine.contains(sub.getOrgId());
}
/** 供详情页显示用:这个人是不是我负责的学生(前端据此决定要不要显示代传按钮)。 */
public boolean canProxy(Long submissionId) {
LoginUser me = UserContext.require();
CollectSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null || !inMyScope(me, sub)) {
return false;
}
return sub.getStatus() == null || sub.getStatus() == CollectSubmission.STATUS_DRAFT;
}
/**
* 取一个学生的名册信息(代传页面要显示"你正在替谁传")。
*
* <p>刻意带上姓名与班级:代传是"替别人做事",界面上必须一直显示对象是谁 ——
* 老师一次帮几个学生传材料时,最容易出的错就是**传给了上一个人**。
*/
public CollectRoster rosterOf(Long submissionId) {
LoginUser me = UserContext.require();
CollectSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
}
if (!inMyScope(me, sub)) {
throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里");
}
return sub.getRosterId() == null ? null : rosterMapper.selectById(sub.getRosterId());
}
/**
* PC 端(管理/审核角色)代传:范围是**整个租户**,不限于自己负责的班。
*
* <h3>为什么必须单独一个入口,而不是给 {@link #proxyUpload} 加个 boolean</h3>
*
* <p>加 boolean 的写法会让"这次到底校没校范围"变成调用点的一个参数 ——
* 而参数是会抄错的:新加一处调用,忘了传 true,就出现"任何老师能给任何学生塞材料"。
* 两个入口各自把范围判定写在**自己里面**,调用点无从传错。
*
* <p>范围放宽到整个租户仍然有边界:跨租户依旧不可能(租户拦截器挡着),
* 而且代传留痕(source / proxy_by / proxy_reason / 操作日志)一模一样 ——
* "管理员代传"反而更需要留痕,因为他能碰的学生更多。
*/
@Transactional(rollbackFor = Exception.class)
public TeacherDtos.ProxyUploadVO proxyUploadAsAdmin(Long submissionId, String materialCode,
String originalName, byte[] bytes,
String proxyReason, String clientIp) {
LoginUser me = UserContext.require();
CollectSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除");
}
if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) {
throw new BizException(ErrorCode.AUDIT_IN_PROGRESS,
"这份填报单已经提交(或已审完),代传请先让学生撤回,或让管理员处理");
}
if (materialCode == null || materialCode.isBlank()) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要代传的材料");
}
Long fileId = materialFileService.storeFile(sub, materialCode, originalName, bytes, clientIp);
String reason = proxyReason == null ? null
: (proxyReason.length() > 250 ? proxyReason.substring(0, 250) : proxyReason.trim());
// 来源记 3(管理员代传)而不是 2:审核时间轴上要能区分"班主任帮学生传的"
// 与"管理员替他传的" —— 这两件事的责任人不同
fileMapper.update(null, Wrappers.<CollectFile>lambdaUpdate()
.eq(CollectFile::getId, fileId)
.set(CollectFile::getSource, CollectFile.SOURCE_PROXY_ADMIN)
.set(CollectFile::getProxyBy, me.uid())
.set(CollectFile::getProxyReason, reason));
operationLog.record(OperationLogService.MODULE_FILE,
OperationLogService.ACTION_PROXY_UPLOAD,
"FILE", fileId, "ADMIN", materialCode,
"{\"submissionId\":" + submissionId + ",\"by\":\"ADMIN\"}");
SysUser admin = userMapper.selectById(me.uid());
String name = admin == null || admin.getRealName() == null ? "管理员" : admin.getRealName();
log.info("管理端代传:submission={} material={} file={} 代传人={} 原因={}",
submissionId, materialCode, fileId, me.uid(), reason);
TeacherDtos.ProxyUploadVO vo = new TeacherDtos.ProxyUploadVO();
vo.setFileId(fileId);
vo.setProxyBy(me.uid());
vo.setProxyByName(name);
vo.setSourceText("由 " + name + " 代传");
return vo;
}
/**
* 取一份材料,并要求它属于**我负责的班**的学生。
*
* <p>授权链是:文件 → 填报单 → 班级 → 是不是我负责的。每一跳都必须走,
* 少一跳就会出现"任何老师能看任何学生的身份证照片"。
*
* <p>越权时用 {@code 40301 FORBIDDEN} 而不是"文件不存在":
* 这时用户确实该知道自己越权了;而"文件不存在"会让他以为数据丢了,
* 反复重试或者报障。
*/
public CollectFile requireMyStudentFile(Long fileId) {
LoginUser me = UserContext.require();
CollectFile f = fileMapper.selectById(fileId);
if (f == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或已删除");
}
CollectSubmission sub = f.getSubmissionId() == null
? null : submissionMapper.selectById(f.getSubmissionId());
if (sub == null) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这份文件没有关联填报单,无法确认归属");
}
if (!inMyScope(me, sub)) {
throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里");
}
return f;
}
}
@@ -62,11 +62,6 @@ public class JwtService {
return client == Client.MP ? mpExpireSeconds : pcExpireSeconds; return client == Client.MP ? mpExpireSeconds : pcExpireSeconds;
} }
/**
* 签发 token。
*
* @return {@code [token, 有效期秒数]}
*/
public String issue(LoginUser user, Client client) { public String issue(LoginUser user, Client client) {
long exp = expireSecondsOf(client); long exp = expireSecondsOf(client);
Instant now = Instant.now(); Instant now = Instant.now();
@@ -0,0 +1,42 @@
-- ============================================================================
-- V12:文件来源与代传留痕
--
-- 背景:`collect_file.source` 这一列**从来没有被代码显式写过** ——
-- 库里那 10 行 source=1 全部来自列默认值。也就是说:
-- 「这份材料是学生自己传的,还是班主任代传的」**当前在数据上完全区分不出来**。
--
-- 而契约 §10.6 明确要求:
-- > 代传必须留痕:文件记录 `source = 4`(代传),审核时间轴显示"由李老师代传"。学生端可见。
--
-- 一、source 的口径(**两处文档冲突,以 DDL 为准**)
-- ---------------------------------------------------------------------------
-- · PRD 的建表注释(`数据信息填报系统PRD.md` 第 1894 行):
-- `1 本人上传 2 班主任代传 3 管理员代传 4 整理器导入`
-- · 接口设计 §3.6 的字段表(第 464 行):
-- `1 聊天记录 2 相册 3 手机文件 4 代传`
--
-- 两者都是"4 个值",但含义完全不同。以 **DDL 为准**,理由是:
-- ① DDL 是"这一列到底表示什么"的定义,字段表只是它的展示层描述;
-- ② 已有数据全部是 1(= 本人上传),与 DDL 口径一致;
-- ③ PC 端的 `FILE_SOURCE` 常量(`apis/xt/audit.ts`)已经按 DDL 写了
-- `1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入`,
-- 改成字段表口径要动前端 + 历史数据。
-- 不一致这件事记在盘点文档的 F-38 里。
--
-- 二、为什么还要 proxy_by / proxy_reason
-- ---------------------------------------------------------------------------
-- `source = 2` 只说"是班主任代传的",但**说不出是哪个班主任、为什么**。
-- 而代传恰恰是最需要留痕的动作之一:学生没传、老师替他传了,
-- 出了问题要能回答"谁在什么时候替他传的、他给的理由是什么"。
-- 契约要求"审核时间轴显示'由李老师代传'"—— 名字只能存在这里。
-- ============================================================================
ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS proxy_by BIGINT;
ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS proxy_reason VARCHAR(256);
COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入(**以此为准**;接口设计 §3.6 的字段表口径与它冲突,见 F-38)';
COMMENT ON COLUMN collect_file.proxy_by IS '代传人 user_id(source=2/3 时有值);审核时间轴据此显示「由某某老师代传」';
COMMENT ON COLUMN collect_file.proxy_reason IS '代传原因(选填,但会挂到审核时间轴上给学生看到)';
-- 「谁代传的」是代传留痕的主要查法
CREATE INDEX IF NOT EXISTS idx_file_proxy ON collect_file (tenant_id, proxy_by);
+59
View File
@@ -24,6 +24,65 @@
"navigationBarBackgroundColor": "#0052D9", "navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white"
} }
},
{
"path": "pages/join/join",
"style": {
"navigationBarTitleText": "申请加入名单",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/tasks/tasks",
"style": {
"navigationBarTitleText": "我的填报",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
"path": "pages/progress/progress",
"style": {
"navigationBarTitleText": "填报进度",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/dashboard/dashboard",
"style": {
"navigationBarTitleText": "班级看板",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{
"path": "pages/audit/audit",
"style": {
"navigationBarTitleText": "待我审核",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/audit-detail/audit-detail",
"style": {
"navigationBarTitleText": "审核详情",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/messages/messages",
"style": {
"navigationBarTitleText": "消息",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -0,0 +1,728 @@
<template>
<view class="det">
<view class="det__wrap">
<view v-if="loading" class="det__empty">正在读取…</view>
<template v-else-if="d">
<view class="det__hero">
<text class="det__name">{{ d.realName }}</text>
<text class="det__sub">
{{ d.orgName || '未分班' }} · {{ d.taskName || '' }}
</text>
<view class="det__badges">
<text class="det__status">{{ d.statusText }}</text>
<text v-if="d.redCount" class="det__badge det__badge--bad">红 {{ d.redCount }}</text>
<text v-if="d.yellowCount" class="det__badge det__badge--warn">黄 {{ d.yellowCount }}</text>
<text v-if="d.rejectCount" class="det__badge">已驳 {{ d.rejectCount }} 次</text>
</view>
</view>
<!-- 有红问题时把"为什么不能通过"写在最显眼处 -->
<view v-if="redTitles.length" class="det__red">
有 {{ redTitles.length }} 项阻断级问题,<text class="det__strong">不能通过</text>:
{{ redTitles.join(';') }}
</view>
<view v-if="d.lastRejectText || d.lastRejectCode" class="det__warn">
上次驳回:{{ d.lastRejectText || d.lastRejectCode }}
</view>
<!-- 材料 -->
<view class="det__card">
<view class="det__title">材料</view>
<view v-for="g in d.materials || []" :key="g.materialCode" class="det__mat">
<view class="det__mat-head">
<text class="det__mat-name">{{ g.materialName }}</text>
<text v-if="g.required" class="det__tag det__tag--req">必传</text>
<text class="det__tag" :class="g.done ? 'det__tag--ok' : 'det__tag--todo'">
{{ g.done ? '已交' : '未交' }}
</text>
</view>
<view v-if="g.files && g.files.length" class="det__files">
<view v-for="f in g.files" :key="f.id" class="det__file">
<image v-if="f.previewUrl || f.thumbUrl" :src="f.thumbUrl || f.previewUrl || ''"
class="det__img" mode="aspectFit"
@click="preview(g.files || [], f)" />
<view class="det__file-meta">
<text class="det__file-name">{{ f.standardName || f.originalName || '(无文件名)' }}</text>
<text class="det__file-sub">
{{ f.finalSizeKb ? `${f.finalSizeKb}KB` : '' }}
<template v-if="f.width && f.height"> · {{ f.width }}×{{ f.height }}</template>
<template v-if="f.source && f.source !== 1"> · {{ sourceText(f.source) }}</template>
</text>
<text v-for="(i, idx) in f.issues || []" :key="idx" class="det__file-issue"
:class="i.severity === 3 ? 'det__file-issue--bad' : 'det__file-issue--warn'">
{{ i.title }}
</text>
</view>
</view>
</view>
<text v-else class="det__muted">没有文件</text>
</view>
</view>
<!-- 问题清单 -->
<view class="det__card">
<view class="det__title">
校验问题({{ (d.issues || []).length }})
<text class="det__muted">现场重算</text>
</view>
<view v-for="(i, idx) in d.issues || []" :key="idx" class="det__issue">
<text class="det__issue-title" :class="i.severity === 3 ? 'det__issue--bad' : 'det__issue--warn'">
[{{ i.ruleCode }}] {{ i.title }}
</text>
<text class="det__muted">{{ i.detail }}</text>
</view>
<text v-if="!(d.issues || []).length" class="det__muted">没有校验问题</text>
</view>
<!-- 学生改动 -->
<view v-if="(d.changedDetail || []).length" class="det__card">
<view class="det__title">学生改动({{ d.changedDetail.length }} 项)</view>
<view v-for="c in d.changedDetail" :key="c.field" class="det__change">
<text class="det__change-label">{{ c.label || c.field }}</text>
<text class="det__muted">名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}</text>
</view>
</view>
<!-- 底部动作:驳回必须点按钮 + 二次确认 -->
<view v-if="showActions" class="det__actions">
<button class="det__btn det__btn--reject" @click="openReject">驳回</button>
<button class="det__btn" :disabled="!canApprove" @click="approve">
{{ canApprove ? '通过' : '不能通过(有红问题)' }}
</button>
</view>
<!--
代传入口(§10.6):只在这个学生**还没提交**时才有意义 ——
已提交的单子不能塞材料(那会让审到一半的材料清单变了)。
放在最下面而不是和"通过/驳回"并排:它是补救动作,不是主流程。
-->
<view v-if="canProxy" class="det__proxy">
<view class="det__proxy-title">帮他传材料</view>
<view class="det__proxy-hint">
学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
<text class="det__strong">这份材料会标注「由你代传」</text>,学生也看得到。
</view>
<view class="det__proxy-mats">
<view v-for="g in (d.materials || []).filter((m) => m.required)" :key="g.materialCode"
class="det__proxy-mat" :class="{ 'det__proxy-mat--on': proxyMaterial === g.materialCode }"
@click="proxyMaterial = g.materialCode">
{{ g.materialName }}{{ g.done ? '(已交)' : '' }}
</view>
</view>
<input v-model="proxyReason" class="det__proxy-reason"
placeholder="代传原因(选填,但学生看得到)" :maxlength="100" />
<button class="det__btn det__btn--ghost" :disabled="proxyBusy" @click="doProxy">
{{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
</button>
</view>
</template>
<!-- 驳回弹层:原因快选 + 必须二次确认 -->
<view v-if="rejectVisible" class="det__mask" @click="rejectVisible = false">
<view class="det__sheet" @click.stop>
<view class="det__sheet-title">驳回这一单</view>
<view class="det__sheet-hint">原因会原样发给学生看,所以要说清"哪里不对、该怎么办"。</view>
<view class="det__reasons">
<view v-for="r in reasonCodes" :key="r.code" class="det__reason"
:class="{ 'det__reason--on': rejectForm.reasonCode === r.code }"
@click="pickReason(r)">
{{ r.text }}
</view>
</view>
<textarea v-model="rejectForm.reasonText" class="det__textarea"
:placeholder="needText ? '请写清问题(必填)' : '补充说明(可选)'" :maxlength="200" />
<view class="det__sheet-actions">
<button class="det__btn det__btn--ghost" @click="rejectVisible = false">取消</button>
<button class="det__btn det__btn--reject" :disabled="submitting" @click="doReject">
确认驳回
</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 学生详情 + 驳回(《03-接口设计》§10.3/§10.4,原型 ③⑤)。
*
* ## 两条铁律在界面上的落点
*
* 1. **驳回必须点按钮 + 二次确认**(左滑不绑驳回)。所以这个页面里
* 驳回是一条独立的路径:选原因 → 写说明 → 确认。误触的代价不可恢复。
* 2. **有红问题不能通过**:按钮直接禁用并把原因显示出来,而不是等他点了再弹 40032。
* 判定以服务端的 `approvePolicy.canApprove` 为准,前端不自己算。
*/
defineOptions({ name: 'TeacherAuditDetail' })
interface Issue {
ruleCode: string
severity: number
title: string
detail: string
}
interface FileItem {
id: number
originalName?: string
standardName?: string
previewUrl?: string
thumbUrl?: string
finalSizeKb?: number
width?: number
height?: number
source?: number
issues?: Issue[]
}
interface MaterialGroup {
materialCode: string
materialName: string
required: boolean
done: boolean
files?: FileItem[]
}
interface Detail {
id: number
status: number
statusText: string
taskName?: string
orgName?: string
realName: string
idCardMask?: string
materials?: MaterialGroup[]
issues?: Issue[]
redCount: number
yellowCount: number
rejectCount: number
lastRejectCode?: string
lastRejectText?: string
changedDetail?: Array<{ field: string, label?: string, rosterValue?: string, studentValue?: string }>
approvePolicy?: { canApprove: boolean, reason?: string, redTitles?: string[] }
}
const loading = ref(false)
const submitting = ref(false)
const d = ref<Detail | null>(null)
const rejectVisible = ref(false)
const reasonCodes = ref<Array<{ code: string, text: string, requiresText: boolean }>>([])
const rejectForm = reactive({ reasonCode: '', reasonText: '' })
let submissionId = 0
let openRejectOnLoad = false
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
const redTitles = computed(() => {
const fromPolicy = d.value?.approvePolicy?.redTitles
if (fromPolicy?.length) {
return fromPolicy
}
return (d.value?.issues || []).filter((i) => i.severity === 3).map((i) => i.title)
})
const canApprove = computed(() => d.value?.approvePolicy?.canApprove === true)
const showActions = computed(() => d.value?.status === 2 || d.value?.status === 3)
const needText = computed(() => {
const r = reasonCodes.value.find((x) => x.code === rejectForm.reasonCode)
return r?.requiresText === true
})
function sourceText(src: number) {
return src === 2 ? '班主任代传' : (src === 3 ? '管理员代传' : (src === 4 ? '整理器导入' : ''))
}
function preview(files: FileItem[], cur: FileItem) {
const urls = files.map((f) => f.previewUrl || f.thumbUrl || '').filter(Boolean)
const curUrl = cur.previewUrl || cur.thumbUrl || ''
if (urls.length) {
uni.previewImage({ urls, current: curUrl })
}
}
async function load() {
loading.value = true
try {
d.value = await request<Detail>({
url: `/api/v1/mp/teacher/audit/${submissionId}`,
token: token()
})
if (openRejectOnLoad) {
openRejectOnLoad = false
openReject()
}
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function approve() {
try {
const r = await request<{ replayed?: boolean }>({
url: `/api/v1/mp/teacher/audit/${submissionId}/approve`,
method: 'POST',
token: token(),
data: { actionSource: 'BUTTON' }
})
uni.showToast({ title: r?.replayed ? '这一单已经审过了' : '已通过', icon: 'none' })
setTimeout(() => uni.navigateBack(), 600)
} catch (e) {
uni.showModal({
title: '不能通过',
content: e instanceof Error ? e.message : '操作失败',
showCancel: false
})
}
}
async function openReject() {
rejectForm.reasonCode = ''
rejectForm.reasonText = ''
rejectVisible.value = true
if (!reasonCodes.value.length) {
try {
reasonCodes.value = await request<Array<{ code: string, text: string, requiresText: boolean }>>({
url: '/api/v1/mp/teacher/audit/reason-codes',
token: token()
})
} catch {
uni.showToast({ title: '驳回原因字典没取到', icon: 'none' })
}
}
}
function pickReason(r: { code: string, text: string }) {
rejectForm.reasonCode = r.code
}
/** 二次确认:驳回不可恢复(学生要改一份材料),所以再问一次 */
function doReject() {
if (!rejectForm.reasonCode) {
uni.showToast({ title: '请选择驳回原因', icon: 'none' })
return
}
if (needText.value && !rejectForm.reasonText.trim()) {
uni.showToast({ title: '选「其他原因」时必须写清问题', icon: 'none' })
return
}
const r = reasonCodes.value.find((x) => x.code === rejectForm.reasonCode)
uni.showModal({
title: '确认驳回?',
content: `原因:${rejectForm.reasonText.trim() || r?.text || ''}\n`
+ '学生会看到这句话并需要重新提交。一期没有通知渠道,请另外口头告知他。',
confirmText: '确认驳回',
success: async (res) => {
if (!res.confirm) {
return
}
submitting.value = true
try {
const out = await request<{ needManualNotice?: boolean }>({
url: `/api/v1/mp/teacher/audit/${submissionId}/reject`,
method: 'POST',
token: token(),
data: {
reasonCode: rejectForm.reasonCode,
reasonText: rejectForm.reasonText.trim() || undefined
}
})
rejectVisible.value = false
uni.showModal({
title: '已驳回',
content: out?.needManualNotice
? '通知没有发出去(一期没有通知渠道),请口头告知学生。'
: '已通知学生。',
showCancel: false,
success: () => uni.navigateBack()
})
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '驳回失败', icon: 'none' })
} finally {
submitting.value = false
}
}
})
}
onLoad((options) => {
submissionId = Number(options?.submissionId || 0)
openRejectOnLoad = String(options?.reject || '') === '1'
load()
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.det {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__empty {
padding: 60rpx 28rpx;
font-size: 28rpx;
color: #8b95a3;
text-align: center;
background: #fff;
border-radius: 16rpx;
}
&__hero {
padding: 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
}
&__name {
display: block;
font-size: 38rpx;
font-weight: 600;
color: #1b2430;
}
&__sub {
display: block;
margin-top: 8rpx;
font-size: 25rpx;
color: #8b95a3;
}
&__badges {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
&__status {
padding: 4rpx 14rpx;
font-size: 22rpx;
color: $primary;
background: #eaf1fd;
border-radius: 8rpx;
}
&__badge {
padding: 4rpx 14rpx;
font-size: 22rpx;
color: #8b95a3;
background: #f2f3f5;
border-radius: 8rpx;
&--bad {
color: $danger;
background: #fdecee;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__red {
padding: 20rpx 24rpx;
margin-bottom: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__warn {
padding: 20rpx 24rpx;
margin-bottom: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #8a5a12;
background: #fdf2e9;
border-radius: 12rpx;
}
&__strong {
font-weight: 600;
}
&__card {
padding: 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
margin-bottom: 18rpx;
font-size: 30rpx;
font-weight: 600;
color: #1b2430;
}
&__mat {
padding: 18rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__mat-head {
display: flex;
align-items: center;
gap: 12rpx;
}
&__mat-name {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__tag {
padding: 4rpx 12rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--req {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--todo {
color: $warn;
background: #fdf2e9;
}
}
&__files {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 14rpx;
}
&__file {
display: flex;
gap: 16rpx;
}
&__img {
flex: none;
width: 160rpx;
height: 160rpx;
background: #f2f3f5;
border-radius: 10rpx;
}
&__file-meta {
flex: 1;
min-width: 0;
}
&__file-name {
display: block;
font-size: 24rpx;
line-height: 1.5;
color: #1b2430;
word-break: break-all;
}
&__file-sub {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #8b95a3;
}
&__file-issue {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
line-height: 1.5;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
}
&__issue {
padding: 12rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__issue-title {
display: block;
font-size: 26rpx;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
}
&__change {
padding: 12rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__change-label {
display: block;
font-size: 26rpx;
color: #1b2430;
}
&__muted {
font-size: 23rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
&__btn {
flex: 1;
height: 88rpx;
font-size: 30rpx;
line-height: 88rpx;
color: #fff;
background: $primary;
border-radius: 14rpx;
&[disabled] {
color: #fff;
background: #c9cdd4;
}
&--reject {
color: #fff;
background: $danger;
}
&--ghost {
color: #4a5768;
background: #f2f3f5;
}
}
&__mask {
position: fixed;
inset: 0;
z-index: 99;
display: flex;
align-items: flex-end;
background: rgb(0 0 0 / 45%);
}
&__sheet {
width: 100%;
max-height: 80vh;
padding: 32rpx 28rpx 40rpx;
overflow-y: auto;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
}
&__sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #1b2430;
}
&__sheet-hint {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: #8b95a3;
}
&__reasons {
margin-top: 20rpx;
}
&__reason {
padding: 20rpx 22rpx;
margin-bottom: 12rpx;
font-size: 26rpx;
line-height: 1.5;
color: #4a5768;
background: #f7f8fa;
border: 2rpx solid transparent;
border-radius: 12rpx;
&--on {
color: $primary;
background: #eaf1fd;
border-color: $primary;
}
}
&__textarea {
width: 100%;
height: 160rpx;
padding: 20rpx;
margin-top: 16rpx;
font-size: 26rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__sheet-actions {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
}
</style>
+517
View File
@@ -0,0 +1,517 @@
<template>
<view class="audit">
<view class="audit__wrap">
<!-- 顶部:还剩几张 + 主动作 -->
<view class="audit__bar">
<text class="audit__count">待审 {{ list.length }} 张</text>
<button class="audit__batch" :disabled="!greenCount" @click="batchApprove">
一键通过无问题({{ greenCount }})
</button>
</view>
<view v-if="loading" class="audit__empty">正在读取…</view>
<view v-else-if="!list.length" class="audit__empty">
<text class="audit__empty-title">没有待你初审的单子了</text>
<text class="audit__empty-hint">学生提交后会自动出现在这里。</text>
</view>
<template v-else>
<!--
⭐ 原型铁律:**通过可以右滑,驳回必须点按钮**。
误触通过的代价是"再撤一次"(可恢复);误触驳回的代价是
学生白改一份材料(不可恢复)。两者不对称,所以手势只绑一个方向。
-->
<movable-area class="audit__area">
<movable-view class="audit__view" direction="horizontal" :x="x" :damping="40"
:out-of-bounds="false" @change="onMove" @touchend="onTouchEnd">
<view class="audit__card" :class="swipeHintClass">
<view class="audit__head">
<text class="audit__name">{{ cur.realName }}</text>
<text class="audit__level" :class="levelClass">{{ levelText }}</text>
</view>
<view class="audit__row">
<text class="audit__label">班级</text>
<text class="audit__value">{{ cur.orgName || '—' }}</text>
</view>
<view class="audit__row">
<text class="audit__label">材料</text>
<text class="audit__value">
{{ cur.materialCount }} 项<template v-if="(cur.materialThumbs || []).length">(有预览)</template>
</text>
</view>
<view class="audit__row">
<text class="audit__label">提交</text>
<text class="audit__value">{{ fmt(cur.submitTime) }}</text>
</view>
<view v-if="cur.redSummary" class="audit__red">阻断:{{ cur.redSummary }}</view>
<view v-else-if="cur.redCount || cur.yellowCount" class="audit__warn">
红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
</view>
<view v-if="(cur.changedFields || []).length" class="audit__changed">
学生改了 {{ cur.changedFields.length }} 项预填内容
</view>
<view class="audit__thumbs">
<image v-for="(u, i) in (cur.materialThumbs || []).slice(0, 3)" :key="i" :src="u"
class="audit__thumb" mode="aspectFill" @click="preview(u)" />
</view>
</view>
</movable-view>
</movable-area>
<view class="audit__hint">
<text class="audit__hint-main">右滑 → 通过</text>
<text class="audit__hint-sub">驳回请点下面的按钮(要选原因,不能滑)</text>
</view>
<view class="audit__actions">
<button class="audit__btn audit__btn--reject" @click="reject">驳回</button>
<button class="audit__btn" @click="approve('BUTTON')">通过</button>
</view>
<view class="audit__detail" @click="goDetail">看完整材料与问题清单</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 班主任审核卡片流(《03-接口设计》§10.2~§10.5,原型 ②)。
*
* ## 手势只绑一个方向(这不是防手滑,是防不可恢复的错误)
*
* 通过可以右滑;**驳回必须点按钮**。理由是不对称:
* · 误触通过 → 再撤一次就好(可恢复);
* · 误触驳回 → 学生白改一份材料、多跑一趟(不可恢复)。
* 所以左滑刻意**什么都不做** —— 不给"左滑驳回"留任何入口。
*
* ## 只显示服务端给的待办
*
* "我能不能审这一单"由服务端按范围判定(权限 ≠ 范围)。
* 前端不自己按班级过滤:过滤不了越权,只会在口径不一致时制造假象。
*/
defineOptions({ name: 'TeacherAudit' })
interface Todo {
submissionId: number
realName: string
orgName?: string
taskName?: string
submitTime?: string
level: number
redCount: number
yellowCount: number
redSummary?: string
materialCount: number
materialThumbs?: string[]
changedFields?: string[]
/** 服务端算好的:能不能批量通过(红黄都为零) */
batchApprovable: boolean
statusText: string
}
const loading = ref(false)
const list = ref<Todo[]>([])
const x = ref(0)
const dragging = ref(false)
let orgId = 0
/** 右滑多远算"通过"(px)。太小会误触,太大滑不动 */
const THRESHOLD = 120
const cur = computed<Todo>(() => list.value[0] || ({} as Todo))
const greenCount = computed(() => list.value.filter((t) => t.batchApprovable).length)
const levelText = computed(() => {
const l = cur.value.level
if (l === 3) return '有红问题'
if (l === 2) return '有警告'
return '无问题'
})
const levelClass = computed(() => {
const l = cur.value.level
if (l === 3) return 'audit__level--bad'
if (l === 2) return 'audit__level--warn'
return 'audit__level--ok'
})
const swipeHintClass = computed(() => {
if (!dragging.value) {
return ''
}
return x.value >= THRESHOLD ? 'audit__card--pass' : ''
})
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function onMove(e: { detail?: { x?: number } }) {
const v = Number(e?.detail?.x ?? 0)
x.value = v
dragging.value = true
}
function preview(url: string) {
uni.previewImage({ urls: [url] })
}
function goDetail() {
if (!cur.value.submissionId) {
return
}
uni.navigateTo({
url: `/pages/audit-detail/audit-detail?submissionId=${cur.value.submissionId}`
})
}
async function load() {
loading.value = true
try {
const d = await request<{ list: Todo[] }>({
url: `/api/v1/mp/teacher/audit/todos?node=1${orgId ? `&orgId=${orgId}` : ''}&pageSize=20`,
token: token()
})
list.value = d.list || []
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
x.value = 0
dragging.value = false
}
}
/** 右滑到位才通过;左滑**什么都不做**(不给驳回留手势入口) */
function onTouchEnd() {
const passed = x.value >= THRESHOLD
dragging.value = false
if (passed) {
approve('SWIPE')
} else {
// 弹回原位:movable-view 的 x 需要显式设回 0
x.value = 0
}
}
async function approve(source: 'SWIPE' | 'BUTTON') {
const id = cur.value.submissionId
if (!id) {
return
}
try {
const r = await request<{ statusText?: string, replayed?: boolean }>({
url: `/api/v1/mp/teacher/audit/${id}/approve`,
method: 'POST',
token: token(),
data: { actionSource: source }
})
uni.showToast({
title: r?.replayed ? '这一单刚才已经审过了' : '已通过',
icon: 'none'
})
list.value = list.value.slice(1)
x.value = 0
if (!list.value.length) {
await load()
}
} catch (e) {
// 红问题(40032)等错误原样显示 —— 后端的话写得很具体("存在 1 个红色问题")
uni.showModal({
title: '不能通过',
content: e instanceof Error ? e.message : '操作失败',
showCancel: false
})
x.value = 0
}
}
function reject() {
if (!cur.value.submissionId) {
return
}
// 驳回要选原因(且原因会原样给学生看),所以走独立的驳回页,不在卡片上做
uni.navigateTo({
url: `/pages/audit-detail/audit-detail?submissionId=${cur.value.submissionId}&reject=1`
})
}
async function batchApprove() {
const ids = list.value.filter((t) => t.batchApprovable).map((t) => t.submissionId)
if (!ids.length) {
return
}
uni.showModal({
title: `一键通过 ${ids.length} 份?`,
// 说清"只带无问题的":服务端会强制跳过红黄,用户不该以为全过了
content: '只包含**没有任何问题**的单子;有红黄问题的一律跳过,需要你单独看。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
const r = await request<{ approved: number, skipped: number }>({
url: '/api/v1/mp/teacher/audit/batch-approve',
method: 'POST',
token: token(),
data: { submissionIds: ids, actionSource: 'BUTTON' }
})
uni.showModal({
title: '批量通过完成',
content: `通过 ${r?.approved ?? 0} 份,跳过 ${r?.skipped ?? 0} 份。`
+ (r?.skipped ? '被跳过的都是有问题需要单独看的。' : ''),
showCancel: false
})
await load()
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '批量通过失败', icon: 'none' })
}
}
})
}
onLoad((options) => {
orgId = Number(options?.orgId || 0)
})
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.audit {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
&__count {
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__batch {
height: 64rpx;
padding: 0 24rpx;
font-size: 24rpx;
line-height: 64rpx;
color: #fff;
background: $success;
border-radius: 32rpx;
&[disabled] {
color: #fff;
background: #c9cdd4;
}
}
&__empty {
padding: 60rpx 28rpx;
text-align: center;
background: #fff;
border-radius: 16rpx;
}
&__empty-title {
display: block;
font-size: 30rpx;
color: #1b2430;
}
&__empty-hint {
display: block;
margin-top: 12rpx;
font-size: 25rpx;
color: #8b95a3;
}
/* movable-area 必须给高度,否则卡片不可见 */
&__area {
width: 100%;
height: 620rpx;
}
&__view {
width: 100%;
height: 600rpx;
}
&__card {
padding: 32rpx 28rpx;
background: #fff;
border: 2rpx solid transparent;
border-radius: 20rpx;
box-shadow: 0 6rpx 24rpx rgb(27 36 48 / 8%);
transition: border-color 0.15s;
&--pass {
border-color: $success;
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
&__name {
font-size: 38rpx;
font-weight: 600;
color: #1b2430;
}
&__level {
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 8rpx;
&--ok {
color: $success;
background: #e8f6f0;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
&--bad {
color: $danger;
background: #fdecee;
}
}
&__row {
display: flex;
justify-content: space-between;
padding: 10rpx 0;
}
&__label {
font-size: 26rpx;
color: #8b95a3;
}
&__value {
font-size: 26rpx;
color: #1b2430;
}
&__red {
padding: 16rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.6;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__warn {
padding: 16rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
color: #8a5a12;
background: #fdf2e9;
border-radius: 12rpx;
}
&__changed {
margin-top: 12rpx;
font-size: 24rpx;
color: $warn;
}
&__thumbs {
display: flex;
gap: 12rpx;
margin-top: 18rpx;
}
&__thumb {
width: 140rpx;
height: 140rpx;
border: 1rpx solid #e5e6eb;
border-radius: 10rpx;
}
&__hint {
margin: 12rpx 0 20rpx;
text-align: center;
}
&__hint-main {
display: block;
font-size: 26rpx;
color: $success;
}
&__hint-sub {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 20rpx;
}
&__btn {
flex: 1;
height: 88rpx;
font-size: 31rpx;
line-height: 88rpx;
color: #fff;
background: $primary;
border-radius: 14rpx;
&--reject {
color: $danger;
background: #fdecee;
}
}
&__detail {
margin-top: 24rpx;
font-size: 26rpx;
color: $primary;
text-align: center;
}
}
</style>
@@ -0,0 +1,404 @@
<template>
<view class="dash">
<view class="dash__wrap">
<view class="dash__hero">
<text class="dash__title">我带的班</text>
<text class="dash__sub">
应填 {{ s.rosterTotal }} 人 · 已交 {{ s.submittedCount }} 人 · 已通过 {{ s.approvedCount }} 人
</text>
<!--
⚠️ 这个百分比是**已提交率**,不是通过率(契约 §10.1 的口径铁律)。
所以标签就写"已交率",旁边单独列"已通过" —— 合并成一个"完成率"之后,
老师看到 60% 不知道该去催人还是该去看材料。
-->
<view class="dash__rate">
<text class="dash__rate-num">{{ s.submitRate }}</text>
<text class="dash__rate-label">已交率</text>
</view>
</view>
<!-- 待办:这一屏最该被点的是"待我初审" -->
<view class="dash__todos">
<view class="dash__todo" @click="goAudit">
<text class="dash__todo-num">{{ t.auditCount }}</text>
<text class="dash__todo-label">待我初审</text>
</view>
<view class="dash__todo">
<text class="dash__todo-num">{{ s.unsubmittedCount }}</text>
<text class="dash__todo-label">还没交</text>
</view>
<view class="dash__todo">
<text class="dash__todo-num">{{ t.rejectedAwaitingCount }}</text>
<text class="dash__todo-label">驳回等改</text>
</view>
<view class="dash__todo">
<text class="dash__todo-num">{{ s.abnormalCount }}</text>
<text class="dash__todo-label">有异常</text>
</view>
</view>
<!-- 截止提醒:只剩几天是最容易被忽略、后果又最重的事 -->
<view v-if="d && d.taskName" class="dash__deadline" :class="deadlineClass">
<text class="dash__deadline-title">最近截止:{{ d.taskName }}</text>
<text class="dash__deadline-sub">{{ fmtDate(d.endTime) }} {{ remainText }}</text>
</view>
<view class="dash__section">我的班级</view>
<view v-if="loading" class="dash__empty">正在读取…</view>
<view v-else-if="!orgs.length" class="dash__empty">
你还没有被指定为任何班级的初审人。请让管理员在「收集任务 → 配置班级」里指定。
</view>
<view v-for="o in orgs" :key="o.orgId" class="dash__card" @click="goAudit(o)">
<view class="dash__card-head">
<text class="dash__org">{{ o.orgName }}</text>
<text v-if="o.isHead" class="dash__tag dash__tag--head">主班主任</text>
<text v-else class="dash__tag">副班主任</text>
</view>
<view class="dash__card-row">
<text class="dash__muted">应填 {{ o.rosterTotal }} · 已交 {{ o.submittedCount }}</text>
<text class="dash__muted">已通过 {{ o.approvedCount }}</text>
</view>
<view class="dash__card-foot">
<text class="dash__rate-small">已交率 {{ o.submitRate }}</text>
<text v-if="o.auditCount" class="dash__badge">待审 {{ o.auditCount }}</text>
<text v-else class="dash__muted">没有待审</text>
</view>
</view>
<view class="dash__foot">
只统计<text class="dash__strong">我负责的班</text>。已交率与已通过分开算 ——
还有人没交要先催,有人没过要看材料。
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 班主任看板(《03-接口设计》§10.1,原型「班级看板」)。
*
* ## 两件事决定了这一屏对不对
*
* 1. **已交率 ≠ 通过率**。契约 §10.1 的口径铁律:`submitRate` 是
* `已提交 / 应填`,与 `approvedCount` **分开呈现**。合并之后
* 「还有人没交」(去催报)和「有人没过」(去看材料)会变成同一个数字。
* 所以界面上就写"已交率",旁边单独列"已通过"。
* 2. **只统计我负责的班**。范围由服务端按"指定初审人 → 副班主任 → 班级负责人"
* 三级回退算好;前端**不要**再按 orgId 过滤(前端过滤不了越权,
* 只会在口径不一致时制造假象)。
*/
defineOptions({ name: 'TeacherDashboard' })
interface Summary {
rosterTotal: number
submittedCount: number
approvedCount: number
pendingCount: number
unsubmittedCount: number
abnormalCount: number
submitRate: string
}
interface OrgStat {
orgId: number
orgName: string
rosterTotal: number
submittedCount: number
approvedCount: number
submitRate: string
isHead: boolean
auditCount: number
}
interface Dashboard {
summary: Summary
myOrgs: OrgStat[]
todos: { auditCount: number, abnormalCount: number, rejectedAwaitingCount: number }
deadline?: { taskId: number, taskName: string, endTime: string, remainDays: number }
}
const loading = ref(false)
const s = reactive<Summary>({
rosterTotal: 0, submittedCount: 0, approvedCount: 0,
pendingCount: 0, unsubmittedCount: 0, abnormalCount: 0, submitRate: '—'
})
const t = reactive({ auditCount: 0, abnormalCount: 0, rejectedAwaitingCount: 0 })
const orgs = ref<OrgStat[]>([])
const d = ref<Dashboard['deadline']>()
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmtDate(v?: string) {
return v ? v.replace('T', ' ').slice(0, 10) : '—'
}
const remainText = computed(() => {
const r = d.value?.remainDays
if (r === undefined || r === null) {
return ''
}
if (r < 0) {
return '(已过截止)'
}
return r === 0 ? '(今天截止)' : `(还剩 ${r} 天)`
})
const deadlineClass = computed(() => {
const r = d.value?.remainDays
if (r === undefined || r === null) {
return ''
}
if (r < 0) {
return 'dash__deadline--over'
}
return r <= 3 ? 'dash__deadline--soon' : ''
})
async function load() {
loading.value = true
try {
const data = await request<Dashboard>({
url: '/api/v1/mp/teacher/dashboard',
token: token()
})
Object.assign(s, data.summary || {})
Object.assign(t, data.todos || {})
orgs.value = data.myOrgs || []
d.value = data.deadline
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function goAudit(o?: OrgStat) {
// 角标与卡片都跳到审核卡片流;带 orgId 时只看那个班
uni.navigateTo({ url: `/pages/audit/audit${o ? `?orgId=${o.orgId}` : ''}` })
}
// onShow:从审核页返回时数字要跟着变(审完一个,"待我初审"就该少 1)
onShow(load)
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.dash {
min-height: 100vh;
padding: 24rpx;
&__wrap {
max-width: 720rpx;
margin: 0 auto;
}
&__hero {
padding: 32rpx 28rpx;
margin-bottom: 20rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #fff;
}
&__sub {
display: block;
margin-top: 10rpx;
font-size: 25rpx;
line-height: 1.6;
color: rgb(255 255 255 / 88%);
}
&__rate {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 16rpx;
}
&__rate-num {
font-size: 52rpx;
font-weight: 600;
color: #fff;
}
&__rate-label {
font-size: 24rpx;
color: rgb(255 255 255 / 85%);
}
&__todos {
display: flex;
padding: 20rpx 0;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__todo {
flex: 1;
text-align: center;
}
&__todo-num {
display: block;
font-size: 40rpx;
font-weight: 600;
color: #1b2430;
}
&__todo-label {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__deadline {
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
&--soon {
color: #8a5a12;
background: #fdf2e9;
}
&--over {
color: #a03a44;
background: #fdecee;
}
}
&__deadline-title {
display: block;
}
&__deadline-sub {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
}
&__section {
margin: 24rpx 0 12rpx;
font-size: 28rpx;
font-weight: 600;
color: #1b2430;
}
&__empty {
padding: 32rpx 28rpx;
font-size: 26rpx;
line-height: 1.7;
color: #8b95a3;
background: #fff;
border-radius: 16rpx;
}
&__card {
padding: 24rpx 28rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 16rpx;
}
&__card-head {
display: flex;
align-items: center;
gap: 12rpx;
}
&__org {
flex: 1;
font-size: 30rpx;
font-weight: 600;
color: #1b2430;
}
&__tag {
padding: 4rpx 12rpx;
font-size: 21rpx;
color: #8b95a3;
background: #f2f3f5;
border-radius: 6rpx;
&--head {
color: $primary;
background: #eaf1fd;
}
}
&__card-row {
display: flex;
justify-content: space-between;
margin-top: 12rpx;
}
&__card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
&__rate-small {
font-size: 24rpx;
color: #4a5768;
}
&__badge {
padding: 4rpx 14rpx;
font-size: 22rpx;
color: #fff;
background: $warn;
border-radius: 20rpx;
}
&__muted {
font-size: 24rpx;
color: #8b95a3;
}
&__strong {
font-weight: 600;
color: #4a5768;
}
&__foot {
margin-top: 32rpx;
font-size: 23rpx;
line-height: 1.8;
color: #b3b9c1;
}
}
</style>
+16
View File
@@ -89,6 +89,15 @@ const isError = ref(false)
const loading = ref(false) const loading = ref(false)
const title = ref('材料填报') const title = ref('材料填报')
/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
/** /**
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。 * 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
* *
@@ -146,6 +155,13 @@ function pickScene(options?: Record<string, string | undefined>): string {
async function load() { async function load() {
if (!scene.value) { if (!scene.value) {
// ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。
// 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息
// 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。
if (token()) {
uni.redirectTo({ url: '/pages/tasks/tasks' })
return
}
loading.value = false loading.value = false
isError.value = true isError.value = true
message.value = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次' message.value = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次'
+392
View File
@@ -0,0 +1,392 @@
<template>
<view class="prog">
<view class="prog__wrap">
<view class="prog__hero">
<text class="prog__task">{{ taskName || '本次填报' }}</text>
<text class="prog__percent">{{ p.progressPercent || '—' }}</text>
<text class="prog__status">{{ p.statusText || '' }}</text>
</view>
<view v-if="loading" class="prog__card">正在读取…</view>
<template v-else>
<!-- 材料清单:每一项说清"交了没有 / 有什么问题" -->
<view class="prog__card">
<view class="prog__title">材料</view>
<view v-for="m in p.materials || []" :key="m.materialCode" class="prog__mat">
<view class="prog__mat-head">
<text class="prog__mat-name">{{ m.materialName }}</text>
<text v-if="m.required" class="prog__tag prog__tag--req">必传</text>
<text class="prog__tag" :class="m.done ? 'prog__tag--ok' : 'prog__tag--todo'">
{{ m.done ? `已交 ${m.fileCount} 份` : '未交' }}
</text>
</view>
<view v-if="m.issueText" class="prog__mat-issue" :class="issueClass(m.issueLevel)">
{{ m.issueText }}
</view>
</view>
<view v-if="!(p.materials || []).length" class="prog__muted">本次没有需要上传的材料</view>
</view>
<!-- 时间轴:只列确实发生过的节点 -->
<view class="prog__card">
<view class="prog__title">进展</view>
<view v-for="(e, i) in p.timeline || []" :key="i" class="prog__tl">
<text class="prog__tl-title">{{ e.title }}</text>
<text class="prog__tl-time">{{ fmt(e.time) }}</text>
<text v-if="e.desc" class="prog__tl-desc">{{ e.desc }}</text>
</view>
<view v-if="!(p.timeline || []).length" class="prog__muted">还没有进展</view>
</view>
<view class="prog__actions">
<button v-if="p.canEdit" class="prog__btn" @click="goFill">
{{ p.status === 4 ? '修改后重新提交' : '继续填写' }}
</button>
<button v-if="p.canWithdraw" class="prog__btn prog__btn--warn" @click="withdraw">
撤回修改
</button>
<button v-if="!p.canEdit && !p.canWithdraw" class="prog__btn prog__btn--ghost" @click="reload">
刷新
</button>
</view>
<view class="prog__foot">
有疑问请联系班主任。撤回之后可以继续修改,改完再重新提交。
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 进度与问题清单(《03-接口设计》§9.12,学生端)。
*
* ## 这一屏回答"我还差什么"
*
* 材料清单是核心:每一项说清"交了没有、有什么问题"。
* **不适用本次的材料不会出现**(服务端按条件可见性过滤)——
* 否则在校生会看到一项"工作证明 未交",然后到处问老师要交什么。
*
* ## 时间轴只列确实发生过的节点
*
* 服务端只写真实节点(没提交就没有"已提交")。所以这里也不做"占位"式展示 ——
* 编一条假时间线会让学生以为系统记错了。
*/
defineOptions({ name: 'MyProgress' })
interface MaterialProgress {
materialCode: string
materialName: string
required: boolean
done: boolean
fileCount: number
issueLevel: number
issueText?: string
}
interface ProgressEvent {
time: string
title: string
desc?: string
}
interface Progress {
submissionId: number
status: number
statusText: string
progressPercent: string
materials: MaterialProgress[]
timeline: ProgressEvent[]
canEdit: boolean
canWithdraw: boolean
canSubmit: boolean
}
const loading = ref(false)
const p = ref<Partial<Progress>>({})
const taskName = ref('')
let taskId = 0
let submissionId = 0
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmt(v?: string) {
return v ? v.replace('T', ' ').slice(0, 16) : '—'
}
function issueClass(level: number) {
if (level >= 3) return 'prog__mat-issue--bad'
if (level === 2) return 'prog__mat-issue--warn'
return 'prog__mat-issue--info'
}
/**
* 拿 submissionId 的两条路:
* · 从列表带过来(href 里有 submissionId)—— 最常见
* · 只有 taskId(比如从扫码落地页跳进来)—— 先用进度接口按 taskId 找
*
* ⚠️ 第二种情况需要服务端支持"按 taskId 找我的填报单"。为避免额外接口,
* 这里改成:调用方一律带 submissionId(我的任务列表本来就带)。
* 如果没带,就提示他回列表 —— 比编一个不存在的能力好。
*/
async function load() {
if (!submissionId) {
uni.showModal({
title: '打不开进度',
content: '请从「我的填报」列表里点进来看。',
showCancel: false
})
return
}
loading.value = true
try {
p.value = await request<Progress>({
url: `/api/v1/mp/submissions/${submissionId}/progress`,
token: token()
})
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
function reload() {
load()
}
function goFill() {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${taskId}` })
}
function withdraw() {
uni.showModal({
title: '撤回这次提交?',
content: '撤回后可以继续修改,改完再重新提交。老师那边会看到你还没交。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
await request({
url: `/api/v1/mp/submissions/${submissionId}/withdraw`,
method: 'POST',
token: token()
})
uni.showToast({ title: '已撤回,可以继续修改', icon: 'none' })
await load()
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '撤回失败', icon: 'none' })
}
}
})
}
onLoad((options) => {
taskId = Number(options?.taskId || 0)
submissionId = Number(options?.submissionId || 0)
taskName.value = options?.taskName ? decodeURIComponent(String(options.taskName)) : ''
load()
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.prog {
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;
}
&__task {
display: block;
font-size: 28rpx;
color: rgb(255 255 255 / 85%);
}
&__percent {
display: block;
margin: 8rpx 0;
font-size: 56rpx;
font-weight: 600;
color: #fff;
}
&__status {
display: block;
font-size: 26rpx;
color: rgb(255 255 255 / 90%);
}
&__card {
padding: 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__title {
margin-bottom: 18rpx;
font-size: 30rpx;
font-weight: 600;
color: #1b2430;
}
&__mat {
padding: 18rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__mat-head {
display: flex;
align-items: center;
gap: 12rpx;
}
&__mat-name {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__mat-issue {
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.6;
&--bad {
color: $danger;
}
&--warn {
color: $warn;
}
&--info {
color: #8b95a3;
}
}
&__tag {
padding: 4rpx 12rpx;
font-size: 21rpx;
border-radius: 6rpx;
&--req {
color: $danger;
background: #fdecee;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--todo {
color: $warn;
background: #fdf2e9;
}
}
&__tl {
padding: 14rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__tl-title {
display: block;
font-size: 27rpx;
color: #1b2430;
}
&__tl-time {
display: block;
margin-top: 4rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__tl-desc {
display: block;
margin-top: 4rpx;
font-size: 24rpx;
color: #4a5768;
}
&__muted {
font-size: 25rpx;
color: #8b95a3;
}
&__actions {
display: flex;
gap: 16rpx;
}
&__btn {
flex: 1;
height: 84rpx;
font-size: 29rpx;
line-height: 84rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&--ghost {
color: $primary;
background: #eaf1fd;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__foot {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8b95a3;
}
}
</style>
+531
View File
@@ -0,0 +1,531 @@
<template>
<view class="tasks">
<view class="tasks__wrap">
<!-- 待办提示:学生打开这一屏最想知道的是"我还有什么没交" -->
<view class="tasks__hero">
<text class="tasks__title">我的填报</text>
<text class="tasks__sub">
共 {{ data.total }} 项,其中 <text class="tasks__strong">{{ data.todoCount }}</text> 项待完成
</text>
</view>
<!-- 消息入口:一期推送靠一次性授权,拿不到授权时消息**只在这里**,
所以入口要常驻,并且带未读角标(否则学生不会点进来) -->
<view class="tasks__msg" @click="goMessages">
<text class="tasks__msg-title">我的消息</text>
<text v-if="unread" class="tasks__msg-badge">{{ unread }}</text>
<text v-else class="tasks__msg-sub">暂时没有未读</text>
</view>
<!--
老师入口:这一屏对老师和学生是**同一个页面**(都叫"我的填报"),
因为老师也常常是家长/学员,两套导航反而让人找不到。
但老师多一个入口 —— 靠 /auth/me 的角色判断,而不是让他自己选身份。
-->
<view v-if="isTeacher" class="tasks__teacher" @click="goDashboard">
<text class="tasks__teacher-title">我是老师 · 进班级看板</text>
<text class="tasks__teacher-sub">看待审、右滑通过、看班上谁还没交</text>
</view>
<view v-if="loading" class="tasks__empty">正在读取…</view>
<view v-else-if="!data.list.length" class="tasks__empty">
<text>老师还没有把你加入任何一次认定的名单。</text>
<text class="tasks__empty-hint">
如果你已经拿到填报二维码,扫一下就能核对身份;核对不上可以在那个页面申请加入名单。
</text>
</view>
<view v-for="t in data.list" :key="t.taskId" class="tasks__card" @click="open(t)">
<view class="tasks__head">
<text class="tasks__name">{{ t.taskName }}</text>
<text class="tasks__status" :class="statusClass(t)">{{ t.statusText }}</text>
</view>
<view class="tasks__row">
<text class="tasks__label">报考</text>
<text class="tasks__value">
{{ [t.occupation, t.worktype, t.jobLevel].filter(Boolean).join(' · ') || '—' }}
</text>
</view>
<view class="tasks__row">
<text class="tasks__label">班级</text>
<text class="tasks__value">{{ t.orgName || '—' }}</text>
</view>
<view class="tasks__row">
<text class="tasks__label">截止</text>
<text class="tasks__value">{{ fmtDate(t.endTime) }}{{ remainText(t) }}</text>
</view>
<!-- 被驳回时把原因摆出来:这是学生唯一知道"哪里不对"的来源 -->
<view v-if="t.submissionStatus === 4 && t.lastRejectReason" class="tasks__reject">
<text class="tasks__strong">被驳回:</text>{{ t.lastRejectReason }}
</view>
<!-- 不能填时给出原因(服务端算好的,前端不自己判) -->
<view v-else-if="!t.canFillNow && t.fillBlockReason" class="tasks__block">
{{ t.fillBlockReason }}
</view>
<view class="tasks__actions">
<button v-if="t.canFillNow" class="tasks__btn" @click.stop="open(t)">
{{ t.submissionStatus === 4 ? '修改后重新提交' : (t.submissionStatus === 0 ? '继续填写' : '开始填写') }}
</button>
<button v-else class="tasks__btn tasks__btn--ghost" @click.stop="open(t)">
查看进度
</button>
<button v-if="t.canWithdraw" class="tasks__btn tasks__btn--warn" @click.stop="withdraw(t)">
撤回修改
</button>
</view>
</view>
<view class="tasks__footer">职业技能等级认定材料收集中台</view>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 我的任务列表(《03-接口设计》§9.1,学生端)。
*
* ## 为什么需要这一屏
*
* 在此之前学生只能靠"再扫一次二维码"回到某个任务 —— 而:
* · 老师发的是**一次性通知**,消息刷过去就找不到了;
* · 被驳回之后学生往往不知道自己还有活要干(通知渠道还没做,只能靠他自己回来看)。
* 这一屏让他随时能看到「我还有什么没交、为什么被驳回」。
*
* ## 三件事由**服务端**决定,前端不自己判
*
* `canFillNow` / `fillBlockReason` / `canWithdraw` 都是服务端算好的。
* 前端自己按任务状态判"能不能填"必然漏条件(还要看截止时间、是否在审核中、
* 是否已通过),而漏掉的表现是"按钮能点,点进去被拒"。
*/
defineOptions({ name: 'MyTasks' })
interface MyTask {
taskId: number
taskName: string
taskStatus: number
canFillNow: boolean
fillBlockReason?: string
endTime?: string
remainDays?: number
orgName?: string
occupation?: string
worktype?: string
jobLevel?: string
submissionStatus?: number
/** 有填报单时才有:撤回与看进度都要用它 */
submissionId?: number
statusText: string
rejectCount?: number
lastRejectReason?: string
canWithdraw: boolean
}
const loading = ref(false)
const isTeacher = ref(false)
const unread = ref(0)
const data = reactive<{ list: MyTask[], total: number, todoCount: number, doneCount: number }>({
list: [],
total: 0,
todoCount: 0,
doneCount: 0
})
function goMessages() {
uni.navigateTo({ url: '/pages/messages/messages' })
}
/** 未读数单独取一次(列表接口不带它 —— 那是消息中心的字段)。 */
async function loadUnread() {
try {
const d = await request<{ unreadCount: number }>({
url: '/api/v1/mp/messages?pageSize=1&read=false',
token: token()
})
unread.value = d.unreadCount || 0
} catch {
unread.value = 0
}
}
/** 老师角色(与后端 BuiltinRole 的 role_code 一致)。 */
const TEACHER_ROLES = ['HEAD_TEACHER', 'DEPUTY_TEACHER', 'AUDITOR', 'SCHOOL_ADMIN']
/**
* 判断"我是不是老师"。
*
* <p>用 {@code /auth/me} 的 roleCodes,而不是在本地存一个"身份"开关 ——
* 开关会被学生误点,而角色是服务端判定的。
* 取不到就当学生(fail-safe:宁可少给一个入口,也不要给错)。
*/
async function detectTeacher() {
try {
const me = await request<{ roleCodes?: string[] }>({
url: '/api/v1/auth/me',
token: token()
})
isTeacher.value = (me.roleCodes || []).some((r) => TEACHER_ROLES.includes(r))
} catch {
isTeacher.value = false
}
}
function goDashboard() {
uni.navigateTo({ url: '/pages/dashboard/dashboard' })
}
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
function fmtDate(v?: string) {
return v ? v.replace('T', ' ').slice(0, 10) : '—'
}
/** 截止提醒:只剩几天要说出来 —— 这是学生最容易忽略的事 */
function remainText(t: MyTask) {
if (t.remainDays === undefined || t.remainDays === null) {
return ''
}
if (t.remainDays < 0) {
return '(已过)'
}
if (t.remainDays === 0) {
return '(今天截止)'
}
return t.remainDays <= 3 ? `(还剩 ${t.remainDays} 天)` : ''
}
function statusClass(t: MyTask) {
const s = t.submissionStatus
if (s === 3) return 'tasks__status--ok'
if (s === 4) return 'tasks__status--bad'
if (s === 1 || s === 2) return 'tasks__status--wait'
return 'tasks__status--todo'
}
async function load() {
loading.value = true
try {
const d = await request<{ list: MyTask[], total: number, todoCount: number, doneCount: number }>({
url: '/api/v1/mp/tasks',
token: token()
})
data.list = d.list || []
data.total = d.total || 0
data.todoCount = d.todoCount || 0
data.doneCount = d.doneCount || 0
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '读取失败', icon: 'none' })
} finally {
loading.value = false
}
}
/**
* 打开一个任务。
*
* 两条路:
* · 能填 → 直接进填报页(带 taskId;H5 的页面跳转不保留参数,所以要显式拼)
* · 不能填 → 进进度页看状态(那里有材料清单、问题与时间轴)
*/
function open(t: MyTask) {
if (t.canFillNow) {
uni.navigateTo({ url: `/pages/fill/fill?taskId=${t.taskId}` })
} else {
// 带 submissionId:进度页要它才能拉数据(列表本来就带了,不用再查一次)
uni.navigateTo({
url: `/pages/progress/progress?taskId=${t.taskId}`
+ `&submissionId=${t.submissionId || 0}`
+ `&taskName=${encodeURIComponent(t.taskName || '')}`
})
}
}
function withdraw(t: MyTask) {
// submissionId 由列表直接给出(服务端已带)—— 不再去"按 taskId 找填报单",
// 那需要额外接口,而列表里本来就有这个字段
if (!t.submissionId) {
uni.showToast({ title: '找不到填报单,请刷新后重试', icon: 'none' })
return
}
uni.showModal({
title: '撤回这次提交?',
content: '撤回后可以继续修改,改完再重新提交。老师那边会看到你还没交。',
success: async (res) => {
if (!res.confirm) {
return
}
try {
await request({
url: `/api/v1/mp/submissions/${t.submissionId}/withdraw`,
method: 'POST',
token: token()
})
uni.showToast({ title: '已撤回,可以继续修改', icon: 'none' })
await load()
} catch (e) {
uni.showToast({ title: e instanceof Error ? e.message : '撤回失败', icon: 'none' })
}
}
})
}
// 用 onShow 而不是 onLoad:从填报页返回时列表要刷新(状态可能已经变了)
onShow(() => {
load()
loadUnread()
if (!isTeacher.value) {
detectTeacher()
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$warn: #ed7b2f;
$page-bg: #f3f3f3;
page {
background-color: $page-bg;
}
.tasks {
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;
}
&__title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: #fff;
}
&__sub {
display: block;
margin-top: 10rpx;
font-size: 26rpx;
color: rgb(255 255 255 / 85%);
}
&__strong {
font-weight: 600;
}
&__empty {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 40rpx 28rpx;
font-size: 28rpx;
line-height: 1.7;
color: #4a5768;
background: #fff;
border-radius: 16rpx;
}
&__empty-hint {
font-size: 24rpx;
color: #8b95a3;
}
&__card {
padding: 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
&__name {
flex: 1;
padding-right: 16rpx;
font-size: 32rpx;
font-weight: 600;
color: #1b2430;
}
&__status {
flex: none;
padding: 4rpx 14rpx;
font-size: 22rpx;
border-radius: 8rpx;
&--todo {
color: $warn;
background: #fdf2e9;
}
&--wait {
color: $primary;
background: #eaf1fd;
}
&--ok {
color: $success;
background: #e8f6f0;
}
&--bad {
color: $danger;
background: #fdecee;
}
}
&__row {
display: flex;
justify-content: space-between;
padding: 8rpx 0;
}
&__label {
font-size: 26rpx;
color: #8b95a3;
}
&__value {
flex: 1;
font-size: 26rpx;
color: #1b2430;
text-align: right;
}
&__reject {
padding: 18rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.7;
color: #a03a44;
background: #fdecee;
border-radius: 12rpx;
}
&__block {
padding: 18rpx 20rpx;
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.7;
color: #4a5768;
background: #f7f8fa;
border-radius: 12rpx;
}
&__actions {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
&__btn {
flex: 1;
height: 80rpx;
font-size: 28rpx;
line-height: 80rpx;
color: #fff;
background: $primary;
border-radius: 12rpx;
&--ghost {
color: $primary;
background: #eaf1fd;
}
&--warn {
color: $warn;
background: #fdf2e9;
}
}
&__msg {
display: flex;
align-items: center;
gap: 16rpx;
padding: 22rpx 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 16rpx;
}
&__msg-title {
flex: 1;
font-size: 28rpx;
color: #1b2430;
}
&__msg-badge {
min-width: 36rpx;
padding: 2rpx 12rpx;
font-size: 22rpx;
color: #fff;
text-align: center;
background: $danger;
border-radius: 18rpx;
}
&__msg-sub {
font-size: 23rpx;
color: #b3b9c1;
}
&__teacher {
padding: 24rpx 28rpx;
margin-bottom: 20rpx;
background: #fff;
border-left: 8rpx solid $primary;
border-radius: 16rpx;
}
&__teacher-title {
display: block;
font-size: 29rpx;
font-weight: 600;
color: $primary;
}
&__teacher-sub {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: #8b95a3;
}
&__footer {
margin-top: 40rpx;
font-size: 24rpx;
color: #b3b9c1;
text-align: center;
}
}
</style>