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:
+56
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -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);
|
||||||
|
}
|
||||||
+424
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+225
@@ -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 <> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
-15
@@ -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) {
|
||||||
|
|||||||
+124
@@ -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 + "%";
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次'
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user