From ae6ac58332993417261dfe4385eb90f57a057faf Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 15 Sep 2026 16:50:05 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E6=8D=A2=E7=8F=AD=E7=BA=A7=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../2026-09-15-batch-student-class-change.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-15-batch-student-class-change.md diff --git a/docs/superpowers/plans/2026-09-15-batch-student-class-change.md b/docs/superpowers/plans/2026-09-15-batch-student-class-change.md new file mode 100644 index 00000000..1089acff --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-batch-student-class-change.md @@ -0,0 +1,423 @@ +# 批量学生换班级 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 学生信息页「换班级」支持勾选多人,共用同一目标班级,前端串行调用现有单条接口写入 N 条 `status=0` 记录。 + +**Architecture:** 仅改前端 `studentMsg/index.vue`。弹窗改为「学生列表 + 共用目标表单」;提交时对每人 `POST /studentMsg/studentClassChange`;已在目标班的学生跳过计入失败明细;后端与后台脚本不动。 + +**Tech Stack:** Vue 3 + TypeScript + Element Plus、现有 `service.post`、`xg_student_class_change` 既有接口 + +**Spec:** `docs/superpowers/specs/2026-09-15-batch-student-class-change-design.md` + +## Global Constraints + +- 不改后端接口 / 表结构 / `process_student_class_change_fixed.sh` +- 不立即更新学生当前班级(仍由后台脚本处理) +- 多人共用同一套新学院/专业/班级/生效时间/原因 +- 原班级不限制;部分成功不回滚;有失败则保留弹窗 + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue` | 按钮、弹窗、打开逻辑、批量提交 | +| `docs/开发文档/API/学生学籍异动与换班级API.md` | 补充批量换班级(前端循环)说明 | + +--- + +### Task 1: 数据结构与打开弹窗(支持多人) + +**Files:** +- Modify: `web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue`(约 1911–1932、3339–3392) + +**Interfaces:** +- Produces: `changeClassStudents: Ref`;`changeClassSubmitting` / `changeClassProgress`;`changeClassFailList` +- Produces: `openChangeClassModal()` 在 `ids.length >= 1` 时打开弹窗并填充列表 + +- [ ] **Step 1: 在换班级相关状态旁增加类型与列表状态** + +在 `changeClassDialog` / `changeClassForm` 附近加入(保留 `changeClassForm` 中目标班级字段;单人专用的 studentId/original* 可逐步不再依赖,由列表驱动): + +```ts +interface ChangeClassStudentItem { + studentId: string | number; + studentNo: string; + studentName: string; + gender?: string; + originalCollegeId: string | number; + originalCollegeName: string; + originalMajorId: string | number; + originalMajorName: string; + originalClassId: string | number; + originalClassName: string; +} + +const changeClassStudents = ref([]); +const changeClassSubmitting = ref(false); +const changeClassProgress = ref({ current: 0, total: 0 }); +const changeClassFailList = ref<{ studentNo: string; studentName: string; reason: string }[]>([]); +``` + +- [ ] **Step 2: 重写 `openChangeClassModal`** + +替换现有「必须 length === 1 + 拉详情」逻辑: + +```ts +const openChangeClassModal = async () => { + if (ids.value.length === 0) { + ElMessage.warning('请选择要换班级的学生'); + return; + } + + const selectedStudents = studentList.value.filter((s) => ids.value.includes(s.id)); + if (selectedStudents.length === 0) { + ElMessage.error('未找到选中的学生信息'); + return; + } + + changeClassStudents.value = selectedStudents.map((s) => ({ + studentId: s.id, + studentNo: s.studentNo, + studentName: s.name, + gender: s.gender, + originalCollegeId: s.collegeId, + originalCollegeName: s.collegeName, + originalMajorId: s.majorId, + originalMajorName: s.majorName, + originalClassId: s.classId, + originalClassName: s.className + })); + + changeClassForm.newCollegeId = ''; + changeClassForm.newMajorId = ''; + changeClassForm.newClassId = ''; + changeClassForm.effectiveTime = ''; + changeClassForm.changeReason = ''; + majorOptions.value = []; + classOptions.value = []; + changeClassFailList.value = []; + changeClassProgress.value = { current: 0, total: 0 }; + + changeClassDialog.title = + changeClassStudents.value.length === 1 + ? '换班级申请' + : `批量换班级(${changeClassStudents.value.length} 人)`; + + await loadCollegeOptions(); + changeClassDialog.visible = true; +}; +``` + +说明:列表行已含学院/专业/班级字段,无需再逐个请求详情(减少等待)。若某行字段缺失,提交前该人记失败「学生信息不完整」。 + +- [ ] **Step 3: 浏览器手测打开弹窗** + +Run: 打开 `http://localhost/student/studentmsg/studentMsg`,勾选 1 人与多人,分别点「换班级」 + +Expected: +- 未勾选:按钮仍禁用(现有 `:disabled="multiple"` 即可,`multiple = !selection.length`) +- 勾选 1 / 多人:均可打开;标题分别为「换班级申请」/「批量换班级(N 人)」 +- 控制台无报错 + +- [ ] **Step 4: Commit** + +```bash +git add web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue +git commit -m "$(cat <<'EOF' +feat(studentMsg): 换班级弹窗支持加载多名学生 + +EOF +)" +``` + +--- + +### Task 2: 弹窗 UI — 学生列表 + 共用目标表单 + +**Files:** +- Modify: `web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue`(约 926–1035 换班级对话框模板) + +**Interfaces:** +- Consumes: `changeClassStudents`、`changeClassDialog`、`changeClassForm`、`changeClassFailList`、`changeClassSubmitting`、`changeClassProgress` +- Produces: 可见的学生表 + 目标表单;提交按钮绑定进度态 + +- [ ] **Step 1: 替换弹窗上半区「单人信息卡片」为表格** + +将「学生信息」`el-card` 内 `student-info-grid` 替换为: + +```vue + + + + + + + + + + +``` + +保留下半区「新班级信息」表单不变(`newCollegeId` / `newMajorId` / `newClassId` / `effectiveTime` / `changeReason` 及原有 `@change` 处理器)。 + +- [ ] **Step 2: 失败明细与提交态 footer** + +在表单下方、`` 之后增加失败列表;footer 按钮增加 loading/progress: + +```vue +
+ +
    +
  • + {{ item.studentNo }} {{ item.studentName }}:{{ item.reason }} +
  • +
+
+
+ + +``` + +- [ ] **Step 3: 手测 UI** + +Expected: 多人打开时表格展示各人原班级;目标下拉联动仍可用;失败区初始为空。 + +- [ ] **Step 4: Commit** + +```bash +git add web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue +git commit -m "$(cat <<'EOF' +feat(studentMsg): 换班级弹窗改为学生列表+共用目标表单 + +EOF +)" +``` + +--- + +### Task 3: 批量提交逻辑 + +**Files:** +- Modify: `web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue`(`submitChangeClass`,约 3469–3521) + +**Interfaces:** +- Consumes: `changeClassStudents`、`changeClassForm`、学院/专业/班级 options +- Produces: 串行写入 N 条记录;更新 `changeClassFailList` / progress;全成功关窗刷新 + +- [ ] **Step 1: 重写 `submitChangeClass`** + +完整替换为: + +```ts +const submitChangeClass = async () => { + const formRef = changeClassFormRef.value; + if (!formRef) return; + + await formRef.validate(async (valid: boolean) => { + if (!valid) return; + if (!changeClassStudents.value.length) { + ElMessage.warning('没有待换班级的学生'); + return; + } + + const newCollegeName = + collegeOptions.value.find((c) => c.id === changeClassForm.newCollegeId)?.label || ''; + const newMajorName = + majorOptions.value.find((m) => m.id === changeClassForm.newMajorId)?.label || ''; + const newClassName = + classOptions.value.find((c) => c.id === changeClassForm.newClassId)?.label || ''; + const changeTime = proxy?.parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}'); + + changeClassSubmitting.value = true; + changeClassFailList.value = []; + changeClassProgress.value = { current: 0, total: changeClassStudents.value.length }; + + let successCount = 0; + + try { + for (const student of changeClassStudents.value) { + changeClassProgress.value.current += 1; + + if ( + student.originalClassId === undefined || + student.originalClassId === null || + student.originalClassId === '' + ) { + changeClassFailList.value.push({ + studentNo: student.studentNo, + studentName: student.studentName, + reason: '学生信息不完整' + }); + continue; + } + + if (String(student.originalClassId) === String(changeClassForm.newClassId)) { + changeClassFailList.value.push({ + studentNo: student.studentNo, + studentName: student.studentName, + reason: '已在目标班级' + }); + continue; + } + + const submitData = { + studentId: student.studentId, + studentNo: student.studentNo, + studentName: student.studentName, + originalCollegeId: student.originalCollegeId, + originalCollegeName: student.originalCollegeName, + originalMajorId: student.originalMajorId, + originalMajorName: student.originalMajorName, + originalClassId: student.originalClassId, + originalClassCode: '', + originalClassName: student.originalClassName, + newCollegeId: changeClassForm.newCollegeId, + newCollegeName, + newMajorId: changeClassForm.newMajorId, + newMajorName, + newClassId: changeClassForm.newClassId, + newClassName, + changeReason: changeClassForm.changeReason, + changeType: '1', + changeTime, + effectiveTime: changeClassForm.effectiveTime, + status: '0' + }; + + try { + const res = await service.post('/studentMsg/studentClassChange', submitData); + if (res && res.code === 200) { + successCount += 1; + } else { + changeClassFailList.value.push({ + studentNo: student.studentNo, + studentName: student.studentName, + reason: res?.msg || '提交失败' + }); + } + } catch (error: any) { + console.error('提交换班级申请失败:', error); + changeClassFailList.value.push({ + studentNo: student.studentNo, + studentName: student.studentName, + reason: error?.message || '提交失败,请重试' + }); + } + } + + if (changeClassFailList.value.length === 0) { + ElMessage.success( + changeClassStudents.value.length === 1 + ? '换班级申请提交成功' + : `批量换班级成功(${successCount} 人)` + ); + changeClassDialog.visible = false; + await getList(); + } else if (successCount > 0) { + ElMessage.warning(`成功 ${successCount} 人,失败 ${changeClassFailList.value.length} 人`); + await getList(); + } else { + ElMessage.error('全部提交失败,请查看失败明细'); + } + } finally { + changeClassSubmitting.value = false; + } + }); +}; +``` + +- [ ] **Step 2: 手测提交场景** + +| 场景 | Expected | +|------|----------| +| 选 1 人,正常目标班 | 1 条 `status=0`;弹窗关闭;提示成功 | +| 选 3 人,不同原班,同一目标 | 3 条记录,目标字段相同 | +| 含 1 人已在目标班 | 该人失败明细「已在目标班级」;其他人成功;弹窗保留 | +| 全部已在目标班 | 全部失败;弹窗保留;不假装成功 | + +可用换班级记录页 `/` 下对应菜单或接口 `GET /studentMsg/studentClassChange/list` 核对。 + +- [ ] **Step 3: Commit** + +```bash +git add web/src/views/xg/xsjcxx/studentMsg/studentMsg/index.vue +git commit -m "$(cat <<'EOF' +feat(studentMsg): 换班级支持批量串行提交并汇总失败 + +EOF +)" +``` + +--- + +### Task 4: 文档同步 + +**Files:** +- Modify: `docs/开发文档/API/学生学籍异动与换班级API.md` +- Modify: `docs/superpowers/specs/2026-09-15-batch-student-class-change-design.md`(状态改为已实现) + +- [ ] **Step 1: 在 API 文档「模块说明」后增加章节** + +```markdown +## 3. 学生信息页批量换班级(前端) + +- 入口:学生信息页「换班级」按钮(权限 `studentMsg:class:change`) +- 行为:勾选 1~N 名学生,共用同一目标学院/专业/班级/生效时间/原因 +- 实现:前端串行多次调用 `POST /studentMsg/studentClassChange`,每人一条记录,`status=0` +- 后端:无新接口;班级实际变更仍由后台脚本 `学生换班级/process_student_class_change_fixed.sh` 处理 +- 边界:已在目标班级的学生跳过并计入失败明细;部分失败不回滚已成功记录 +``` + +并在文末更新记录表增加一行:`2026-09-15 | 补充学生信息页批量换班级说明 | Cursor` + +- [ ] **Step 2: Spec 状态改为「已实现」** + +- [ ] **Step 3: Commit** + +```bash +git add docs/开发文档/API/学生学籍异动与换班级API.md \ + docs/superpowers/specs/2026-09-15-batch-student-class-change-design.md +git commit -m "$(cat <<'EOF' +docs: 同步批量换班级 API 说明与设计状态 + +EOF +)" +``` + +--- + +## Spec coverage (self-review) + +| Spec 要求 | Task | +|-----------|------| +| 多人同一目标班级 | Task 2 + 3 | +| 原班级不限制 | Task 1(不校验同源) | +| 只写 status=0,脚本处理 | Task 3 payload | +| 前端循环现有接口 | Task 3 | +| 按钮支持多人 | Task 1(打开逻辑)+ 既有 disabled | +| 学生列表 + 共用表单 | Task 2 | +| 进度与失败保留弹窗 | Task 2 + 3 | +| 已在目标班跳过 | Task 3 | +| 不改后端/脚本 | 全任务遵守 | +| 文档同步 | Task 4 | +| 单人行为兼容 | Task 3 成功文案与 1 人路径 | + +**Placeholder scan:** 无 TBD /「类似 Task N」含糊步骤。 +**类型一致性:** `ChangeClassStudentItem` / `changeClassFailList` 在 Task 1–3 命名一致。