From 7523ebe789b099a470e5e7ce0176882ca45dba42 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 09:32:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(task):=20M1-02=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E4=B8=8E=E7=8F=AD=E4=B8=BB=E4=BB=BB=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 —— 班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。 后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用); 前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示 「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引 (BLOCKING_HINTS),而不是只说"未设置覆盖班级"。 三条守住的规则: 1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除", 不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己 的任务上,B 校的班主任就出现在 A 校的审批流里。 2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权, 那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把 "谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。 3) 移除是软移除(status=0),且「移除再加回」必须复用原行。 后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status 条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。 本轮修掉的 3 个真问题: 1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而 CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。 基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active 和一段基于错误前提的代码,已全部撤掉并更正注释。 教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。 2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾: DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在 PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs 名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职 表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警 变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉, 而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值, 并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。 3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package, target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行 了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4, 于是"两个 v4"→"checksum 冲突",排查绕了好几圈。 顺带修掉: · 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进 「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。 · defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。 我此前写的 5 个组件都是被禁的 tuple 形式,一并改正: OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。 · e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally 里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号 留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。 其余三条 E2E 脚本仍是老样子,下一轮一并改。 验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、 e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言; 前端 typecheck / eslint / lint:style 0 错、build 通过; 浏览器实测抽屉的开合、树的勾选规则与保存落库。 --- .../task/controller/TaskController.java | 26 + .../fill/module/task/dto/TaskOrgSaveReq.java | 68 +++ .../fill/module/task/dto/TaskOrgVO.java | 53 ++ .../module/task/service/TaskOrgService.java | 460 ++++++++++++++ .../V4__task_org_expected_count_nullable.sql | 37 ++ docs/db/schema-postgresql.sql | 4 +- docs/交接摘要.md | 70 ++- docs/数据信息填报系统PRD.md | 4 +- frontend-pc/src/apis/xt/task.ts | 62 ++ frontend-pc/src/views/org/OrgFormModal.vue | 4 +- frontend-pc/src/views/task/TaskFormModal.vue | 4 +- frontend-pc/src/views/task/TaskOrgDrawer.vue | 360 +++++++++++ frontend-pc/src/views/task/index.vue | 59 +- frontend-pc/src/views/user/UserFormModal.vue | 4 +- .../src/views/user/UserImportModal.vue | 4 +- scripts/e2e-m1-orgs.py | 562 ++++++++++++++++++ 16 files changed, 1756 insertions(+), 25 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgSaveReq.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskOrgService.java create mode 100644 backend/xt-server/src/main/resources/db/migration/V4__task_org_expected_count_nullable.sql create mode 100644 frontend-pc/src/views/task/TaskOrgDrawer.vue create mode 100644 scripts/e2e-m1-orgs.py diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java index 807c63e..adb499e 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/controller/TaskController.java @@ -4,8 +4,11 @@ import cn.tianbaotong.fill.common.api.ApiResult; import cn.tianbaotong.fill.common.api.PageResult; import cn.tianbaotong.fill.module.task.dto.TaskActionReq; import cn.tianbaotong.fill.module.task.dto.TaskCreateVO; +import cn.tianbaotong.fill.module.task.dto.TaskOrgSaveReq; +import cn.tianbaotong.fill.module.task.dto.TaskOrgVO; import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq; import cn.tianbaotong.fill.module.task.dto.TaskVO; +import cn.tianbaotong.fill.module.task.service.TaskOrgService; import cn.tianbaotong.fill.module.task.service.TaskService; import cn.tianbaotong.fill.security.PermissionDef; import cn.tianbaotong.fill.security.RequirePerm; @@ -23,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 收集任务(《03-接口设计》§7.1 ~ §7.5)。 * @@ -36,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController; public class TaskController { private final TaskService taskService; + private final TaskOrgService taskOrgService; @GetMapping @RequirePerm(PermissionDef.TASK_VIEW) @@ -67,6 +73,26 @@ public class TaskController { return ApiResult.ok(taskService.detail(id)); } + @GetMapping("/{id}/orgs") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "任务覆盖班级与班主任", + description = "只返回生效的覆盖关系。headUserId 为空表示取该班负责人(sys_org.leader_user_id)," + + "不是「没人审」") + public ApiResult> listOrgs(@PathVariable Long id) { + return ApiResult.ok(taskOrgService.list(id)); + } + + @PutMapping("/{id}/orgs") + @RequirePerm(PermissionDef.TASK_MANAGE) + @Operation(summary = "设置任务覆盖班级与班主任", + description = "整体替换语义:请求体就是最终答案,不在列表里的班级会被软移除(status=0)。" + + "班级必须是 orgType=3;初审人必须是本租户内已启用且持有班主任/副班主任角色的账号。" + + "草稿、收集中、已暂停状态可改") + public ApiResult saveOrgs(@PathVariable Long id, @Valid @RequestBody TaskOrgSaveReq req) { + taskOrgService.save(id, req); + return ApiResult.ok(); + } + @PutMapping("/{id}") @RequirePerm(PermissionDef.TASK_MANAGE) @Operation(summary = "修改任务", description = "已发布的任务不可改收集模式与认定类型") diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgSaveReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgSaveReq.java new file mode 100644 index 0000000..f20d2f6 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgSaveReq.java @@ -0,0 +1,68 @@ +package cn.tianbaotong.fill.module.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 设置任务覆盖班级与班主任(《03-接口设计》§7.6 的 PUT 请求)。 + * + *

整体替换语义:请求体就是"这个任务最终覆盖哪些班"的完整答案。 + * 不在列表里的班会被置为"已移除"({@code status = 0}),而不是物理删除 —— + * 因为名册、提交单都可能挂在那个班下面,删掉关系会让历史数据丢失归属。 + * + *

为什么用整体替换而不是"逐个增删":勾选框的交互天然是"提交最终状态"。 + * 做成增量接口,界面就得自己算差集,而只要有一次算错, + * 就会出现"我明明取消勾选了,它还留在列表里"—— 这种不一致查起来极费时间。 + */ +@Getter +@Setter +@Schema(description = "设置任务覆盖班级与班主任") +public class TaskOrgSaveReq { + + /** + * 覆盖的班级列表。 + * + *

允许为空数组 —— 那是"暂时清空覆盖范围"的合法中间状态, + * 发布时会被 {@code 40019} 拦住。**不在这里拦**,是因为 + * 让人「先清空再重新勾选」比「必须一次点对」友好得多。 + */ + @NotNull(message = "请提供覆盖班级列表") + @Valid + @Schema(description = "覆盖的班级列表;空数组表示清空(发布时会被 40019 拦住)") + private List orgs = new ArrayList<>(); + + /** 一个班一行。 */ + @Getter + @Setter + @Schema(description = "覆盖班级") + public static class Item { + + @NotNull(message = "缺少班级 id") + @Schema(description = "班级(sys_org.id,orgType 必须为 3)") + private Long orgId; + + /** + * 该班初审人(班主任)。 + * + *

可空 —— 为空表示取班级负责人。指定时必须是本租户内 + * 持有班主任或副班主任角色的启用账号(见 {@code TaskOrgService})。 + */ + @Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id") + private Long headUserId; + + /** 副班主任。同一人不能既是主又是副(服务端会拒)。 */ + @Schema(description = "副班主任") + private List deputyUserIds = new ArrayList<>(); + + @Min(value = 0, message = "应填人数不能为负") + @Schema(description = "应填人数,用于与名册实际人数比对告警") + private Integer expectedCount; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgVO.java new file mode 100644 index 0000000..34c9ed8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/dto/TaskOrgVO.java @@ -0,0 +1,53 @@ +package cn.tianbaotong.fill.module.task.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 任务覆盖的班级与班主任(《03-接口设计》§7.6)。 + * + *

一行 = 一个班。初审人(班主任)是按班指定的,不是按任务 —— + * 这是产品的核心差别:任务覆盖 20 个班,就有 20 个班主任各审各的, + * 而不是一个管理员审所有班。 + * + *

⚠️ {@code headUserId} 为空的语义是「取该班负责人」, + * 不是「没人审」。判定顺序与 DDL 注释一致: + * 本表的 {@code head_user_id} 优先,为空则回退 {@code sys_org.leader_user_id}。 + * 所以界面上不该把空显示成「未指定」就完事 —— 那会让管理员以为漏配了, + * 然后在几百个班上机械地点几百次,而它们本来就等于班级负责人。 + */ +@Getter +@Setter +@Schema(description = "任务覆盖班级与班主任") +public class TaskOrgVO { + + private Long orgId; + + private String orgName; + + /** 该班初审人;为空表示取 {@code sys_org.leader_user_id}。 */ + private Long headUserId; + + private String headUserName; + + /** 副班主任 / 协办人。 */ + private List deputyUserIds = new ArrayList<>(); + + private List deputyUserNames = new ArrayList<>(); + + /** + * 应填人数。 + * + *

它的用途是与名册实际人数比对,不一致时在任务详情告警 + * (比如某班应有 28 人、名册里却有 31 人 —— 那说明名册导错了或多导了)。 + * 所以它是个"期望值",不是"实际值",两者都要能看见才有意义。 + */ + private Integer expectedCount; + + /** 固定为 {@code 1}(本接口只返回生效的覆盖关系)。 */ + private Integer status; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskOrgService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskOrgService.java new file mode 100644 index 0000000..12bd88a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/task/service/TaskOrgService.java @@ -0,0 +1,460 @@ +package cn.tianbaotong.fill.module.task.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.org.entity.SysOrg; +import cn.tianbaotong.fill.module.org.mapper.OrgMapper; +import cn.tianbaotong.fill.module.task.dto.TaskOrgSaveReq; +import cn.tianbaotong.fill.module.task.dto.TaskOrgVO; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskOrgMapper; +import cn.tianbaotong.fill.module.user.entity.SysUser; +import cn.tianbaotong.fill.module.user.mapper.UserMapper; +import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper; +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.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 任务覆盖班级与班主任(《03-接口设计》§7.6)。 + * + *

这张表回答两个问题:这次收集涉及哪些班、每个班谁初审。 + * 它是发布前置校验的 5 项之一({@code 40019}),也是数据隔离的边界 —— + * 班主任能看到哪些任务、能审哪些单子,全部由这里决定。 + * + *

三条必须守住的规则: + *

    + *
  1. 班级与班主任都必须在本租户内。跨租户的 id 一律当作"不存在"处理, + * 不透露"它存在但不属于你"。这是最容易漏的一环 —— + * 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任 + * 就出现在 A 校的审批流里。
  2. + *
  3. 初审人必须是班主任({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。 + * 指定初审人本质上是一次授权:那个人将能看到该班学生的身份证号、 + * 学籍材料。放开这一条等于把授权能力变成任意授权。
  4. + *
  5. 移除是软移除({@code status = 0}),不是物理删除。 + * 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。
  6. + *
  7. 「移除再加回」必须复用原来那一行。表上有 + * {@code uk_task_org UNIQUE (task_id, org_id)},而这个约束不带 status 条件 —— + * 一个班在同一任务下只能存在一行(生效或已移除都算)。 + * 所以 {@code persist} 里要先按 orgId 把历史行找出来复用, + * 直接 insert 会违反唯一约束。这一点很容易漏:单测"/移除后重新添加"就会炸。
  8. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskOrgService { + + /** 可以被指定为初审人的角色。 */ + private static final Set TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER", "DEPUTY_TEACHER"); + + /** + * 覆盖范围可改的任务状态:草稿 / 收集中 / 已暂停。 + * + *

「收集中」也允许改,是刻意的:中途加一个班是真实需求 + * ("这个班漏报了,补进来")。已截止(2)之后没意义,已归档(3)整体只读。 + */ + private static final Set EDITABLE_TASK_STATUS = Set.of( + CollectTask.STATUS_DRAFT, + CollectTask.STATUS_COLLECTING, + CollectTask.STATUS_PAUSED); + + /** 班级的 {@code sys_org.org_type}。一期只做学校收集,覆盖范围只能是班级。 */ + private static final int ORG_TYPE_CLASS = 3; + + private static final int STATUS_ACTIVE = 1; + private static final int STATUS_REMOVED = 0; + + private final TaskMapper taskMapper; + private final TaskOrgMapper taskOrgMapper; + private final OrgMapper orgMapper; + private final UserMapper userMapper; + private final UserRoleMapper userRoleMapper; + + // ── 查询 ────────────────────────────────────── + + /** + * 任务覆盖的班级与班主任。 + * + *

只返回生效的({@code status = 1})。已移除的历史行不返回 —— + * 界面上的"覆盖范围"是当下的事实,历史轨迹要查库或看审计日志。 + */ + public List list(Long taskId) { + requireTask(taskId); + List rows = taskOrgMapper.selectList( + Wrappers.lambdaQuery() + .eq(CollectTaskOrg::getTaskId, taskId) + .eq(CollectTaskOrg::getStatus, STATUS_ACTIVE) + .orderByAsc(CollectTaskOrg::getId)); + return assemble(rows); + } + + // ── 保存(整体替换)────────────────────────── + + /** + * 设置覆盖班级与班主任。整体替换语义:请求体就是最终答案。 + * + *

为什么是整体替换而不是"逐个增删":勾选框的交互天然是"提交最终状态"。 + * 做成增量接口,界面就得自己算差集,而只要有一次算错, + * 就会出现"我明明取消勾选了,它还留在列表里"。 + */ + @Transactional(rollbackFor = Exception.class) + public void save(Long taskId, TaskOrgSaveReq req) { + CollectTask task = requireTask(taskId); + int status = task.getStatus() == null ? -1 : task.getStatus(); + if (!EDITABLE_TASK_STATUS.contains(status)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "任务已" + statusText(status) + ",覆盖班级不可再改"); + } + + Map> deputiesOf = normalizeAndValidate(req); + + persist(taskId, req, deputiesOf); + + log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId, + req.getOrgs().size()); + } + + /** + * 参数归一化与全部校验。 + * + * @return 每个班归一化后的副班主任 id 集合(已去重、去掉 null、并校验过与主班主任不重复) + */ + private Map> normalizeAndValidate(TaskOrgSaveReq req) { + // 同一个班只能出现一次。不拦的话会因为唯一索引报错,而错误信息 + // 完全指不出是哪个班的问题(PG 只说 duplicate key) + Map wanted = new LinkedHashMap<>(); + for (TaskOrgSaveReq.Item item : req.getOrgs()) { + if (wanted.putIfAbsent(item.getOrgId(), item) != null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "同一个班级出现了两次(orgId=" + item.getOrgId() + ")"); + } + } + + assertOrgsAreClasses(wanted.keySet()); + + Map> deputiesOf = new LinkedHashMap<>(); + Set allUserIds = new LinkedHashSet<>(); + for (Map.Entry entry : wanted.entrySet()) { + TaskOrgSaveReq.Item item = entry.getValue(); + LinkedHashSet deputies = new LinkedHashSet<>(); + if (item.getDeputyUserIds() != null) { + deputies.addAll(item.getDeputyUserIds()); + } + deputies.removeIf(Objects::isNull); + + if (item.getHeadUserId() != null && deputies.contains(item.getHeadUserId())) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "同一个班的主班主任与副班主任不能是同一人(userId=" + item.getHeadUserId() + ")"); + } + deputiesOf.put(entry.getKey(), deputies); + + if (item.getHeadUserId() != null) { + allUserIds.add(item.getHeadUserId()); + } + allUserIds.addAll(deputies); + } + + if (!allUserIds.isEmpty()) { + assertAssignableTeachers(allUserIds); + } + return deputiesOf; + } + + /** 班级必须存在、在同一个租户内、且确实是"班级"。 */ + private void assertOrgsAreClasses(Collection orgIds) { + if (orgIds.isEmpty()) { + return; + } + Map found = new HashMap<>(); + // 租户过滤由 TenantEntity + 租户拦截器保证:别的租户的班会直接查不到, + // 于是下面统一报"不存在或已删除" —— 不透露"它存在但不属于你" + for (SysOrg org : orgMapper.selectByIds(orgIds)) { + found.put(org.getId(), org); + } + for (Long orgId : orgIds) { + SysOrg org = found.get(orgId); + if (org == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "班级不存在或已删除(orgId=" + orgId + ")"); + } + if (org.getOrgType() == null || org.getOrgType() != ORG_TYPE_CLASS) { + // 把整个"学校"或"专业部"当成一个班是最容易犯的错: + // 后果是全校学生都成了本次任务的应填范围,而界面上只显示一行,看不出异常 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "「" + org.getOrgName() + "」是" + orgTypeText(org.getOrgType()) + + ",不是班级;任务的覆盖范围只能选到班级一层"); + } + } + } + + /** + * 初审人必须是本租户内、已启用、且持有班主任角色的账号。 + * + *

为什么角色校验不能省:指定初审人是一次授权 —— + * 那个人将能看到该班学生的身份证号与学籍材料。不做角色校验, + * 就等于把"谁能看学生隐私"这件事变成任意授权。 + */ + private void assertAssignableTeachers(Set userIds) { + Map users = new HashMap<>(); + for (SysUser user : userMapper.selectByIds(userIds)) { + users.put(user.getId(), user); + } + for (Long userId : userIds) { + SysUser user = users.get(userId); + if (user == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "指定的班主任不存在或已删除(userId=" + userId + ")"); + } + if (user.getStatus() == null || user.getStatus() != 1) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "「" + user.getRealName() + "」已停用,不能作为初审人"); + } + } + + Map> rolesOf = new HashMap<>(); + for (UserRoleMapper.UserRoleRow row : userRoleMapper.selectRoleCodesByUserIds(new ArrayList<>(userIds))) { + rolesOf.computeIfAbsent(row.getUserId(), key -> new HashSet<>()).add(row.getRoleCode()); + } + for (Long userId : userIds) { + Set roles = rolesOf.getOrDefault(userId, Set.of()); + if (roles.stream().noneMatch(TEACHER_ROLE_CODES::contains)) { + SysUser user = users.get(userId); + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "「" + user.getRealName() + "」不是班主任,不能作为初审人;" + + "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色"); + } + } + } + + /** + * 落库:软移除不在请求里的班,新增或更新请求里的班。 + * + *

⚠️ 更新必须用 {@code UpdateWrapper} 而不是 {@code updateById}。 + * {@code updateById} 对 {@code null} 字段的策略是"跳过不写", + * 而"把主班主任清空(改回取班级负责人)"正是一次把字段置为 null 的操作 —— + * 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现, + * 这种"保存成功但没保存"是最难查的一类 bug。 + */ + private void persist(Long taskId, TaskOrgSaveReq req, Map> deputiesOf) { + Map wanted = new LinkedHashMap<>(); + for (TaskOrgSaveReq.Item item : req.getOrgs()) { + wanted.put(item.getOrgId(), item); + } + + List existing = taskOrgMapper.selectList( + Wrappers.lambdaQuery().eq(CollectTaskOrg::getTaskId, taskId)); + + Map activeByOrg = new HashMap<>(); + Map removedByOrg = new HashMap<>(); + for (CollectTaskOrg row : existing) { + if (row.getStatus() != null && row.getStatus() == STATUS_ACTIVE) { + activeByOrg.put(row.getOrgId(), row); + } else { + removedByOrg.put(row.getOrgId(), row); + } + } + + // 1) 不在请求里的生效行 → 软移除 + for (Map.Entry entry : activeByOrg.entrySet()) { + if (!wanted.containsKey(entry.getKey())) { + taskOrgMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectTaskOrg::getId, entry.getValue().getId()) + .set(CollectTaskOrg::getStatus, STATUS_REMOVED)); + } + } + + // 2) 请求里的 → 新增或更新 + for (Map.Entry entry : wanted.entrySet()) { + Long orgId = entry.getKey(); + TaskOrgSaveReq.Item item = entry.getValue(); + String deputies = joinIds(deputiesOf.get(orgId)); + + CollectTaskOrg reuse = activeByOrg.get(orgId); + if (reuse == null) { + // ⚠️ 被移除过又加回来时,**必须复用那一行**,不能插新行。 + // 表上有 uk_task_org UNIQUE (task_id, org_id),而它**不带 status 条件** —— + // 一个班在同一任务下只能有一行,无论生效与否。 + // 直接 insert 会立刻违反唯一约束(表现为 500,且错误信息看不出是"加回"引起的)。 + reuse = removedByOrg.get(orgId); + } + + if (reuse == null) { + CollectTaskOrg insert = new CollectTaskOrg(); + insert.setTaskId(taskId); + insert.setOrgId(orgId); + insert.setHeadUserId(item.getHeadUserId()); + insert.setDeputyUserIds(deputies); + // expectedCount 为 null 就是"未设置",**不能写成 0** —— + // 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让 + // "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上, + // 告警变噪音后就没人看,真正对不上的那次反而被漏掉。 + insert.setExpectedCount(item.getExpectedCount()); + insert.setStatus(STATUS_ACTIVE); + taskOrgMapper.insert(insert); + } else { + taskOrgMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectTaskOrg::getId, reuse.getId()) + .set(CollectTaskOrg::getHeadUserId, item.getHeadUserId()) + .set(CollectTaskOrg::getDeputyUserIds, deputies) + .set(CollectTaskOrg::getExpectedCount, item.getExpectedCount()) + .set(CollectTaskOrg::getStatus, STATUS_ACTIVE)); + } + } + } + + // ── VO 装配 ─────────────────────────────────── + + private List assemble(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + + Set orgIds = new LinkedHashSet<>(); + Set userIds = new LinkedHashSet<>(); + Map> deputiesOf = new LinkedHashMap<>(); + for (CollectTaskOrg row : rows) { + orgIds.add(row.getOrgId()); + List deputies = parseIds(row.getDeputyUserIds()); + deputiesOf.put(row.getId(), deputies); + if (row.getHeadUserId() != null) { + userIds.add(row.getHeadUserId()); + } + userIds.addAll(deputies); + } + + Map orgNames = loadOrgNames(orgIds); + Map userNames = loadUserNames(userIds); + + List out = new ArrayList<>(rows.size()); + for (CollectTaskOrg row : rows) { + TaskOrgVO vo = new TaskOrgVO(); + vo.setOrgId(row.getOrgId()); + vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)")); + vo.setHeadUserId(row.getHeadUserId()); + vo.setHeadUserName(userNames.get(row.getHeadUserId())); + List deputies = deputiesOf.get(row.getId()); + vo.setDeputyUserIds(deputies); + vo.setDeputyUserNames(deputies.stream() + .map(id -> userNames.getOrDefault(id, "(账号已删除)")) + .toList()); + vo.setExpectedCount(row.getExpectedCount()); + vo.setStatus(row.getStatus()); + out.add(vo); + } + return out; + } + + private Map loadOrgNames(Set orgIds) { + Map map = new HashMap<>(); + if (orgIds.isEmpty()) { + return map; + } + for (SysOrg org : orgMapper.selectByIds(orgIds)) { + map.put(org.getId(), org.getOrgName()); + } + return map; + } + + private Map loadUserNames(Set userIds) { + Map map = new HashMap<>(); + if (userIds.isEmpty()) { + return map; + } + for (SysUser user : userMapper.selectByIds(userIds)) { + // 用"姓名(账号)":学校里同名同姓很常见,只显示姓名会导致 + // 选人时无法区分,而选错了就是把别人的班交给了错误的人 + map.put(user.getId(), user.getUsername() == null + ? user.getRealName() + : user.getRealName() + "(" + user.getUsername() + ")"); + } + return map; + } + + // ── 小工具 ──────────────────────────────────── + + private CollectTask requireTask(Long taskId) { + CollectTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + return task; + } + + /** + * 逗号分隔 id 的序列化。 + * + *

存的是首尾都带逗号的形式({@code ,3,7,}),不是 {@code 3,7}。 + * 原因:用 {@code LIKE '%,3,%'} 精确匹配时,裸逗号分隔的 {@code 3,17} + * 会被子串误命中,让"莫名其妙的协办人能看到别人的班"。 + */ + private static String joinIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(","); + for (Long id : ids) { + sb.append(id).append(','); + } + return sb.toString(); + } + + private static List parseIds(String raw) { + List out = new ArrayList<>(); + if (raw == null || raw.isBlank()) { + return out; + } + for (String part : raw.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + continue; + } + try { + out.add(Long.valueOf(trimmed)); + } catch (NumberFormatException e) { + // 库里存了脏数据不该让整个列表 500;跳过并在日志里留痕 + log.warn("collect_task_org.deputy_user_ids 含非法 id:{}", trimmed); + } + } + return out; + } + + private static String statusText(int status) { + return switch (status) { + case CollectTask.STATUS_DRAFT -> "草稿"; + case CollectTask.STATUS_COLLECTING -> "收集中"; + case CollectTask.STATUS_CLOSED -> "截止"; + case CollectTask.STATUS_ARCHIVED -> "归档"; + case CollectTask.STATUS_PAUSED -> "暂停"; + default -> "未知状态"; + }; + } + + private static String orgTypeText(Integer orgType) { + if (orgType == null) { + return "未知类型"; + } + return switch (orgType) { + case 1 -> "学校"; + case 2 -> "院系/专业"; + case 3 -> "班级"; + case 4 -> "报名点"; + default -> "未知类型(" + orgType + ")"; + }; + } +} diff --git a/backend/xt-server/src/main/resources/db/migration/V4__task_org_expected_count_nullable.sql b/backend/xt-server/src/main/resources/db/migration/V4__task_org_expected_count_nullable.sql new file mode 100644 index 0000000..ef7b4d4 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V4__task_org_expected_count_nullable.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- collect_task_org.expected_count:允许为空(NULL ≠ 0) +-- ============================================================================ +-- +-- 背景:V1 把它建成了 `integer NOT NULL DEFAULT 0`,DDL 注释还写着 +-- '应填人数(冗余,由名册汇总)' +-- 也就是"系统从名册算出来的值"。但这与《03-接口设计》§7.6 矛盾: +-- · 它出现在 PUT /tasks/{id}/orgs 的**请求体**里(管理员填的,不是系统算的) +-- · 原话是"expectedCount 用于与名册实际人数比对,不一致时在任务详情告警" +-- 只有"管理员填的期望值 vs 名册实际值"才需要比对。若它等于名册汇总结果, +-- 比对永远相等,这条告警不可能触发,也就没有存在的意义。 +-- 所以权威口径以《03-接口设计》为准:它由管理员填写,是"期望值"。 +-- +-- 为什么必须允许 NULL(而不是继续用 0 表示"没填"): +-- 0 是一个**有意义的业务值**("这个班预期 0 人"), +-- 拿它兼职表示"未设置"会让两者的区别永久丢失。具体后果是: +-- 管理员没填这个数字的班,比对时会被算成"期望 0 人、实际 31 人", +-- 于是**每个没填的班都告警**。告警一旦变成噪音就没人看了, +-- 真正"应填 28 人、名册却有 31 人"(名册导错了)反而会被漏掉 —— +-- 而这条告警的全部价值就在那一次真正的对不上。 +-- +-- 为什么同时 DROP DEFAULT: +-- 留着 DEFAULT 0,新增行仍会悄悄写 0,问题原样保留。 +-- +-- ⚠️ 历史行里已有的 0 保持原样,不动。因为无法判断它是"当时没填"还是 +-- "确实期望 0 人" —— 猜错会把告警吃掉。开发者本地的测试数据由 +-- scripts/dev-clean-*.py 清理,不需要在这里猜测。 +-- ============================================================================ + +ALTER TABLE collect_task_org + ALTER COLUMN expected_count DROP NOT NULL; + +ALTER TABLE collect_task_org + ALTER COLUMN expected_count DROP DEFAULT; + +COMMENT ON COLUMN collect_task_org.expected_count IS + '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同'; diff --git a/docs/db/schema-postgresql.sql b/docs/db/schema-postgresql.sql index 85b6a37..28dabed 100644 --- a/docs/db/schema-postgresql.sql +++ b/docs/db/schema-postgresql.sql @@ -170,7 +170,7 @@ CREATE TABLE collect_task_org ( org_id bigint NOT NULL, head_user_id bigint, deputy_user_ids varchar(256), - expected_count integer NOT NULL DEFAULT 0, + expected_count integer, status smallint NOT NULL DEFAULT 1, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), @@ -180,7 +180,7 @@ CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id); COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定'; COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id'; COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔'; -COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)'; +COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同'; COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效'; CREATE TABLE collect_form_schema ( diff --git a/docs/交接摘要.md b/docs/交接摘要.md index c0bd2d3..cda8804 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -48,8 +48,9 @@ | M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** | | M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** | | **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** | -| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务** | -| 待做 | M1 剩余(M1-02 覆盖班级、M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 | +| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** | +| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** | +| 待做 | M1 剩余(M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 | --- @@ -153,6 +154,71 @@ --- +## 一·六、M1-02 覆盖班级与班主任(2026-09-29 上午,未提交) + +> 范围:`GET/PUT /api/v1/tasks/{id}/orgs`(《03-接口设计》§7.6); +> 前端「收集任务 → 操作 → 配置班级」抽屉(勾选班级 + 主/副班主任 + 应填人数)。 + +**验收**:`python scripts/e2e-m1-orgs.py` → **48/48**; +`e2e-m1-task.py` 71/71、`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过; +前端 `typecheck` / `eslint` / `lint:style` 0 错、`build` 通过。 + +### 三条守住的规则(改动时当成规则) + +| # | 规则 | 为什么 | +|:--:|---|---| +| 1 | **班级与初审人都必须在本租户内** | 跨租户的 id 一律当作"不存在或已删除",不透露"它存在但不属于你"。漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,B 校的班主任就出现在 A 校的审批流里 | +| 2 | **初审人必须持有班主任/副班主任角色** | 「指定初审人」本质上是一次**授权** —— 那个人将能看到该班学生的身份证号与学籍材料。不做角色校验就等于把"谁能看学生隐私"变成任意授权。报错要给出下一步(去账号管理分配角色),不能只说"校验失败" | +| 3 | **移除是软移除**(`status = 0`),且**"移除再加回"必须复用原行** | 名册与提交单挂在覆盖关系下,物理删会让历史数据失去归属。而复用是**必须**的(不是优化):`uk_task_org UNIQUE (task_id, org_id)` 不带 status 条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束 | + +### 本轮修掉的 3 个真问题 + +| # | 问题 | 后果与修法 | +|:--:|---|---| +| 1 | **我误判 `uk_task_org` 不存在** | 先前的 grep 只匹配含 `collect_task_org` 字样的行,而 `CONSTRAINT uk_task_org UNIQUE (task_id, org_id)` 那行**没有表名** —— 于是漏掉了,还基于"没有唯一约束"的错误前提写了一个多余的部分唯一索引 `uk_task_org_active`。已撤掉(DROP INDEX + 删 flyway 历史行 + 删文件),并更正了注释:**"复用已移除的行"不是优化而是必须**。教训:**查约束要查 `CREATE TABLE` 整块,不能只 grep 表名** | +| 2 | **`collect_task_org.expected_count` 是 `NOT NULL DEFAULT 0`,与接口契约矛盾** | DDL 注释写的是"冗余,由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在 **PUT 请求体**里,且说"用于**与名册实际人数比对**,不一致时告警" —— 只有"管理员填的期望值 vs 名册实际值"才需要比对。而 `0` 是有意义的业务值("预期 0 人"),拿它兼职表示"没填"会让**每个没填的班**都触发"应填 0 人、实际 31 人"的假告警;告警变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉 —— 而这条告警的全部价值就在那一次。已由 `V4__task_org_expected_count_nullable.sql` 改为**可空 + 去掉默认值**,并同步更正了 `docs/db/schema-postgresql.sql` 与 PRD 里的内联 DDL。**NULL ≠ 0 这条规则适用于所有"可选数字"字段** | +| 3 | **删掉迁移文件后必须 `mvn clean`,否则它会"复活"** | 我删掉 `V4__task_org_active_unique.sql` 后只跑了 `mvn package`(没有 clean):`target/classes/db/migration/` 里**还留着旧副本**,被打进 jar,Flyway 发现历史里没有 v4 就**又执行了一遍** —— 索引回来了、历史行填回去了。之后 I 又建了同名版本号的新 V4,启动就报 `Found more than one migration with version 4`;清了旧副本后又变成 `Migration checksum mismatch for migration version 4`。**排查绕了好几圈,根源就是一行 `mvn package` 少了 `clean`。** 规则:**动过 `db/migration/` 里的文件(新增、删除、改名),一律 `mvn clean package`** | + +顺带修掉:`views/task/index.vue` 的操作列从 3 个链接涨到 4 个(编辑/配置班级/发布/删除), +160px 会挤成竖排 —— 把「进度」并进「状态」列腾出 40px,操作列给到 200px,合计 820px +(可用约 824px)。发布被拦时现在会**告诉用户去哪里补**(`BLOCKING_HINTS`), +而不是只说"未设置覆盖班级"。 + +### E2E 脚本必须"崩了也清理" + +`e2e-m1-orgs.py` 第一次运行时在准备阶段抛了 `TypeError`(我自己写错了参数), +**脚本直接退出、清理段没执行**,把两个测试账号留在了库里 —— 直到我在浏览器里 +看到「班主任」下拉里有个 `E2E班主任688ed2` 才发现。 + +已改成:清理逻辑抽成模块级 `cleanup_all()`(幂等),既在 `main()` 末尾调用, +也在 `__main__` 的 `try/except BaseException` 里兜一次。 +**其余三条 E2E 脚本(`e2e-m0.py` / `e2e-m0-user.py` / `e2e-m1-task.py`)还是老样子, +崩了会漏数据,下一轮一并按这个模式改。** + +> 另外记一笔:清理残留账号时我写的用户名前缀过滤(`adm`)**把 `admin` 也匹配进去了**, +> 是服务端的「不能停用当前登录的账号,请换一个管理员账号操作」把它挡下来的。 +> 那条护栏当初加的时候是为了防自锁,这次意外挡住了一次误删 —— 护栏的价值往往 +> 不是在被设计的场景里体现的。 + +### ⚠️ 新增两条前端硬规则(来自 `frontend-pc/AGENTS.md` 与 `.agents/skills/`) + +1. **未经用户明确要求,不要 `git commit` / `push`**(AGENTS.md 常驻规则)。 + 本轮改动**未提交**,工作区里留着。 +2. **`defineEmits` 必须用函数调用签名**,禁止 tuple/对象形式: + ```ts + // ✅ + const emit = defineEmits<{ (e: 'save-success'): void }>() + // ❌ const emit = defineEmits<{ 'save-success': [] }>() + ``` + 框架自带组件(GiForm / GiButton / GiTable)用的都是前者 —— 这是本项目已确立的写法。 + 我此前写的 5 个组件(`OrgFormModal` / `TaskFormModal` / `TaskOrgDrawer` / + `UserFormModal` / `UserImportModal`)都是被禁的 tuple 形式,**本轮已全部改正**。 + +改文件前先按 `frontend-pc/AGENTS.md` 的表格加载对应 skill(`.vue` → `code-vue`,样式 → `code-css`, +`.ts` → `code-ts`,CRUD → `project-crud`)。 + +--- + **关键实现决策(不要在后继开发中改回去)** | 决策 | 理由 | diff --git a/docs/数据信息填报系统PRD.md b/docs/数据信息填报系统PRD.md index 9aa5fc9..7cab262 100644 --- a/docs/数据信息填报系统PRD.md +++ b/docs/数据信息填报系统PRD.md @@ -1601,7 +1601,7 @@ CREATE TABLE collect_task_org ( org_id bigint NOT NULL, head_user_id bigint, deputy_user_ids varchar(256), - expected_count integer NOT NULL DEFAULT 0, + expected_count integer, status smallint NOT NULL DEFAULT 1, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), @@ -1611,7 +1611,7 @@ CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id); COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定'; COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id'; COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔'; -COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)'; +COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同'; COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效'; ``` diff --git a/frontend-pc/src/apis/xt/task.ts b/frontend-pc/src/apis/xt/task.ts index e200aec..119be86 100644 --- a/frontend-pc/src/apis/xt/task.ts +++ b/frontend-pc/src/apis/xt/task.ts @@ -5,6 +5,7 @@ * 分页 `{ list, total }`(适配层会补一份 `records`)。 */ import http from '@/utils/http' +import { getUserList } from './user' /** 审核流配置(§3.2) */ export interface AuditConfig { @@ -217,3 +218,64 @@ export const NAMING_VARIABLES = [ /** 默认命名模板(与 DDL 的列默认值一致) */ export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}' + +// ── 覆盖班级与班主任(§7.6)────────────────────────── + +/** 任务覆盖的一个班 */ +export interface TaskOrgVO { + orgId: number + orgName: string + /** + * 该班初审人。 + * + * ⚠️ 为空不是「没人审」,而是「取该班负责人」(`sys_org.leader_user_id`)。 + * 界面上要把这个区别讲清楚,否则管理员会以为漏配了, + * 然后在几百个班上机械地点几百次 —— 而它们本来就等于班级负责人。 + */ + headUserId: number | null + headUserName: string | null + deputyUserIds: number[] + deputyUserNames: string[] + /** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */ + expectedCount: number | null + /** 恒为 1(只返回生效行) */ + status: number +} + +/** 一个班的提交项。PUT 是整体替换语义:请求体就是最终答案 */ +export interface TaskOrgItem { + orgId: number + headUserId?: number | null + deputyUserIds?: number[] + /** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */ + expectedCount?: number | null +} + +export function getTaskOrgs(taskId: number) { + return http.get(`/tasks/${taskId}/orgs`) +} + +/** 设置覆盖班级与班主任。不在列表里的班级会被软移除 */ +export function saveTaskOrgs(taskId: number, orgs: TaskOrgItem[]) { + return http.put(`/tasks/${taskId}/orgs`, { orgs }) +} + +/** + * 可被指定为初审人的账号。 + * + * 拉两种角色再合并:后端要求初审人持有班主任**或**副班主任角色, + * 所以两边的账号都是合法选项。只取启用状态 —— 停用的账号会被后端拒掉, + * 列在选项里只会让人白选一次。 + */ +export async function getTeacherOptions(): Promise> { + const [head, deputy] = await Promise.all([ + getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 }), + getUserList({ roleCode: 'DEPUTY_TEACHER', status: 1, pageSize: 200 }) + ]) + const merged = new Map() + for (const u of [...(head.data?.list ?? []), ...(deputy.data?.list ?? [])]) { + // 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人 + merged.set(u.id, u.username ? `${u.realName}(${u.username})` : u.realName) + } + return Array.from(merged.entries(), ([id, label]) => ({ id, label })) +} diff --git a/frontend-pc/src/views/org/OrgFormModal.vue b/frontend-pc/src/views/org/OrgFormModal.vue index e5125e2..34d61c6 100644 --- a/frontend-pc/src/views/org/OrgFormModal.vue +++ b/frontend-pc/src/views/org/OrgFormModal.vue @@ -55,7 +55,9 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org' */ defineOptions({ name: 'OrgFormModal' }) -const emit = defineEmits<{ 'save-success': [] }>() +const emit = defineEmits<{ + (e: 'save-success'): void +}>() const visible = ref(false) const submitting = ref(false) const detailId = ref(null) diff --git a/frontend-pc/src/views/task/TaskFormModal.vue b/frontend-pc/src/views/task/TaskFormModal.vue index 5611f7d..23c573f 100644 --- a/frontend-pc/src/views/task/TaskFormModal.vue +++ b/frontend-pc/src/views/task/TaskFormModal.vue @@ -119,7 +119,9 @@ import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task' */ defineOptions({ name: 'TaskFormModal' }) -const emit = defineEmits<{ 'save-success': [] }>() +const emit = defineEmits<{ + (e: 'save-success'): void +}>() const visible = ref(false) const submitting = ref(false) const detailId = ref(null) diff --git a/frontend-pc/src/views/task/TaskOrgDrawer.vue b/frontend-pc/src/views/task/TaskOrgDrawer.vue new file mode 100644 index 0000000..a8ba97b --- /dev/null +++ b/frontend-pc/src/views/task/TaskOrgDrawer.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend-pc/src/views/task/index.vue b/frontend-pc/src/views/task/index.vue index dd4b754..f7d5a16 100644 --- a/frontend-pc/src/views/task/index.vue +++ b/frontend-pc/src/views/task/index.vue @@ -49,7 +49,7 @@ 留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。 --> + :scroll="{ x: 820 }" @page-change="onPageChange">