2026-09-15-batch-student-class-change.md 15 KB

批量学生换班级 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<ChangeClassStudentItem[]>;changeClassSubmitting / changeClassProgress;changeClassFailList
  • Produces: openChangeClassModal() 在 ids.length >= 1 时打开弹窗并填充列表

  • [ ] Step 1: 在换班级相关状态旁增加类型与列表状态

在 changeClassDialog / changeClassForm 附近加入(保留 changeClassForm 中目标班级字段;单人专用的 studentId/original* 可逐步不再依赖,由列表驱动):

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<ChangeClassStudentItem[]>([]);
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 + 拉详情」逻辑:

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

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 替换为:

<el-card shadow="never" class="mb-4">
  <template #header>
    <div class="text-lg font-medium">
      学生信息(共 {{ changeClassStudents.length }} 人)
    </div>
  </template>
  <el-table :data="changeClassStudents" max-height="240" border size="small">
    <el-table-column label="学号" prop="studentNo" min-width="110" />
    <el-table-column label="姓名" prop="studentName" min-width="90" />
    <el-table-column label="原学院" prop="originalCollegeName" min-width="120" show-overflow-tooltip />
    <el-table-column label="原专业" prop="originalMajorName" min-width="120" show-overflow-tooltip />
    <el-table-column label="原班级" prop="originalClassName" min-width="120" show-overflow-tooltip />
  </el-table>
</el-card>

保留下半区「新班级信息」表单不变(newCollegeId / newMajorId / newClassId / effectiveTime / changeReason 及原有 @change 处理器)。

  • Step 2: 失败明细与提交态 footer

在表单下方、</el-form> 之后增加失败列表;footer 按钮增加 loading/progress:

<div v-if="changeClassFailList.length" class="mt-3">
  <el-alert type="error" :closable="false" title="部分学生提交失败">
    <ul class="pl-4 m-0">
      <li v-for="(item, idx) in changeClassFailList" :key="idx">
        {{ item.studentNo }} {{ item.studentName }}:{{ item.reason }}
      </li>
    </ul>
  </el-alert>
</div>

<template #footer>
  <div class="dialog-footer">
    <el-button :disabled="changeClassSubmitting" @click="changeClassDialog.visible = false">取消</el-button>
    <el-button type="primary" :loading="changeClassSubmitting" @click="submitChangeClass">
      <template v-if="changeClassSubmitting">
        提交中 {{ changeClassProgress.current }}/{{ changeClassProgress.total }}
      </template>
      <template v-else>确定</template>
    </el-button>
  </div>
</template>
  • Step 3: 手测 UI

Expected: 多人打开时表格展示各人原班级;目标下拉联动仍可用;失败区初始为空。

  • Step 4: Commit
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

完整替换为:

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
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 文档「模块说明」后增加章节

## 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

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 命名一致。