feat(roster): 名册导入前端 —— 任务发布现在真的能在界面上走通了
第 2 步。在此之前「配置 → 任务就绪」缺了**名册**那一环:班级能配、材料能配、
表单能配,但名册只能靠 SQL 造,于是任务在界面上**根本发布不了**(40016 名册非空)。
一、新增(frontend-pc/)
src/apis/xt/roster.ts 导入(multipart)+ 分页 + 类型 + 状态字典
src/views/task/TaskRosterDrawer.vue 「名单管理」抽屉:导入 → 体检报告 → 名册列表
views/task/index.vue 加「名单管理」入口;已截止/已归档只读仍可查看
config/xt-routes.ts 顶层「名单管理」菜单说明改为指向任务行
二、★ 验证了"发布真的通"(e2e-m2-roster.py 第 8 节)
配覆盖班级 → 配必传材料 → **发布成功** → 状态变「收集中」→ stat 仍带分母。
force=true 只跳过 P1 提醒,那 5 项阻断级校验 force 也绕不过 —— 所以是真链路。
三、界面上五个刻意的设计
1. 先体检再导入(两步):实测表头在第 1 行或第 6 行,一步导入只会看到
"成功 0 条",然后以为文件坏了
2. 明说「前面那 5 行是标题与说明,已跳过,这不是导错了」—— 不解释的话
用户会去改自己那份本来正确的表格
3. 行号 + 列字母 + 原值 + 原因四样一起给,用户拿着就能回去改 Excel
4. dryRun 默认 true(服务端也默认 true):不显式要求就不写库
5. 阻断行与警告行分两张表:混在一起会让人以为整批都没进
原因后面跟**机器码**而不是中文标签(第一版给中文,和上面那句原因重复了一遍)
四、顺带修掉一处「界面在说谎」
名册导进来了,任务列表却还显示「待导入名册」—— TaskStatVO 恒为 null 是 M1 的
既有缺口(注释写着"名册为空时返回 null,由界面显示待导入名册")。现在名册有了:
· TaskService.fillStats():一次算一页(两条批量查询,不逐任务查)
· **名册为空时仍返回 null**,口径没有放宽 —— 显示 0% 看起来像"没人填",
而真相是"还没有分母"
· abnormalCount / avgAuditHours 仍为 null(要 M4 的校验表 / M5 的审核记录)
· e2e-m1-task 那条断言改成措辞更准的"名册为空时 stat 为 null"(意图不变),
并在 e2e-m2-roster 补 4 条"名册有值后 stat 该有值"
这条也是第 2 步**自己制造**的问题:能导名册之后,"待导入名册"从诚实变成说谎。
五、为什么顶层「名单管理」菜单还是"待建"
名册是**按任务**的,独立页得先选任务(多一步且容易选错);它和「配置班级」
「材料规则」「表单设计」是同一层的配置。报考范围确认(M2-04/05)会接着做进
同一个抽屉,那时也不需要新页面。所以改的是那句已过期的"对应里程碑 M2"。
六、浏览器验证(真链路,只绕开"手指点选")
文件框没法自动化,但可以把夹具放到 frontend-pc/public/ 再喂进去:
fetch 字节 → new File → DataTransfer → input.files → dispatch change
实测:3 人被正确导入、脱敏证件号、报考三项、扩展字段、状态列、搜索全对。
夹具用完即删。
七、回归
全量 622 条断言、0 失败(名册 E2E 62 → 67)
frontend-pc: vue-tsc 0 错、eslint 目标文件 0 错
verify-docs.py 19 项全通过
八、两个流程教训(已写进交接摘要)
· `Test-Path jar` 不能当作"打包成功":这次 mvn 失败而旧 jar 还在,
于是检查得到"打包 ✓",测试对着旧 jar 跑,四条新断言全红。
必须看退出码 / BUILD SUCCESS。
· `call()` 返回的是 (status, body) 元组不是字典 —— 我新写的那节忘了解包。
与第 1 步"已存在判断写反"同类:新写的代码最容易在细节上错。
九、下一步(第 3 步)
报考范围聚合与一致性体检(M2-04/05)—— 产品的核心增量。
先确认:sys_eval_plan 那几张"评价计划"表还没落地(FR-311 未做),
需要先定"计划从哪来"。
This commit is contained in:
+22
-3
@@ -9,6 +9,7 @@ import cn.tianbaotong.fill.common.util.MaskUtil;
|
||||
import cn.tianbaotong.fill.module.roster.dto.RosterImportReport;
|
||||
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.support.IdentityNormalizer;
|
||||
import cn.tianbaotong.fill.support.SensitiveCodec;
|
||||
@@ -69,6 +70,12 @@ public class RosterImportService {
|
||||
/** 表头只会出现在前 N 行里(实测最靠后的是第 6 行,留一倍余量)。 */
|
||||
private static final int HEADER_SCAN_ROWS = 10;
|
||||
|
||||
/** 名册可改的任务状态 —— 与 {@code MaterialDefService.EDITABLE_TASK_STATUS} 同口径。 */
|
||||
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
|
||||
CollectTask.STATUS_DRAFT,
|
||||
CollectTask.STATUS_COLLECTING,
|
||||
CollectTask.STATUS_PAUSED);
|
||||
|
||||
/** 一行里命中这么多个已知列名,才认为它是表头。 */
|
||||
private static final int HEADER_MIN_SCORE = 3;
|
||||
|
||||
@@ -107,7 +114,17 @@ public class RosterImportService {
|
||||
LoginUser me = UserContext.require();
|
||||
Long tenantId = me.tenantId();
|
||||
|
||||
requireTask(taskId);
|
||||
CollectTask task = requireTask(taskId);
|
||||
// 与材料规则同口径:已截止/已归档的任务不许再动名册。
|
||||
// 只拦"真导入"—— dryRun 不写库,看体检报告是安全的,不该被拦。
|
||||
if (!dryRun) {
|
||||
int st = task.getStatus() == null ? -1 : task.getStatus();
|
||||
if (!EDITABLE_TASK_STATUS.contains(st)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"任务已" + (st == CollectTask.STATUS_CLOSED ? "截止" : "归档")
|
||||
+ ",名册不可再改(只能查看)。如需继续收集,请新建任务");
|
||||
}
|
||||
}
|
||||
if (orgId != null && rosterMapper.countOrg(tenantId, orgId) == 0) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "班级不存在或不属于本单位");
|
||||
}
|
||||
@@ -693,9 +710,11 @@ public class RosterImportService {
|
||||
.map(RosterImportReport.RowIssue::row).distinct().count();
|
||||
}
|
||||
|
||||
private void requireTask(Long taskId) {
|
||||
if (taskMapper.selectById(taskId) == null) {
|
||||
private CollectTask requireTask(Long taskId) {
|
||||
CollectTask task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -4,6 +4,9 @@ import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 「任务是否具备发布条件」的体检查询。
|
||||
*
|
||||
@@ -100,4 +103,47 @@ public interface TaskReadinessMapper {
|
||||
WHERE task_id = #{taskId} AND deleted = 0 AND status IN (1, 2)
|
||||
""")
|
||||
Long countPendingAudit(@Param("taskId") Long taskId);
|
||||
|
||||
// ── 任务统计快照(§3.3):列表页一次算一页,不要每行查一次 ──────────
|
||||
|
||||
/**
|
||||
* 一批任务的名册行数。
|
||||
*
|
||||
* <p><b>为什么是"一批"而不是"一个"</b>:任务列表一页 10 行,
|
||||
* 逐行查会让"打开任务列表"变成 10 次以上的数据库往返,
|
||||
* 而这一屏是管理端每天打开次数最多的页面。
|
||||
*
|
||||
* <p>⚠️ 这里带了 {@code tenant_id}(本文件上面几条没有):
|
||||
* 上面几条的 {@code taskId} 都来自"已经过租户过滤的任务",
|
||||
* 而批量版本的 ids 虽然也来自租户过滤后的分页结果,
|
||||
* 但一条查不到租户的行就会把整页的统计串到别的学校去 —— 代价不对等,带上更稳。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, COUNT(*) AS cnt FROM collect_roster
|
||||
WHERE tenant_id = #{tenantId} AND deleted = 0
|
||||
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
|
||||
GROUP BY task_id
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> countRosterByTaskIds(@Param("tenantId") Long tenantId,
|
||||
@Param("ids") List<Long> ids);
|
||||
|
||||
/**
|
||||
* 一批任务的提交单状态分布(每个任务每种状态一行)。
|
||||
*
|
||||
* <p>调用方按 {@code status} 汇总成"已提交 / 待审 / 已通过 / 已驳回"。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, status AS status, COUNT(*) AS cnt FROM collect_submission
|
||||
WHERE tenant_id = #{tenantId} AND deleted = 0
|
||||
AND task_id IN <foreach collection="ids" item="i" open="(" separator="," close=")">#{i}</foreach>
|
||||
GROUP BY task_id, status
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> countSubmissionsByTaskIds(@Param("tenantId") Long tenantId,
|
||||
@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
+96
-1
@@ -9,6 +9,7 @@ import cn.tianbaotong.fill.module.task.dto.AuditConfigVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.CompressConfigVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskActionReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskCreateVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskStatVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskVO;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
@@ -32,8 +33,10 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -116,9 +119,99 @@ public class TaskService {
|
||||
IPage<CollectTask> page = taskMapper.selectPage(new Page<>(num, size), q);
|
||||
// 用 toFullVo:列表里就要显示审核流与压缩参数,走 toVo 会是 null
|
||||
List<TaskVO> list = page.getRecords().stream().map(this::toFullVo).toList();
|
||||
fillStats(list);
|
||||
return PageResult.of(list, page.getTotal(), num, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填「任务统计快照」(§3.3)—— 名册(M2)与提交单(M3)都有之后它才开始有值。
|
||||
*
|
||||
* <p>⚠️ <b>名册为空时返回 {@code null},而不是一串 0</b>。
|
||||
* 这是 {@link TaskStatVO} 类注释里定下的口径:显示"完成率 0%"看起来像"没人填",
|
||||
* 而真相是"还没有分母"。界面据此显示「待导入名册」。
|
||||
*
|
||||
* <p>一次算一页(两条批量查询),不做逐任务查询:任务列表是管理端
|
||||
* 打开次数最多的页面,10 行 × 2 次往返是白白多出来的。
|
||||
*/
|
||||
private void fillStats(List<TaskVO> vos) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (TaskVO v : vos) {
|
||||
if (v != null && v.getId() != null) {
|
||||
ids.add(v.getId());
|
||||
}
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Long tenantId = UserContext.tenantId();
|
||||
|
||||
Map<Long, Integer> rosterTotal = new HashMap<>();
|
||||
for (Map<String, Object> r : readinessMapper.countRosterByTaskIds(tenantId, ids)) {
|
||||
rosterTotal.put(asLong(col(r, "taskId")), asInt(col(r, "cnt")));
|
||||
}
|
||||
Map<Long, Map<Integer, Integer>> byStatus = new HashMap<>();
|
||||
for (Map<String, Object> r : readinessMapper.countSubmissionsByTaskIds(tenantId, ids)) {
|
||||
byStatus.computeIfAbsent(asLong(col(r, "taskId")), k -> new HashMap<>())
|
||||
.put(asInt(col(r, "status")), asInt(col(r, "cnt")));
|
||||
}
|
||||
|
||||
for (TaskVO v : vos) {
|
||||
if (v == null || v.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
int rt = rosterTotal.getOrDefault(v.getId(), 0);
|
||||
if (rt == 0) {
|
||||
v.setStat(null);
|
||||
continue;
|
||||
}
|
||||
Map<Integer, Integer> st = byStatus.getOrDefault(v.getId(), Map.of());
|
||||
int pending = st.getOrDefault(1, 0) + st.getOrDefault(2, 0);
|
||||
int approved = st.getOrDefault(3, 0);
|
||||
int rejected = st.getOrDefault(4, 0);
|
||||
int archived = st.getOrDefault(5, 0);
|
||||
// 「已提交」= 交上来且没被退回的(待初审 + 待终审 + 已通过)。
|
||||
// 与"已通过"必须分开报:原型定稿的口径是「完成率 82% 是已提交率 23/28」。
|
||||
int submitted = pending + approved;
|
||||
|
||||
TaskStatVO s = new TaskStatVO();
|
||||
s.setRosterTotal(rt);
|
||||
s.setSubmittedCount(submitted);
|
||||
s.setPendingCount(pending);
|
||||
s.setApprovedCount(approved);
|
||||
s.setRejectedCount(rejected);
|
||||
// 没交的 = 名册里减去"已经交了的"与"被退回的"(草稿算没交:他还没提交)
|
||||
s.setUnsubmittedCount(Math.max(0, rt - submitted - rejected - archived));
|
||||
s.setSubmitRate(pct(submitted, rt));
|
||||
s.setPassRate(pct(approved, rt));
|
||||
// abnormalCount / avgAuditHours 仍为 null:前者要 M4 的校验问题表、
|
||||
// 后者要 M5 的审核记录,现在编一个数就是谎
|
||||
v.setStat(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 列标签在 PostgreSQL 里会被折成小写,而 MyBatis 用列标签做 key ——
|
||||
// 按不区分大小写取,避免"取不到就当 0"这种最难受的错(数字看起来正常,其实是空的)
|
||||
private static Object col(Map<String, Object> row, String name) {
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
if (e.getKey().equalsIgnoreCase(name)) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String pct(int part, int total) {
|
||||
return total <= 0 ? null : Math.round(part * 100.0 / total) + "%";
|
||||
}
|
||||
|
||||
private static Integer asInt(Object o) {
|
||||
return o instanceof Number n ? n.intValue() : null;
|
||||
}
|
||||
|
||||
private static Long asLong(Object o) {
|
||||
return o instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
/** 是否看全租户:学校管理员与审核员。平台运营不分管具体学校的任务,但也不该被拦。 */
|
||||
private boolean seesWholeTenant() {
|
||||
LoginUser me = UserContext.require();
|
||||
@@ -128,7 +221,9 @@ public class TaskService {
|
||||
// ── 详情 ──────────────────────────────────────
|
||||
|
||||
public TaskVO detail(Long id) {
|
||||
return toFullVo(requireTask(id));
|
||||
TaskVO vo = toFullVo(requireTask(id));
|
||||
fillStats(new ArrayList<>(List.of(vo)));
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ── 新建 ──────────────────────────────────────
|
||||
|
||||
+131
@@ -1775,3 +1775,134 @@ GET /api/v1/tasks/{id}/roster keyword / orgId / submitStatus / bindSta
|
||||
5. 界面文案要照顾"表头在第 6 行"这件事 —— **不要让用户以为系统导错了**
|
||||
|
||||
> 做完第 2 步,M1 的"配置闭环"就真的能在界面上从头走到尾了(里程碑 A 的完整形态)。
|
||||
|
||||
---
|
||||
|
||||
## §一·二十三 第 2 步完成:名册导入前端 —— **任务发布现在真的能在界面上走通了**
|
||||
|
||||
### 这一步的意义(一句话)
|
||||
|
||||
在此之前,「配置 → 任务就绪」这条链缺了**名册**那一环:班级能配、材料能配、表单能配,
|
||||
但名册只能靠 SQL 造 —— 于是任务在界面上**发布不了**(发布前置校验 `40016` 名册非空)。
|
||||
|
||||
现在四个配置入口齐了,并且**验证过发布真的通**(`e2e-m2-roster.py` 第 8 节):
|
||||
|
||||
```
|
||||
[8] 配置闭环:名册到位后,任务应当能发布
|
||||
✓ 配覆盖班级成功
|
||||
✓ 配必传材料成功
|
||||
✓ **发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了)
|
||||
✓ 任务状态变成「收集中」
|
||||
✓ 发布后 stat 仍带分母(rosterTotal=3)
|
||||
```
|
||||
|
||||
> `force=true` 只跳过 P1 级提醒(如"某班没指定初审人"),那 5 项阻断级校验
|
||||
> **force 也绕不过** —— 所以这条断言证明的是真链路,不是绕过去的。
|
||||
|
||||
### 落地了什么
|
||||
|
||||
**新增**(`frontend-pc/`):
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/apis/xt/roster.ts` | 导入(multipart)+ 名册分页 + 类型 + 状态字典 |
|
||||
| `src/views/task/TaskRosterDrawer.vue` | 「名单管理」抽屉:导入 → 体检报告 → 名册列表 |
|
||||
|
||||
**改动**:`views/task/index.vue` 加「名单管理」入口(草稿/收集中/已暂停可改,
|
||||
已截止/已归档只读仍可查看)+ 发布失败提示改指本行入口;
|
||||
`config/xt-routes.ts` 的顶层「名单管理」菜单说明改成指向任务行(见下)。
|
||||
|
||||
**接口**(第 1 步就做好了,这次只调):`POST /tasks/{id}/roster/import`、`GET /tasks/{id}/roster`。
|
||||
|
||||
### 界面上五个刻意的设计
|
||||
|
||||
| # | 设计 | 为什么 |
|
||||
|:--:|---|---|
|
||||
| 1 | **先体检、再导入(两步)** | 实测表头在第 1 行或第 6 行。一步导入的话用户只看到"成功 0 条",然后以为自己文件坏了 —— 而真相是系统把标题行当成了表头 |
|
||||
| 2 | **明说"前面那 5 行是标题与说明,已跳过,这不是导错了"** | 看到"第 6 行"的人第一反应就是"导错了",然后会去改自己那份**本来正确**的表格 |
|
||||
| 3 | **行号 + 列字母 + 原值 + 原因,四样一起给** | 只给"第 12 行有问题",用户还得自己回去找是哪一列 |
|
||||
| 4 | **`dryRun` 默认 true** | 服务端也默认 true:不显式要求就不写库。避免"以为只是看看,结果已经导进去了" |
|
||||
| 5 | **阻断行与警告行分两张表** | 阻断的要改;警告的已经入库、只是建议补。混在一起用户会以为整批都没进 |
|
||||
|
||||
另外:原因后面跟的是**机器码**(`IDCARD_CHECKSUM` 这种)而不是中文标签 ——
|
||||
第一版给的是中文,结果和上面那句原因重复了一遍("姓名为空 姓名为空"),已改。
|
||||
|
||||
### 顺带修掉一处「界面在说谎」
|
||||
|
||||
名册导进来了,但任务列表还显示「待导入名册」—— 因为 `TaskStatVO` 恒为 `null`
|
||||
(M1 阶段的既有缺口,类注释里写着"名册为空时返回 null,由界面显示「待导入名册」")。
|
||||
现在名册有了,就该有分母了:
|
||||
|
||||
- `TaskService.fillStats()`:**一次算一页**(两条批量查询,不做逐任务查询 ——
|
||||
任务列表是管理端打开次数最多的页面,10 行 × 2 次往返是白白多出来的)
|
||||
- **名册为空时仍返回 `null`**,界面继续显示「待导入名册」—— 这条口径**没有放宽**:
|
||||
显示"0%"看起来像"没人填",而真相是"还没有分母"
|
||||
- `abnormalCount` / `avgAuditHours` **仍为 null**:前者要 M4 的校验问题表、
|
||||
后者要 M5 的审核记录,现在编一个数就是谎
|
||||
- E2E:`e2e-m1-task.py` 那条"stat 为 null"改成措辞更准的"名册为空时 stat 为 null"(意图不变),
|
||||
并在 `e2e-m2-roster.py` 补了 4 条"名册有值后 stat 该有值"
|
||||
|
||||
> 这条也是第 2 步**自己制造出来**的问题:能导名册之后,"待导入名册"就从"诚实"变成"说谎"。
|
||||
|
||||
### 关于顶层菜单「名单管理」为什么还是"待建"
|
||||
|
||||
`config/xt-routes.ts` 里本来就有一个顶层「名单管理」(`/collect/roster`,标着"待建 / M2")。
|
||||
这次**没有**把它做成独立页,而是做进了**任务行**里的抽屉,理由:
|
||||
|
||||
- **名册是按任务(task)的**,独立页得先让用户选任务,多一步且容易选错;
|
||||
- 它和「配置班级」「材料规则」「表单设计」是同一层的配置,放在一起才一致;
|
||||
- 报考范围确认(M2-04/05)会接着做进**同一个抽屉**,那时也不需要新页面。
|
||||
|
||||
所以把那个菜单的说明改成指向任务行("名册是按任务的,入口在收集任务的每一行里"),
|
||||
而不是留着一句已经过期的"对应里程碑 M2"。
|
||||
|
||||
### 浏览器验证:一个可复用的手法
|
||||
|
||||
文件选择框没法自动化(`uni.chooseImage` / `input[type=file]` 都会弹系统对话框),
|
||||
但**可以喂进去**:
|
||||
|
||||
```js
|
||||
const blob = await (await fetch('/某个夹具.xlsx')).blob()
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File([blob], 'x.xlsx', { type: '...' }))
|
||||
input.files = dt.files
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
```
|
||||
|
||||
配合"把夹具放到 `frontend-pc/public/`"(Vite 会直接服务它),
|
||||
就能让浏览器走**完整的真实链路**(选文件 → 体检 → 导入 → 列表刷新),
|
||||
只绕开了"手指点选"那一步。这次就是这样验的:3 人被正确导入、
|
||||
脱敏证件号、报考三项、扩展字段、状态列都对。
|
||||
|
||||
> 夹具用完即删(`frontend-pc/public/_web-roster-fixture.xlsx`),别提交。
|
||||
|
||||
### 两个流程教训
|
||||
|
||||
1. **`Test-Path jar` 不能当作"打包成功"**。这次 `mvn package` 失败了,
|
||||
而 jar 是上一次的、还在 —— 检查文件存在会得到"打包 ✓",
|
||||
然后测试对着**旧 jar** 全绿(或像这次一样四条新断言全红)。
|
||||
**必须看 mvn 的退出码 / `BUILD SUCCESS`**。
|
||||
2. **`call()` 返回的是 `(status, body)` 元组**,不是字典。我新写的那一节忘了解包,
|
||||
报 `'tuple' object has no attribute 'get'` —— 与第 1 步里
|
||||
"把已存在判断写反"是同一类:**新写的代码最容易在细节上错,而 E2E 正是抓它的地方**。
|
||||
|
||||
### 回归
|
||||
|
||||
- **全量 622 条断言、0 失败**(`e2e-m2-roster.py` 62 → **67**,新增配置闭环 5 条)
|
||||
- `frontend-pc`:`vue-tsc --noEmit` **0 错**、`eslint` 目标文件 **0 错**
|
||||
- `python scripts/verify-docs.py` → 19 项全通过
|
||||
|
||||
### 下一步(第 3 步)
|
||||
|
||||
**报考范围聚合与一致性体检(M2-04/05)** —— 这是**产品的核心增量**
|
||||
(官方 Excel 的「报考职业」「报考工种」是自由填写、没有下拉,
|
||||
我们要把它变成"从名单聚合 + 一致性体检"):
|
||||
|
||||
1. 按职业 / 工种 / 等级分组聚合,给出每个分组的**人数**与**名册行列表**
|
||||
2. 5 类 issue 体检:非计划内职业、工种与职业不匹配、等级缺失、同一人多个职业…
|
||||
3. 界面:做进**同一个抽屉**(「报考范围」页签 / 折叠区),支持"排除某行不参与本次认定"
|
||||
4. 发布前置校验 `40024`(报考范围未覆盖全部名册)到那时才真正有意义
|
||||
|
||||
> 做之前先确认一件事:`sys_eval_plan` / `sys_occupation_ref` 这几张"评价计划"表
|
||||
> 库里只有 `sys_occupation_ref`(见 §一·二十二 的记录),
|
||||
> **评价计划本身的导入(FR-311)还没做** —— 需要先定"计划从哪来"(机构下发文件?)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 名册接口(《03-接口设计》§8.1 ~ §8.2)。
|
||||
*
|
||||
* 契约差异(已在 `@/utils/http` 统一适配):后端 `{ code, msg, data }`,
|
||||
* 解包后 `http.get<T>()` 得到 `{ code: 200, data: T }`。
|
||||
*
|
||||
* ## 三件界面上必须照顾到的事
|
||||
*
|
||||
* 1. **表头行不固定**。实测两份真实文件的表头分别在第 1 行和第 6 行,工作表名也不同。
|
||||
* 所以导入接口不是"传个文件就完事":它先回一份**体检报告**(表头在第几行、
|
||||
* 每个候选命中几列、每列映射到哪个字段),由界面呈现给人确认,
|
||||
* 确认之后才用 `dryRun=false` 真导入。
|
||||
* ⚠️ 界面上要把"系统在第 6 行找到表头"讲清楚 —— 否则用户看到"第 6 行"
|
||||
* 会以为系统导错了,而真相是**前 5 行本来就是标题与说明**。
|
||||
*
|
||||
* 2. **部分成功是设计目标**。42 行里有 2 行号码写错,不该让另外 40 个人白等,
|
||||
* 但也不能把那 2 行悄悄丢掉。所以 `errors` / `warnings` 是**逐行**的
|
||||
* (行号 + 列字母 + 原值 + 原因),界面必须让用户能拿着行号回去改 Excel。
|
||||
*
|
||||
* 3. **证件号一律只有脱敏值**。列表里是 `idCardMask`,报错里的 `value` 也是脱敏的 ——
|
||||
* 界面上不需要(也不该)显示完整号码。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
|
||||
/** 一列的映射结果。 */
|
||||
export interface RosterColumnReport {
|
||||
/** 列字母(`A`~),用户能对着 Excel 找 */
|
||||
col: string
|
||||
/** 表头原文 */
|
||||
title: string
|
||||
/** 落到哪:官方字段名,或 `ext_json.xxx`;null = 忽略该列 */
|
||||
mappedTo: string | null
|
||||
/** 是否识别为官方 21 列之一 */
|
||||
matched: boolean
|
||||
}
|
||||
|
||||
/** 一处问题。 */
|
||||
export interface RosterRowIssue {
|
||||
/** Excel 里的真实行号(从 1 开始) */
|
||||
row: number
|
||||
col: string | null
|
||||
field: string
|
||||
/** 原值(证件号已脱敏) */
|
||||
value: string | null
|
||||
/** 人话原因,直接显示 */
|
||||
reason: string
|
||||
/** 机器码:IDCARD_CHECKSUM / DUP_IDCARD / ROW_LOCKED … */
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RosterImportSummary {
|
||||
uniquePersons: number
|
||||
dupRows: number
|
||||
idcardInvalid: number
|
||||
nameSuspect: number
|
||||
phoneInvalid: number
|
||||
}
|
||||
|
||||
/** 导入体检报告(§8.1)。`dryRun=true` 时 `created`/`updated` 是"如果真导会怎样"。 */
|
||||
export interface RosterImportReport {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
headerRow: number
|
||||
sheetName: string | null
|
||||
headerCandidates: { row: number, score: number }[]
|
||||
headerConfirmed: boolean
|
||||
columns: RosterColumnReport[]
|
||||
errors: RosterRowIssue[]
|
||||
warnings: RosterRowIssue[]
|
||||
summary: RosterImportSummary
|
||||
dryRun: boolean
|
||||
mode: 'APPEND' | 'REPLACE' | string
|
||||
created: number
|
||||
updated: number
|
||||
removed: number
|
||||
}
|
||||
|
||||
/** 名册项(§3.6)。证件号与手机号只有脱敏值。 */
|
||||
export interface RosterVO {
|
||||
id: number
|
||||
seqNo: number | null
|
||||
studentNo: string | null
|
||||
realName: string
|
||||
/** 脱敏证件号 */
|
||||
idCardMask: string | null
|
||||
/** 脱敏手机号 */
|
||||
phoneMask: string | null
|
||||
orgId: number | null
|
||||
orgName: string | null
|
||||
occupation: string | null
|
||||
worktype: string | null
|
||||
jobLevel: string | null
|
||||
education: string | null
|
||||
isStudent: number | null
|
||||
school: string | null
|
||||
major: string | null
|
||||
enrollDate: string | null
|
||||
workUnit: string | null
|
||||
isRetake: number | null
|
||||
retakeTheory: string | null
|
||||
retakePractice: string | null
|
||||
retakeOverall: string | null
|
||||
workYears: number | null
|
||||
certOccupation: string | null
|
||||
certLevel: string | null
|
||||
certNo: string | null
|
||||
extJson: Record<string, unknown> | null
|
||||
/** 0 未绑定 / 1 已绑定 */
|
||||
bindStatus: number
|
||||
bindTime: string | null
|
||||
/** 1 门禁核验 / 2 管理员指定 */
|
||||
bindType: number | null
|
||||
/** 0 未提交 / 1 草稿 / 2 待审 / 3 已通过 / 4 已驳回 */
|
||||
submitStatus: number
|
||||
}
|
||||
|
||||
export interface RosterImportOptions {
|
||||
/** 手动指定表头行(1 起);不传则自动探测 */
|
||||
headerRow?: number
|
||||
/** 手动指定工作表;不传则取第一个有数据的 */
|
||||
sheetName?: string
|
||||
/** `APPEND`(默认,按证件号去重合并)/ `REPLACE`(整表替换) */
|
||||
mode?: 'APPEND' | 'REPLACE'
|
||||
/** true = 只体检不落库(**推荐默认**) */
|
||||
dryRun?: boolean
|
||||
/** 整批归到哪个班级 */
|
||||
orgId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入名册(multipart)。
|
||||
*
|
||||
* ⚠️ 超时给到 180s:5000 行的体检要遍历整表 + 逐行算校验位,
|
||||
* 而默认的 30s 在真实的大名单上会超时 —— 而超时的表现是"点了没反应",
|
||||
* 用户只会重复点,于是同一份文件被导好几次。
|
||||
*/
|
||||
export function importRoster(taskId: number, file: File, opts: RosterImportOptions = {}) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('dryRun', String(opts.dryRun ?? true))
|
||||
if (opts.headerRow !== undefined) {
|
||||
fd.append('headerRow', String(opts.headerRow))
|
||||
}
|
||||
if (opts.sheetName) {
|
||||
fd.append('sheetName', opts.sheetName)
|
||||
}
|
||||
if (opts.mode) {
|
||||
fd.append('mode', opts.mode)
|
||||
}
|
||||
if (opts.orgId !== undefined) {
|
||||
fd.append('orgId', String(opts.orgId))
|
||||
}
|
||||
// 不要手动设 Content-Type:axios 会带着 boundary 自己设,
|
||||
// 手动写成 'multipart/form-data' 反而会丢掉 boundary,服务端直接解析失败
|
||||
return http.request<RosterImportReport>({
|
||||
method: 'post',
|
||||
url: `/tasks/${taskId}/roster/import`,
|
||||
data: fd,
|
||||
timeout: 180 * 1000
|
||||
})
|
||||
}
|
||||
|
||||
export interface RosterQuery {
|
||||
keyword?: string
|
||||
orgId?: number
|
||||
submitStatus?: number
|
||||
bindStatus?: number
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 名册分页(§8.2)。
|
||||
*
|
||||
* `keyword` 既能搜姓名(模糊),**也能粘一整串身份证号**(走 hash 等值)——
|
||||
* 服务端按"看起来是不是身份证号"自动分支。密文搜不了 LIKE,这是加密的必然代价。
|
||||
*/
|
||||
export function getRosterList(taskId: number, params: RosterQuery = {}) {
|
||||
return http.get<{ list: RosterVO[], total: number }>(`/tasks/${taskId}/roster`, params)
|
||||
}
|
||||
|
||||
/** 填报单状态(§2.7)。名册行上的 `submitStatus` 与填报单同口径。 */
|
||||
export const SUBMIT_STATUS: Record<number, { text: string, color: string }> = {
|
||||
0: { text: '未提交', color: 'gray' },
|
||||
1: { text: '草稿', color: 'gray' },
|
||||
2: { text: '待初审', color: 'orange' },
|
||||
3: { text: '已通过', color: 'green' },
|
||||
4: { text: '已驳回', color: 'red' }
|
||||
}
|
||||
Vendored
+4
@@ -49,6 +49,8 @@ declare module 'vue' {
|
||||
AOption: typeof import('@arco-design/web-vue')['Option']
|
||||
APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
|
||||
APopover: typeof import('@arco-design/web-vue')['Popover']
|
||||
ARadio: typeof import('@arco-design/web-vue')['Radio']
|
||||
ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
|
||||
ARow: typeof import('@arco-design/web-vue')['Row']
|
||||
AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
|
||||
ASelect: typeof import('@arco-design/web-vue')['Select']
|
||||
@@ -129,6 +131,8 @@ declare global {
|
||||
const AOption: typeof import('@arco-design/web-vue')['Option']
|
||||
const APopconfirm: typeof import('@arco-design/web-vue')['Popconfirm']
|
||||
const APopover: typeof import('@arco-design/web-vue')['Popover']
|
||||
const ARadio: typeof import('@arco-design/web-vue')['Radio']
|
||||
const ARadioGroup: typeof import('@arco-design/web-vue')['RadioGroup']
|
||||
const ARow: typeof import('@arco-design/web-vue')['Row']
|
||||
const AScrollbar: typeof import('@arco-design/web-vue')['Scrollbar']
|
||||
const ASelect: typeof import('@arco-design/web-vue')['Select']
|
||||
|
||||
@@ -62,7 +62,8 @@ export const XT_MENUS: XtMenu[] = [
|
||||
group: 'collect',
|
||||
sort: 12,
|
||||
ready: false,
|
||||
note: '导入名册、报考范围确认、门禁名单维护。对应里程碑 M2。'
|
||||
note: '名册是**按任务**的,所以入口在「收集任务」的每一行里:点「名单管理」。'
|
||||
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。'
|
||||
},
|
||||
{
|
||||
key: 'join',
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="1120" :mask-closable="false" :footer="false"
|
||||
unmount-on-close @cancel="close">
|
||||
<template #title>
|
||||
<span>名单管理</span>
|
||||
<span class="roster__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
已截止 / 已归档只读。与服务端同口径(服务端也会拒),
|
||||
界面上先说清楚 —— 否则用户选完文件点「开始体检」才被告知不能导。
|
||||
注意:**体检(dryRun)不受状态限制**,看报告是安全的,所以只有"确认导入"被禁。
|
||||
-->
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="roster__alert">
|
||||
任务已{{ readonlyReason }},名册不可再改,只能查看。
|
||||
(仍然可以「开始体检」看一份表格有什么问题,只是不能导入。)
|
||||
</a-alert>
|
||||
|
||||
<!--
|
||||
收集中改名册要明确警告:学生可能正在核验。
|
||||
删掉/改掉某一行,那个学生下次扫码就进不来了;而改「报考职业」这类锁定字段,
|
||||
等于把他已经确认过的材料变成另一种认定。
|
||||
-->
|
||||
<a-alert v-else-if="collecting" type="warning" class="roster__alert">
|
||||
<template #title>
|
||||
任务正在收集中,<strong>改动立刻生效</strong>:学生下一次核验就会看到新名单。
|
||||
已经核验过的人不受影响(他们的名册项<b>不会被覆盖</b>,系统会跳过并提示)。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- ── 一、导入 ─────────────────────────────────── -->
|
||||
<div class="roster__section">导入名册</div>
|
||||
<a-alert type="info" :show-icon="false" class="roster__intro">
|
||||
<template #title>
|
||||
表格里需要有 <strong>姓名</strong> 与 <strong>身份证号</strong> 两列,
|
||||
其余按官方模板的列名(学历、联系电话、报考职业…)识别。
|
||||
<strong>表头在第几行不用管</strong>,系统会自己找;认错了可以手动指定。
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="roster__hint">
|
||||
名册是<b>准入名单</b>:学生必须先填写姓名与身份证号、与名册匹配上,才能进入填报。
|
||||
所以这一份名单就是"谁能填、填的是谁"的唯一依据。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-row :gutter="16" class="roster__form">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="整批归到哪个班级" class="roster__item">
|
||||
<a-select v-model="orgId" placeholder="不指定(名册项不带班级)" allow-clear
|
||||
:loading="orgLoading" size="small">
|
||||
<a-option v-for="c in classOptions" :key="c.id" :value="c.id">{{ c.label }}</a-option>
|
||||
</a-select>
|
||||
<template #extra>
|
||||
<span class="roster__hint">官方模板里没有班级列,所以这里选了就整批都算这个班</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="导入方式" class="roster__item">
|
||||
<a-radio-group v-model="mode" type="button" size="small">
|
||||
<a-radio value="APPEND">合并导入</a-radio>
|
||||
<a-radio value="REPLACE">整表替换</a-radio>
|
||||
</a-radio-group>
|
||||
<template #extra>
|
||||
<span class="roster__hint">{{ modeHint }}</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="Excel 文件" class="roster__item">
|
||||
<div class="roster__pick">
|
||||
<!--
|
||||
用原生 input 而不是 a-upload:本场景只需要"选一个本地文件然后自己发请求",
|
||||
原生 input 拿原始 File 的行为是完全确定的。与账号导入弹窗同一做法。
|
||||
-->
|
||||
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="roster__file"
|
||||
@change="onFileChange" />
|
||||
<a-button size="small" :disabled="readonly" @click="pickFile">
|
||||
<template #icon><IconUpload /></template>
|
||||
选择文件
|
||||
</a-button>
|
||||
<span v-if="file" class="roster__filename" :title="file.name">{{ file.name }}</span>
|
||||
<span v-else class="roster__hint">.xlsx / .xls,≤10MB</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<a-button type="primary" size="small" :loading="checking" :disabled="!file || readonly"
|
||||
@click="runDryRun()">
|
||||
{{ report ? '按当前设置重新体检' : '开始体检' }}
|
||||
</a-button>
|
||||
<span class="roster__hint">
|
||||
体检<b>不会写库</b>:先看清楚系统把哪一行当表头、哪几行有问题,确认无误再导入。
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<!-- ── 二、体检报告 ─────────────────────────────── -->
|
||||
<template v-if="report">
|
||||
<a-divider />
|
||||
|
||||
<a-row :gutter="12" class="roster__stats">
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.total }}</div>
|
||||
<div class="roster__stat-label">数据行</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat roster__stat--ok">
|
||||
<div class="roster__stat-num">{{ report.success }}</div>
|
||||
<div class="roster__stat-label">可入库</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat" :class="report.failed ? 'roster__stat--bad' : ''">
|
||||
<div class="roster__stat-num">{{ report.failed }}</div>
|
||||
<div class="roster__stat-label">被阻断</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.dryRun ? plannedNew : report.created }}</div>
|
||||
<div class="roster__stat-label">{{ report.dryRun ? '将新增' : '已新增' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat">
|
||||
<div class="roster__stat-num">{{ report.dryRun ? plannedUpdate : report.updated }}</div>
|
||||
<div class="roster__stat-label">{{ report.dryRun ? '将更新' : '已更新' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="roster__stat" :class="report.warnings.length ? 'roster__stat--warn' : ''">
|
||||
<div class="roster__stat-num">{{ report.warnings.length }}</div>
|
||||
<div class="roster__stat-label">警告(不影响入库)</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert :type="headerAlertType" :show-icon="false" class="roster__header-alert">
|
||||
<template #title>
|
||||
系统把第 <strong>{{ report.headerRow }}</strong> 行当成了表头
|
||||
<span v-if="report.headerRow > 1">
|
||||
—— 它<strong>前面那 {{ report.headerRow - 1 }} 行</strong>是标题与说明,已跳过,
|
||||
这不是导错了
|
||||
</span>
|
||||
。
|
||||
<span v-if="report.sheetName">(工作表:{{ report.sheetName }})</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="report.headerCandidates.length > 1" class="roster__hint">
|
||||
另外还有 {{ report.headerCandidates.length - 1 }} 行也像表头(命中列数:
|
||||
{{ report.headerCandidates.map((c) => `第${c.row}行=${c.score}`).join(',') }})。
|
||||
如果系统选错了,在下面手动指定。
|
||||
</div>
|
||||
<div class="roster__hint">
|
||||
缺了哪些列、多出来的列去了哪,看下面的「列识别结果」——
|
||||
多出来的列不会丢,会原样存进扩展字段。
|
||||
</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<span class="roster__hint">手动指定表头行:</span>
|
||||
<a-input-number v-model="manualHeaderRow" :min="1" :max="200" size="small"
|
||||
:style="{ width: '110px' }" placeholder="行号" />
|
||||
<a-button size="small" :loading="checking" :disabled="!file" @click="runDryRun(manualHeaderRow)">
|
||||
按这一行重新体检
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<!-- 列识别结果 -->
|
||||
<a-table :data="report.columns" :pagination="false" row-key="col" size="small"
|
||||
:max-height="200" :scroll="{ y: 200 }" class="roster__table">
|
||||
<template #columns>
|
||||
<a-table-column title="列" :width="60" align="center" data-index="col" />
|
||||
<a-table-column title="表头原文" data-index="title" :width="200" ellipsis tooltip />
|
||||
<a-table-column title="识别为" :width="180">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.matched" class="roster__mapped">{{ fieldName(record.mappedTo) }}</span>
|
||||
<span v-else-if="record.mappedTo" class="roster__muted">
|
||||
非官方列 → 存进扩展字段
|
||||
</span>
|
||||
<span v-else class="roster__muted">忽略</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="说明" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<a-tag v-if="record.matched" color="green" size="small">官方列</a-tag>
|
||||
<a-tag v-else-if="record.mappedTo" size="small">保留</a-tag>
|
||||
<a-tag v-else size="small">空列</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 阻断行 -->
|
||||
<div class="roster__section roster__section--bad">
|
||||
没能入库的行({{ report.errors.length }})
|
||||
</div>
|
||||
<a-empty v-if="!report.errors.length" description="没有阻断行" />
|
||||
<a-table v-else :data="report.errors" :pagination="false" row-key="key" size="small"
|
||||
:max-height="240" :scroll="{ y: 240 }">
|
||||
<template #columns>
|
||||
<a-table-column title="Excel 行" :width="90" align="center">
|
||||
<template #cell="{ record }">第 {{ record.row }} 行</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="列" :width="60" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.col">{{ record.col }} 列</span>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原值" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.value">{{ record.value }}</span>
|
||||
<span v-else class="roster__muted">(空)</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原因" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
{{ record.reason }}
|
||||
<!-- 标签给的是**机器码**(排查/提单时有用),不重复上面那句人话 -->
|
||||
<code class="roster__code">{{ record.code }}</code>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 警告行 -->
|
||||
<div class="roster__section roster__section--warn">
|
||||
有警告、但仍然会入库的行({{ report.warnings.length }})
|
||||
</div>
|
||||
<a-empty v-if="!report.warnings.length" description="没有警告" />
|
||||
<a-table v-else :data="report.warnings" :pagination="false" row-key="key" size="small"
|
||||
:max-height="200" :scroll="{ y: 200 }">
|
||||
<template #columns>
|
||||
<a-table-column title="Excel 行" :width="90" align="center">
|
||||
<template #cell="{ record }">第 {{ record.row }} 行</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原值" :width="170" ellipsis tooltip>
|
||||
<template #cell="{ record }">{{ record.value || '(空)' }}</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="原因" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
{{ record.reason }}
|
||||
<code class="roster__code">{{ record.code }}</code>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<div class="roster__footer">
|
||||
<a-space>
|
||||
<a-button size="small" @click="resetReport">重新选文件</a-button>
|
||||
<a-button type="primary" size="small" :loading="importing"
|
||||
:disabled="readonly || !report.success" @click="confirmImport">
|
||||
{{ importButtonText }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<div v-if="!report.success" class="roster__hint">
|
||||
没有任何可入库的行,请按上面的原因改好 Excel 再来。
|
||||
</div>
|
||||
<div v-else-if="report.dryRun" class="roster__hint">
|
||||
上面这些数字是<b>预计</b>结果;导入只处理有问题的行以外的那
|
||||
{{ report.success }} 行,其余行改好后可以再次导入(同证件号会合并更新,不会重复)。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── 三、名册 ─────────────────────────────────── -->
|
||||
<a-divider />
|
||||
<div class="roster__section">
|
||||
名册({{ total }} 人)
|
||||
<span v-if="stat.t">已核验 {{ stat.bound }} 人 · 已提交 {{ stat.submitted }} 人</span>
|
||||
</div>
|
||||
|
||||
<a-space class="roster__tool" wrap>
|
||||
<a-input-search v-model="query.keyword" placeholder="姓名,或粘贴完整身份证号" allow-clear
|
||||
class="roster__search" size="small" @search="search" @clear="search" />
|
||||
<a-select v-model="query.submitStatus" placeholder="填报状态" allow-clear size="small"
|
||||
class="roster__filter" @change="search">
|
||||
<a-option v-for="(v, k) in SUBMIT_STATUS" :key="k" :value="Number(k)">{{ v.text }}</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.bindStatus" placeholder="核验状态" allow-clear size="small"
|
||||
class="roster__filter" @change="search">
|
||||
<a-option :value="0">未核验</a-option>
|
||||
<a-option :value="1">已核验</a-option>
|
||||
</a-select>
|
||||
<a-button size="small" @click="loadList">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<a-table :data="rows" :loading="listLoading" row-key="id" size="small" :pagination="pagination"
|
||||
:scroll="{ x: 940 }" class="roster__table" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="序号" :width="64" align="center">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.seqNo !== null">{{ record.seqNo }}</span>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="姓名 / 学历" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div class="roster__name">{{ record.realName }}</div>
|
||||
<div class="roster__muted">{{ record.education || '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="证件号 / 电话" :width="190">
|
||||
<template #cell="{ record }">
|
||||
<!-- 后端只回脱敏值;这里不做任何"还原",界面上不该有完整号码 -->
|
||||
<div class="roster__mono">{{ record.idCardMask || '—' }}</div>
|
||||
<div class="roster__muted">{{ record.phoneMask || '未填' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="班级" :width="110" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.orgName">{{ record.orgName }}</span>
|
||||
<span v-else class="roster__muted">未指定</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="报考(职业 / 工种 / 等级)" :width="230">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.occupation || '—' }}</div>
|
||||
<div class="roster__muted">
|
||||
{{ record.worktype || '—' }} · {{ record.jobLevel || '—' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" :width="130" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="SUBMIT_STATUS[record.submitStatus]?.color ?? 'gray'" size="small">
|
||||
{{ SUBMIT_STATUS[record.submitStatus]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<div class="roster__muted">
|
||||
{{ record.bindStatus === 1 ? '已核验' : '未核验' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="其它" :width="96" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tooltip v-if="record.extJson && Object.keys(record.extJson).length"
|
||||
:content="extText(record.extJson)">
|
||||
<a-tag size="small">扩展字段 {{ Object.keys(record.extJson).length }}</a-tag>
|
||||
</a-tooltip>
|
||||
<span v-else class="roster__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RosterImportReport, RosterVO } from '@/apis/xt/roster'
|
||||
import type { TaskVO } from '@/apis/xt/task'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconRefresh, IconUpload } from '@arco-design/web-vue/es/icon'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as rosterApi from '@/apis/xt/roster'
|
||||
import { SUBMIT_STATUS } from '@/apis/xt/roster'
|
||||
|
||||
/**
|
||||
* 任务「名单管理」抽屉(《03-接口设计》§8.1 ~ §8.2)—— FR-501 / FR-502。
|
||||
*
|
||||
* ## 为什么这个界面必须存在
|
||||
*
|
||||
* 发布任务的 5 项前置检查里有一项是「名册不能为空」(40016)。在它之前,
|
||||
* 名册只能靠直接改数据库来造 —— 而**名册是准入名单**:学生能不能进来填、
|
||||
* 填的是谁,全靠它。也就是说这个界面之前,整条填报链路在界面上是走不通的。
|
||||
*
|
||||
* ## 三处刻意的设计
|
||||
*
|
||||
* 1. **先体检、再导入(两步)**。实测两份真实文件的表头分别在第 1 行和第 6 行,
|
||||
* 工作表名也不同。一步导入的话,用户只会看到"成功 0 条",
|
||||
* 然后以为自己的文件坏了 —— 而真相是系统把标题行当成了表头。
|
||||
* 所以:先把"系统认的是第几行、认成了哪几列、哪几行有问题"摊开给他看,
|
||||
* **确认之后才写库**。
|
||||
*
|
||||
* 2. **把"表头在第 6 行"解释清楚**。看到"第 6 行"的人第一反应是"导错了"。
|
||||
* 所以那句提示里直接写明「它前面那 5 行是标题与说明,已跳过」——
|
||||
* 不解释的话,用户会去改自己那份**本来正确**的表格。
|
||||
*
|
||||
* 3. **行号 + 列字母 + 原值 + 原因,四样一起给**。只给"第 12 行有问题",
|
||||
* 用户还得自己回去找是哪一列;给了列字母与原因,他能直接改完再导一次。
|
||||
* 而"改好再来一次"是这个界面的正常用法(同证件号会合并更新,不会重复)。
|
||||
*/
|
||||
defineOptions({ name: 'TaskRosterDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const taskId = ref<number | null>(null)
|
||||
const taskName = ref('')
|
||||
const taskStatus = ref(0)
|
||||
|
||||
/** 与服务端 `RosterImportService.EDITABLE_TASK_STATUS` 同口径:{草稿, 收集中, 已暂停} */
|
||||
const EDITABLE = [0, 1, 4]
|
||||
const readonly = computed(() => !EDITABLE.includes(taskStatus.value))
|
||||
const readonlyReason = computed(() => (taskStatus.value === 2 ? '截止' : '归档'))
|
||||
const collecting = computed(() => taskStatus.value === 1)
|
||||
|
||||
// ── 导入 ──────────────────────────────────────────
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
const file = ref<File | null>(null)
|
||||
const mode = ref<'APPEND' | 'REPLACE'>('APPEND')
|
||||
const orgId = ref<number | undefined>(undefined)
|
||||
const checking = ref(false)
|
||||
const importing = ref(false)
|
||||
const report = ref<RosterImportReport | null>(null)
|
||||
const manualHeaderRow = ref<number | undefined>(undefined)
|
||||
/** 整表替换的二次确认只做一次(用户点过一次「确认替换」之后就走真导入) */
|
||||
const replaceConfirmed = ref(false)
|
||||
|
||||
const modeHint = computed(() =>
|
||||
mode.value === 'REPLACE'
|
||||
? '替换:清掉现有名册再导入(已有人核验时会拒绝)'
|
||||
: '合并:按身份证号去重,已有的更新、没有的新增'
|
||||
)
|
||||
|
||||
/** dryRun 时后端把预计结果放在 created/updated 里;这里用同一份数据,只为把标签说准 */
|
||||
const plannedNew = computed(() => report.value?.created ?? 0)
|
||||
const plannedUpdate = computed(() => report.value?.updated ?? 0)
|
||||
|
||||
const headerAlertType = computed(() => (report.value?.headerConfirmed ? 'success' : 'warning'))
|
||||
|
||||
const importButtonText = computed(() => {
|
||||
if (!report.value) return '确认导入'
|
||||
if (report.value.dryRun) {
|
||||
return mode.value === 'REPLACE'
|
||||
? `确认替换:导入这 ${report.value.success} 行`
|
||||
: `确认导入这 ${report.value.success} 行`
|
||||
}
|
||||
return '已完成导入'
|
||||
})
|
||||
|
||||
/**
|
||||
* 列的中文名 —— 报告里回的是字段(`real_name`),直接展示用户看不懂。
|
||||
* 只覆盖官方 21 列;多出来的列报告里已标成"非官方列"。
|
||||
*/
|
||||
const FIELD_NAMES: Record<string, string> = {
|
||||
seq_no: '序号',
|
||||
real_name: '姓名',
|
||||
id_card_no: '身份证号',
|
||||
education: '学历',
|
||||
phone: '联系电话',
|
||||
work_unit: '工作单位',
|
||||
occupation: '报考职业',
|
||||
worktype: '报考工种',
|
||||
job_level: '报考等级',
|
||||
is_retake: '是否补考',
|
||||
retake_theory: '补考理论',
|
||||
retake_practice: '补考实操',
|
||||
retake_overall: '补考综合',
|
||||
is_student: '是否在校生',
|
||||
school: '在读学校',
|
||||
major: '所学专业',
|
||||
enroll_date: '入学时间',
|
||||
work_years: '本职业工作年限',
|
||||
cert_occupation: '已获证书职业工种',
|
||||
cert_level: '已获证书等级',
|
||||
cert_no: '已获证书编号'
|
||||
}
|
||||
|
||||
function fieldName(field: string | null) {
|
||||
if (!field) return '—'
|
||||
return FIELD_NAMES[field] ?? field
|
||||
}
|
||||
|
||||
function extText(ext: Record<string, unknown>) {
|
||||
return Object.entries(ext).map(([k, v]) => `${k}:${String(v)}`).join('\n')
|
||||
}
|
||||
|
||||
// ── 班级下拉 ───────────────────────────────────────
|
||||
|
||||
interface ClassOption {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const classOptions = ref<ClassOption[]>([])
|
||||
const orgLoading = ref(false)
|
||||
|
||||
async function loadClasses() {
|
||||
if (classOptions.value.length) {
|
||||
return
|
||||
}
|
||||
orgLoading.value = true
|
||||
try {
|
||||
const res = await orgApi.getOrgTree()
|
||||
const out: ClassOption[] = []
|
||||
const walk = (nodes: orgApi.OrgTreeVO[] | undefined, parent: string) => {
|
||||
for (const n of nodes ?? []) {
|
||||
if (n.orgType === 3) {
|
||||
out.push({ id: n.id, label: parent ? `${parent} / ${n.orgName}` : n.orgName })
|
||||
}
|
||||
walk(n.children, n.orgType === 3 ? parent : n.orgName)
|
||||
}
|
||||
}
|
||||
walk(res.data, '')
|
||||
classOptions.value = out
|
||||
} finally {
|
||||
orgLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 名册列表 ───────────────────────────────────────
|
||||
|
||||
const listLoading = ref(false)
|
||||
const rows = ref<RosterVO[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive<{
|
||||
keyword: string
|
||||
submitStatus: number | undefined
|
||||
bindStatus: number | undefined
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({ keyword: '', submitStatus: undefined, bindStatus: undefined, pageNum: 1, pageSize: 10 })
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
/**
|
||||
* 一句话统计。没有它,用户看不出"名册导进来了没有、有多少人已经开始填",
|
||||
* 而这些恰恰是他打开这个抽屉最想知道的两件事。
|
||||
*/
|
||||
const stat = computed(() => {
|
||||
const bound = rows.value.filter((r) => r.bindStatus === 1).length
|
||||
const submitted = rows.value.filter((r) => r.submitStatus > 0).length
|
||||
return { t: total.value > 0, bound, submitted }
|
||||
})
|
||||
|
||||
async function loadList() {
|
||||
if (!taskId.value) {
|
||||
return
|
||||
}
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await rosterApi.getRosterList(taskId.value, {
|
||||
keyword: query.keyword.trim() || undefined,
|
||||
submitStatus: query.submitStatus,
|
||||
bindStatus: query.bindStatus,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
rows.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
loadList()
|
||||
}
|
||||
|
||||
// ── 打开 / 关闭 ────────────────────────────────────
|
||||
|
||||
async function open(task: TaskVO) {
|
||||
visible.value = true
|
||||
taskId.value = task.id
|
||||
taskName.value = task.taskName
|
||||
taskStatus.value = task.status
|
||||
resetReport()
|
||||
query.keyword = ''
|
||||
query.submitStatus = undefined
|
||||
query.bindStatus = undefined
|
||||
query.pageNum = 1
|
||||
await Promise.all([loadClasses(), loadList()])
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetReport()
|
||||
}
|
||||
|
||||
function resetReport() {
|
||||
file.value = null
|
||||
report.value = null
|
||||
manualHeaderRow.value = undefined
|
||||
mode.value = 'APPEND'
|
||||
orgId.value = undefined
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const picked = input.files?.[0] ?? null
|
||||
if (picked && picked.size > 10 * 1024 * 1024) {
|
||||
Message.warning('文件超过 10MB,请拆分后分批导入')
|
||||
input.value = ''
|
||||
file.value = null
|
||||
return
|
||||
}
|
||||
file.value = picked
|
||||
report.value = null
|
||||
}
|
||||
|
||||
// ── 体检 / 导入 ────────────────────────────────────
|
||||
|
||||
async function runDryRun(headerRow?: number) {
|
||||
if (!file.value || !taskId.value) {
|
||||
return
|
||||
}
|
||||
checking.value = true
|
||||
try {
|
||||
const res = await rosterApi.importRoster(taskId.value, file.value, {
|
||||
dryRun: true,
|
||||
mode: mode.value,
|
||||
orgId: orgId.value,
|
||||
headerRow: headerRow ?? undefined
|
||||
})
|
||||
report.value = withKeys(res.data ?? null)
|
||||
manualHeaderRow.value = report.value?.headerRow
|
||||
} catch (e) {
|
||||
// 40028(认不出表头)/ 40017(缺关键列)是**预期内的结果**,不是系统故障:
|
||||
// 把它当"需要人工指定表头"来处理,比弹一句红字有用
|
||||
const err = e as Error & { bizCode?: number }
|
||||
if (err?.bizCode === 40028 || err?.bizCode === 40017) {
|
||||
Modal.info({
|
||||
title: '需要人工指定表头',
|
||||
content: `${err.message}
|
||||
|
||||
请在下方把表头行号填上再试一次(比如标题在第 1 行、说明在第 3 行、表头在第 6 行时,填 6)。`,
|
||||
hideCancel: true
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 行 key:报告里的行号可能有多条问题,拼上 code 才是唯一的 */
|
||||
function withKeys(r: RosterImportReport | null): RosterImportReport | null {
|
||||
if (!r) return null
|
||||
const tag = (list: RosterImportReport['errors']) =>
|
||||
list.map((x, i) => ({ ...x, key: `${x.row}-${x.code}-${i}` })) as RosterImportReport['errors']
|
||||
return { ...r, errors: tag(r.errors), warnings: tag(r.warnings) }
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!file.value || !taskId.value || !report.value) {
|
||||
return
|
||||
}
|
||||
if (mode.value === 'REPLACE' && !replaceConfirmed.value) {
|
||||
Modal.warning({
|
||||
title: '整表替换会清掉现有名册',
|
||||
content: `现有的 ${total.value} 条名册会被替换成这份文件里的 ${report.value.success} 条。
|
||||
已经核验或已提交的人会被拒绝(服务端会拦下整次替换)。
|
||||
确认要用「替换」而不是「合并」吗?`,
|
||||
okText: '确认替换',
|
||||
hideCancel: false,
|
||||
onBeforeOk: () => {
|
||||
replaceConfirmed.value = true
|
||||
void confirmImport()
|
||||
return false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await rosterApi.importRoster(taskId.value, file.value, {
|
||||
dryRun: false,
|
||||
mode: mode.value,
|
||||
orgId: orgId.value,
|
||||
headerRow: manualHeaderRow.value
|
||||
})
|
||||
report.value = withKeys(res.data ?? null)
|
||||
const d = report.value
|
||||
const removedPart = d?.removed ? `、移除 ${d.removed} 人` : ''
|
||||
Message.success(`导入完成:新增 ${d?.created ?? 0} 人、更新 ${d?.updated ?? 0} 人${removedPart}`)
|
||||
query.pageNum = 1
|
||||
await loadList()
|
||||
emit('save-success')
|
||||
} finally {
|
||||
importing.value = false
|
||||
replaceConfirmed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.roster {
|
||||
&__task { margin-left: 12px; color: var(--color-text-3); font-weight: 400; }
|
||||
&__alert { margin-bottom: 16px; }
|
||||
&__intro { margin-bottom: 16px; line-height: 1.8; }
|
||||
&__form { margin-bottom: 4px; }
|
||||
&__item { margin-bottom: 8px; }
|
||||
&__pick { display: flex; gap: 10px; align-items: center; }
|
||||
&__file { display: none; }
|
||||
&__filename {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&__hint { font-size: 12px; line-height: 1.7; color: var(--color-text-3); }
|
||||
&__muted { font-size: 12px; color: var(--color-text-3); }
|
||||
&__mono { font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; }
|
||||
&__name { font-weight: 500; color: var(--color-text-1); }
|
||||
&__tool { margin-bottom: 12px; }
|
||||
&__table { margin-bottom: 8px; }
|
||||
&__mapped { color: rgb(var(--primary-6)); }
|
||||
&__code { margin-left: 6px; }
|
||||
|
||||
&__section {
|
||||
margin: 4px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
|
||||
span { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--color-text-3); }
|
||||
|
||||
&--bad { color: rgb(var(--danger-6)); }
|
||||
&--warn { color: rgb(var(--warning-6)); }
|
||||
}
|
||||
|
||||
&__stats { margin-bottom: 16px; }
|
||||
|
||||
&__stat {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 6px;
|
||||
|
||||
&--ok .roster__stat-num { color: rgb(var(--success-6)); }
|
||||
&--bad .roster__stat-num { color: rgb(var(--danger-6)); }
|
||||
&--warn .roster__stat-num { color: rgb(var(--warning-6)); }
|
||||
}
|
||||
|
||||
&__stat-num {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__stat-label { font-size: 12px; color: var(--color-text-3); }
|
||||
|
||||
&__header-alert { margin-bottom: 12px; line-height: 1.8; }
|
||||
&__footer { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; margin: 12px 0 4px; }
|
||||
&__search { width: 240px; }
|
||||
&__filter { width: 130px; }
|
||||
}
|
||||
</style>
|
||||
@@ -31,27 +31,17 @@
|
||||
下面 6 列合计 840 —— 只差 10px,几乎不出现横向滚动条。
|
||||
之前给到 1130px,结果是横向滚动 + 钉住的「操作」列把「状态」整个盖住,
|
||||
表头挤成了「进度操作」。**改列宽前先按这个口径算一遍。**
|
||||
-->
|
||||
<!--
|
||||
⚠️ 关于列宽与「操作」列的 fixed:**改之前先量一下表格可用宽度。**
|
||||
|
||||
实测(1366 宽的屏幕):
|
||||
· 收集任务页:左侧栏 200,表格可用约 824px
|
||||
· 账号管理页:左侧栏 200 + 左侧组织树卡约 250,表格可用**只有约 574px**
|
||||
|
||||
所以账号管理页必须保留 `fixed="right"`:
|
||||
列宽合计 800px 装不进 574px,必然横向滚动;
|
||||
横向滚动时**钉住的操作列会盖住紧邻它的那一列**,这是固定列的固有行为,不是配置错了。
|
||||
去掉 fixed 看似不压盖了,实际是把操作列滚出视野 ——
|
||||
用户根本看不到「编辑 / 重置密码 / 停用」,那比压盖更糟。
|
||||
|
||||
要真正消除压盖只有一条路:**把列数减到能在 574px 内放下**。
|
||||
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
|
||||
⚠️ 2026-09-29 加「名单管理」时的账(新增一个链接,所以必须从别处挪宽度):
|
||||
· 「任务」230 → 200、「填报时间」160 → 130,共省出 60
|
||||
· 「操作」300 → 340(草稿态有 7 个链接,会折成两行 —— 这是**故意认下的**:
|
||||
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
|
||||
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 820 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="任务" :width="230" ellipsis tooltip>
|
||||
<a-table-column title="任务" :width="200" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
<div class="task__name">{{ record.taskName }}</div>
|
||||
<div class="task__code">{{ record.taskCode }}</div>
|
||||
@@ -65,7 +55,7 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="填报时间" :width="160">
|
||||
<a-table-column title="填报时间" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<div class="task__muted">{{ fmt(record.startTime) }} 起</div>
|
||||
<div class="task__muted">{{ fmt(record.endTime) }} 止</div>
|
||||
@@ -95,12 +85,14 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="300" align="center" fixed="right">
|
||||
<a-table-column title="操作" :width="340" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="canManage" size="mini" wrap>
|
||||
<template v-if="record.status === TASK_STATUS_DRAFT">
|
||||
<a-link @click="onEdit(record)">编辑</a-link>
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<!-- 名册(准入名单)排在材料之前:没有它,学生连填报页都进不去 -->
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<!-- 文案必须与服务端 40018 的提示「请到『材料规则』里配置…」一字不差,
|
||||
否则用户按提示找不到地方 -->
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
@@ -114,6 +106,7 @@
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||
<a-link status="warning" @click="onPause(record)">暂停</a-link>
|
||||
@@ -122,6 +115,7 @@
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_PAUSED">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link @click="onConfigRoster(record)">名单管理</a-link>
|
||||
<a-link @click="onConfigMaterials(record)">材料规则</a-link>
|
||||
<a-link @click="onConfigForm(record)">表单设计</a-link>
|
||||
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
||||
@@ -129,10 +123,14 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_CLOSED">
|
||||
<!-- 截止后名册只能看不能改(抽屉里会说清),所以仍然给入口 -->
|
||||
<a-link @click="onConfigRoster(record)">查看名单</a-link>
|
||||
<a-link @click="onArchive(record)">归档</a-link>
|
||||
</template>
|
||||
|
||||
<span v-else class="task__muted">只读</span>
|
||||
<template v-else>
|
||||
<a-link @click="onConfigRoster(record)">查看名单</a-link>
|
||||
</template>
|
||||
</a-space>
|
||||
<span v-else class="task__muted">只读</span>
|
||||
</template>
|
||||
@@ -145,6 +143,7 @@
|
||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||
<TaskMaterialDrawer ref="materialDrawerRef" @save-success="load" />
|
||||
<TaskFormSchemaDrawer ref="formDrawerRef" @save-success="load" />
|
||||
<TaskRosterDrawer ref="rosterDrawerRef" @save-success="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
@@ -165,6 +164,7 @@ import TaskFormModal from './TaskFormModal.vue'
|
||||
import TaskFormSchemaDrawer from './TaskFormSchemaDrawer.vue'
|
||||
import TaskMaterialDrawer from './TaskMaterialDrawer.vue'
|
||||
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
||||
import TaskRosterDrawer from './TaskRosterDrawer.vue'
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
@@ -195,6 +195,7 @@ const formModalRef = useTemplateRef('formModalRef')
|
||||
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||
const materialDrawerRef = useTemplateRef('materialDrawerRef')
|
||||
const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||
const rosterDrawerRef = useTemplateRef('rosterDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
@@ -204,8 +205,8 @@ const formDrawerRef = useTemplateRef('formDrawerRef')
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请先到「名单管理」导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请重新导入名册',
|
||||
40016: '请点本行的「名单管理」,导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请到本行的「名单管理」里重新导入',
|
||||
// 必须说"点本行":「材料规则」也是一个顶层菜单(那个还是里程碑占位页),
|
||||
// 只说「请到『材料规则』里配置」会把人引到那个占位页去
|
||||
40018: '请点本行的「材料规则」,配置至少一项必传材料',
|
||||
@@ -285,6 +286,10 @@ function onConfigForm(row: TaskVO) {
|
||||
formDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
function onConfigRoster(row: TaskVO) {
|
||||
rosterDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
async function onDelete(row: TaskVO) {
|
||||
await taskApi.deleteTask(row.id)
|
||||
Message.success('已删除')
|
||||
|
||||
@@ -268,7 +268,7 @@ def main() -> int:
|
||||
cc = d.get("compressConfig") or {}
|
||||
check("rosterRequired 恒为 true", d.get("rosterRequired") is True,
|
||||
f"rosterRequired={d.get('rosterRequired')}")
|
||||
check("stat 为 null(名册未接,不能假装是 0)",
|
||||
check("名册为空时 stat 为 null(没有分母,不能假装是 0%)",
|
||||
d.get("stat") is None, f"stat={d.get('stat')}")
|
||||
check("auditConfig 默认:需班主任初审 + 需终审",
|
||||
ac.get("needClassAudit") is True and ac.get("needAdminAudit") is True,
|
||||
|
||||
@@ -491,6 +491,17 @@ def main() -> int:
|
||||
sql(f"SELECT count(*) FROM collect_roster WHERE task_id={task_id} AND bind_user_id IS NOT NULL")[1] == "1",
|
||||
"绑定数不为 1")
|
||||
|
||||
# ── 3b. 任务统计快照(§3.3)现在该有分母了 ─────────────
|
||||
print("\n[3b] 任务统计:名册接上之后,stat 不再恒为 null")
|
||||
# TaskStatVO 的类注释里写着"名册为空时返回 null,由界面显示「待导入名册」"——
|
||||
# 现在名册有 3 行,就该给出分母与比率,而不是继续显示"待导入名册"(那是**界面在说谎**)
|
||||
_, det = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
|
||||
st = (det.get("data") or {}).get("stat") or {}
|
||||
check("stat 不再为 null(有名册就有分母)", st != {}, f"{st}")
|
||||
check("rosterTotal = 名册行数 3", st.get("rosterTotal") == 3, f"{st}")
|
||||
check("submitRate = 0%(3 人里还没人提交)", st.get("submitRate") == "0%", f"{st}")
|
||||
check("unsubmittedCount = 3", st.get("unsubmittedCount") == 3, f"{st}")
|
||||
|
||||
# ── 4. 列表接口 ────────────────────────────────────
|
||||
print("\n[4] 名册列表(§8.2)")
|
||||
status, lst = call("GET", f"{base}/api/v1/tasks/{task_id}/roster?pageNum=1&pageSize=10", admin)
|
||||
@@ -557,6 +568,49 @@ def main() -> int:
|
||||
check("指定了不存在的工作表 → 明确报错并列出可用表名",
|
||||
r8.get("code") == 40001 and SHEET_NAME in str(r8.get("msg") or ""), f"{r8}")
|
||||
|
||||
# ── 8. 配置闭环:这是名册导入真正的意义 ────────────────
|
||||
print("\n[8] 配置闭环:名册到位后,任务应当能发布")
|
||||
# 发布有 5 项前置校验,其中「名册非空」(40016)就是本次补上的那一项。
|
||||
# 也就是说:在这之前,任务在界面上**根本发布不了** —— 这是第 2 步的落地标志。
|
||||
# 发布还缺「覆盖班级」与「必传材料」:它们各自的界面在 M1-02 / M1-04 已验过,
|
||||
# 这里用接口配齐,验的是"三样齐了之后发布真的通"。
|
||||
_, orgs = call("GET", f"{base}/api/v1/orgs/tree", admin)
|
||||
classes: list[dict] = []
|
||||
|
||||
def collect(nodes):
|
||||
for n in nodes or []:
|
||||
if n.get("orgType") == 3:
|
||||
classes.append(n)
|
||||
collect(n.get("children"))
|
||||
|
||||
collect(orgs.get("data"))
|
||||
if not classes:
|
||||
fail("配置闭环", "租户里没有班级节点,无法验证发布")
|
||||
else:
|
||||
org_id = classes[0]["id"]
|
||||
_, r9 = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", admin, {
|
||||
"orgs": [{"orgId": org_id, "expectedCount": 3}]
|
||||
})
|
||||
check("配覆盖班级成功", r9.get("code") == 0, f"{r9}")
|
||||
|
||||
_, r10 = call("POST", f"{base}/api/v1/tasks/{task_id}/materials", admin, {
|
||||
"materialCode": "IDCARD", "materialName": "身份证", "required": True,
|
||||
"fileTypes": ["jpg", "jpeg", "png"], "maxSizeKb": 1024, "maxCount": 2
|
||||
})
|
||||
check("配必传材料成功", r10.get("code") == 0, f"{r10}")
|
||||
|
||||
# force=true 只跳过 P1 级提醒(本例里"没指定初审人"),
|
||||
# 那 5 项阻断级校验(含 40016 名册非空)**force 也绕不过** —— 所以这条断言是真的
|
||||
_, r11 = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", admin, {"force": True})
|
||||
check("**发布成功**(名册 + 覆盖班级 + 必传材料 三样齐了)",
|
||||
r11.get("code") == 0, f"{r11}")
|
||||
_, after = call("GET", f"{base}/api/v1/tasks/{task_id}", admin)
|
||||
check("任务状态变成「收集中」",
|
||||
(after.get("data") or {}).get("status") == 1, f"{(after.get('data') or {}).get('status')}")
|
||||
check("发布后 stat 仍带分母(rosterTotal=3)",
|
||||
((after.get("data") or {}).get("stat") or {}).get("rosterTotal") == 3,
|
||||
f"{(after.get('data') or {}).get('stat')}")
|
||||
|
||||
print()
|
||||
print("=" * 76)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
|
||||
Reference in New Issue
Block a user