pangu-user-platform/docs/05-模块技术方案/学生管理/02-前端技术方案.md

35 KiB
Raw Permalink Blame History

学生管理模块 - 前端技术方案


文档信息 内容
文档版本 V1.0
项目名称 盘古用户平台Pangu User Platform
模块名称 学生管理模块 - 前端
编写团队 pangu
创建日期 2026-01-31
审核状态 待审核

目录

  1. 技术栈说明
  2. 目录结构
  3. 页面设计
  4. 组件设计
  5. API接口
  6. Mock数据
  7. 状态管理
  8. 编码规范
  9. 测试方案

1. 技术栈说明

技术 版本 用途
Vue 3.5.26 前端框架
Vite 7.3.1 构建工具
Element Plus 2.13.2 UI组件库
Pinia 3.0.4 状态管理
Vue Router 4.6.4 路由管理
Axios 1.13.4 HTTP请求
MockJS 1.1.0 Mock数据

2. 目录结构

src/
├── api/
│   └── student.js                 # 学生管理API接口
├── mock/
│   └── student.js                 # 学生管理Mock数据
├── views/
│   └── student/
│       ├── index.vue              # 学生管理主页面
│       └── components/
│           ├── StudentDialog.vue  # 新增/编辑学生弹窗
│           ├── ImportDialog.vue   # 批量导入弹窗
│           └── SchoolTree.vue     # 学校树组件
└── router/
    └── index.js                   # 路由配置(已包含学生路由)

3. 页面设计

3.1 页面布局

学生管理页面采用左右分栏布局:

┌────────────────────────────────────────────────────────────────────┐
│                           学生管理                                  │
├────────────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌────────────────────────────────────────────────┐│
│ │              │ │  搜索区域                                      ││
│ │              │ │  [姓名] [学号] [性别▼] [手机号] [学科▼]        ││
│ │   学校树     │ │                    [查询] [重置]              ││
│ │              │ ├────────────────────────────────────────────────┤│
│ │  ▼ 武汉市第一 │ │  [+新增] [批量导入]                           ││
│ │    ├ 七年级  │ ├────────────────────────────────────────────────┤│
│ │    │ ├ 1班  │ │                                                ││
│ │    │ ├ 2班  │ │  表格区域                                      ││
│ │    │ └ 3班  │ │  ┌────┬────┬────┬────┬────┬────┬────┬────┐    ││
│ │    ├ 八年级  │ │  │姓名│学号│性别│出生│地区│学校│年级│班级...│    ││
│ │    └ 九年级  │ │  ├────┼────┼────┼────┼────┼────┼────┼────┤    ││
│ │              │ │  │张三│STU.│ 男 │2015│湖北│武汉│七年│1班...│    ││
│ │  ▶ 武汉市第三 │ │  │李四│STU.│ 女 │2016│湖北│武汉│七年│2班...│    ││
│ │              │ │  └────┴────┴────┴────┴────┴────┴────┴────┘    ││
│ │  ▶ 武汉市水果 │ │                                                ││
│ │              │ │  分页: 共100条 [< 1 2 3 4 5 >] 10条/页         ││
│ │  [搜索学校]  │ │                                                ││
│ └──────────────┘ └────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────┘

3.2 页面组件结构

<!-- index.vue 结构 -->
<template>
  <div class="app-container">
    <el-row :gutter="20">
      <!-- 左侧学校树 -->
      <el-col :span="6">
        <SchoolTree @node-click="handleNodeClick" />
      </el-col>
      
      <!-- 右侧列表区域 -->
      <el-col :span="18">
        <!-- 搜索区域 -->
        <el-card shadow="never" class="search-wrapper">
          <SearchForm />
        </el-card>
        
        <!-- 表格区域 -->
        <el-card shadow="never" style="margin-top: 12px;">
          <ToolBar />
          <DataTable />
          <Pagination />
        </el-card>
      </el-col>
    </el-row>
    
    <!-- 弹窗组件 -->
    <StudentDialog />
    <ImportDialog />
  </div>
</template>

3.3 表格列定义

字段 prop 列宽 对齐 特殊处理
姓名 studentName min-width="80" left show-overflow-tooltip
学号 studentNo width="100" left -
性别 gender width="60" center Tag组件
出生年月 birthday width="100" left -
地区 regionPath min-width="150" left show-overflow-tooltip
学校 schoolName min-width="140" left show-overflow-tooltip
年级 gradeName width="80" left -
班级 className width="60" left -
学科 subjectName width="60" left -
用户昵称 memberNickname width="100" left -
用户手机号 memberPhone width="120" left 脱敏显示
操作 - width="120" center fixed="right"

3.4 搜索条件

字段 组件 宽度 说明
姓名 el-input 150px 模糊查询
学号 el-input 150px 精确查询
性别 el-select 100px 下拉选择
用户手机号 el-input 150px 模糊查询
学科 el-select 120px 下拉选择

4. 组件设计

4.1 SchoolTree.vue - 学校树组件

功能说明:左侧学校树,支持搜索过滤和节点点击筛选

Props

属性 类型 必填 默认值 说明
defaultExpandAll boolean false 是否默认展开所有节点

Events

事件名 参数 说明
node-click { nodeType, nodeId, nodeData } 节点点击事件

组件代码示例

<template>
  <el-card shadow="never" class="school-tree-card">
    <template #header>
      <span>学校列表</span>
    </template>
    
    <!-- 搜索框 -->
    <el-input
      v-model="filterText"
      placeholder="搜索学校"
      clearable
      :prefix-icon="Search"
      style="margin-bottom: 12px;"
    />
    
    <!-- 树形组件 -->
    <el-scrollbar height="calc(100vh - 280px)">
      <el-tree
        ref="treeRef"
        :data="schoolTree"
        :props="defaultProps"
        :filter-node-method="filterNode"
        :expand-on-click-node="false"
        highlight-current
        default-expand-all
        @node-click="handleNodeClick"
      >
        <template #default="{ node, data }">
          <span class="tree-node">
            <el-icon v-if="data.nodeType === 'school'"><School /></el-icon>
            <el-icon v-else-if="data.nodeType === 'grade'"><Collection /></el-icon>
            <el-icon v-else><User /></el-icon>
            <span style="margin-left: 4px;">{{ node.label }}</span>
          </span>
        </template>
      </el-tree>
    </el-scrollbar>
  </el-card>
</template>

<script setup>
import { ref, watch } from 'vue'
import { Search, School, Collection, User } from '@element-plus/icons-vue'
import { getSchoolTree } from '@/api/student'

const emit = defineEmits(['node-click'])

const filterText = ref('')
const treeRef = ref()
const schoolTree = ref([])

const defaultProps = {
  children: 'children',
  label: 'label'
}

// 获取学校树数据
const loadSchoolTree = async () => {
  const res = await getSchoolTree()
  schoolTree.value = res.data || []
}

// 过滤节点
const filterNode = (value, data) => {
  if (!value) return true
  return data.label.includes(value)
}

// 监听搜索输入
watch(filterText, (val) => {
  treeRef.value?.filter(val)
})

// 节点点击
const handleNodeClick = (data) => {
  emit('node-click', {
    nodeType: data.nodeType,
    nodeId: data.id,
    nodeData: data
  })
}

// 初始化
loadSchoolTree()
</script>

<style scoped>
.school-tree-card {
  height: calc(100vh - 140px);
}
.tree-node {
  display: flex;
  align-items: center;
}
</style>

4.2 StudentDialog.vue - 新增/编辑学生弹窗

功能说明:新增和编辑学生信息的弹窗表单

Props

属性 类型 必填 默认值 说明
visible boolean false 弹窗显示状态
studentId number null 学生ID编辑时传入

Events

事件名 参数 说明
update:visible boolean 更新显示状态
success - 保存成功回调

表单字段

字段 组件 必填 校验规则
学生姓名 el-input 非空最大50字符
学号 el-input - 最大32字符
性别 el-radio-group - -
出生日期 el-date-picker - 格式YYYY-MM
所属区域 el-cascader 三级级联
所属学校 el-select 依赖区域
所属年级 el-select 依赖学校
所属班级 el-select 依赖年级
学科 el-select - -
归属用户 el-select 支持搜索

组件代码示例

<template>
  <el-dialog
    v-model="dialogVisible"
    :title="isEdit ? '编辑学生' : '新增学生'"
    width="650px"
    :close-on-click-modal="false"
    destroy-on-close
    @open="handleOpen"
  >
    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="100px"
    >
      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="学生姓名" prop="studentName">
            <el-input v-model="form.studentName" placeholder="请输入学生姓名" maxlength="50" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="学号" prop="studentNo">
            <el-input v-model="form.studentNo" placeholder="请输入学号" maxlength="32" />
          </el-form-item>
        </el-col>
      </el-row>
      
      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="性别" prop="gender">
            <el-radio-group v-model="form.gender">
              <el-radio label="1">男</el-radio>
              <el-radio label="2">女</el-radio>
              <el-radio label="0">未知</el-radio>
            </el-radio-group>
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="出生年月" prop="birthday">
            <el-date-picker
              v-model="form.birthday"
              type="month"
              placeholder="选择出生年月"
              format="YYYY-MM"
              value-format="YYYY-MM"
              style="width: 100%"
            />
          </el-form-item>
        </el-col>
      </el-row>
      
      <el-form-item label="所属区域" prop="regionId">
        <el-cascader
          v-model="form.regionIds"
          :options="regionOptions"
          :props="{ value: 'regionId', label: 'regionName', children: 'children' }"
          placeholder="请选择区域"
          style="width: 100%"
          @change="handleRegionChange"
        />
      </el-form-item>
      
      <el-row :gutter="20">
        <el-col :span="8">
          <el-form-item label="学校" prop="schoolId">
            <el-select v-model="form.schoolId" placeholder="请选择学校" @change="handleSchoolChange" style="width: 100%">
              <el-option
                v-for="item in schoolOptions"
                :key="item.schoolId"
                :label="item.schoolName"
                :value="item.schoolId"
              />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="年级" prop="schoolGradeId">
            <el-select v-model="form.schoolGradeId" placeholder="请选择年级" @change="handleGradeChange" style="width: 100%">
              <el-option
                v-for="item in gradeOptions"
                :key="item.id"
                :label="item.gradeName"
                :value="item.id"
              />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="8">
          <el-form-item label="班级" prop="schoolClassId">
            <el-select v-model="form.schoolClassId" placeholder="请选择班级" style="width: 100%">
              <el-option
                v-for="item in classOptions"
                :key="item.id"
                :label="item.className"
                :value="item.id"
              />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
      
      <el-row :gutter="20">
        <el-col :span="12">
          <el-form-item label="学科" prop="subjectId">
            <el-select v-model="form.subjectId" placeholder="请选择学科" clearable style="width: 100%">
              <el-option
                v-for="item in subjectOptions"
                :key="item.subjectId"
                :label="item.subjectName"
                :value="item.subjectId"
              />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="归属用户" prop="memberId">
            <el-select
              v-model="form.memberId"
              placeholder="请输入手机号搜索"
              filterable
              remote
              :remote-method="searchMember"
              :loading="memberLoading"
              style="width: 100%"
            >
              <el-option
                v-for="item in memberOptions"
                :key="item.memberId"
                :label="`${item.nickname}${item.phone}`"
                :value="item.memberId"
              />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
    
    <template #footer>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getStudent, addStudent, updateStudent, searchMemberList } from '@/api/student'
import { getRegionTree } from '@/api/region'
import { getSchoolListByRegion, getGradesBySchool, getClassesByGrade } from '@/api/school'
import { getSubjectList } from '@/api/subject'

const props = defineProps({
  visible: { type: Boolean, default: false },
  studentId: { type: Number, default: null }
})

const emit = defineEmits(['update:visible', 'success'])

const dialogVisible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val)
})

const isEdit = computed(() => !!props.studentId)

const formRef = ref()
const submitLoading = ref(false)
const memberLoading = ref(false)

// 表单数据
const form = ref({
  studentName: '',
  studentNo: '',
  gender: '0',
  birthday: '',
  regionIds: [],
  regionId: null,
  schoolId: null,
  schoolGradeId: null,
  schoolClassId: null,
  subjectId: null,
  memberId: null
})

// 下拉选项
const regionOptions = ref([])
const schoolOptions = ref([])
const gradeOptions = ref([])
const classOptions = ref([])
const subjectOptions = ref([])
const memberOptions = ref([])

// 校验规则
const rules = {
  studentName: [
    { required: true, message: '请输入学生姓名', trigger: 'blur' },
    { max: 50, message: '姓名最大50个字符', trigger: 'blur' }
  ],
  regionIds: [
    { required: true, message: '请选择区域', trigger: 'change' }
  ],
  schoolId: [
    { required: true, message: '请选择学校', trigger: 'change' }
  ],
  schoolGradeId: [
    { required: true, message: '请选择年级', trigger: 'change' }
  ],
  schoolClassId: [
    { required: true, message: '请选择班级', trigger: 'change' }
  ],
  memberId: [
    { required: true, message: '请选择归属用户', trigger: 'change' }
  ]
}

// 弹窗打开
const handleOpen = async () => {
  await loadOptions()
  if (isEdit.value) {
    await loadStudentInfo()
  }
}

// 加载选项数据
const loadOptions = async () => {
  const [regionRes, subjectRes] = await Promise.all([
    getRegionTree(),
    getSubjectList()
  ])
  regionOptions.value = regionRes.data || []
  subjectOptions.value = subjectRes.rows || []
}

// 加载学生信息(编辑模式)
const loadStudentInfo = async () => {
  const res = await getStudent(props.studentId)
  if (res.code === 200) {
    Object.assign(form.value, res.data)
    // 加载级联数据
    await handleRegionChange(form.value.regionIds)
    await handleSchoolChange(form.value.schoolId)
    await handleGradeChange(form.value.schoolGradeId)
  }
}

// 区域变更
const handleRegionChange = async (val) => {
  form.value.regionId = val ? val[val.length - 1] : null
  form.value.schoolId = null
  form.value.schoolGradeId = null
  form.value.schoolClassId = null
  schoolOptions.value = []
  gradeOptions.value = []
  classOptions.value = []
  
  if (form.value.regionId) {
    const res = await getSchoolListByRegion(form.value.regionId)
    schoolOptions.value = res.data || []
  }
}

// 学校变更
const handleSchoolChange = async (val) => {
  form.value.schoolGradeId = null
  form.value.schoolClassId = null
  gradeOptions.value = []
  classOptions.value = []
  
  if (val) {
    const res = await getGradesBySchool(val)
    gradeOptions.value = res.data || []
  }
}

// 年级变更
const handleGradeChange = async (val) => {
  form.value.schoolClassId = null
  classOptions.value = []
  
  if (val) {
    const res = await getClassesByGrade(val)
    classOptions.value = res.data || []
  }
}

// 搜索会员
const searchMember = async (query) => {
  if (query.length < 3) return
  memberLoading.value = true
  try {
    const res = await searchMemberList({ phone: query })
    memberOptions.value = res.rows || []
  } finally {
    memberLoading.value = false
  }
}

// 提交表单
const handleSubmit = async () => {
  const valid = await formRef.value?.validate()
  if (!valid) return
  
  submitLoading.value = true
  try {
    const api = isEdit.value ? updateStudent : addStudent
    const res = await api(form.value)
    if (res.code === 200) {
      ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
      dialogVisible.value = false
      emit('success')
    }
  } finally {
    submitLoading.value = false
  }
}

// 重置表单
watch(() => props.visible, (val) => {
  if (!val) {
    formRef.value?.resetFields()
    form.value = {
      studentName: '',
      studentNo: '',
      gender: '0',
      birthday: '',
      regionIds: [],
      regionId: null,
      schoolId: null,
      schoolGradeId: null,
      schoolClassId: null,
      subjectId: null,
      memberId: null
    }
  }
})
</script>

4.3 ImportDialog.vue - 批量导入弹窗

功能说明批量导入学生的弹窗支持下载模板和上传Excel

组件代码示例

<template>
  <el-dialog
    v-model="dialogVisible"
    title="批量导入学生"
    width="550px"
    :close-on-click-modal="false"
    destroy-on-close
  >
    <el-steps :active="step" simple style="margin-bottom: 24px;">
      <el-step title="下载模板" />
      <el-step title="上传文件" />
      <el-step title="导入结果" />
    </el-steps>
    
    <!-- 步骤1下载模板 -->
    <div v-if="step === 0" class="step-content">
      <el-alert
        title="导入说明"
        type="info"
        :closable="false"
        style="margin-bottom: 16px;"
      >
        <template #default>
          <p>1. 请先下载导入模板,按模板格式填写数据</p>
          <p>2. 必填字段:姓名、学号、用户手机号、区域、学校、年级、班级</p>
          <p>3. 单次导入数据量不超过1000条</p>
          <p>4. 若用户手机号不存在系统将自动创建家长用户初始密码123456</p>
        </template>
      </el-alert>
      
      <div style="text-align: center;">
        <el-button type="primary" :icon="Download" @click="downloadTemplate">
          下载导入模板
        </el-button>
      </div>
    </div>
    
    <!-- 步骤2上传文件 -->
    <div v-if="step === 1" class="step-content">
      <el-upload
        ref="uploadRef"
        :action="uploadUrl"
        :headers="uploadHeaders"
        :limit="1"
        :on-success="handleUploadSuccess"
        :on-error="handleUploadError"
        :before-upload="beforeUpload"
        :auto-upload="false"
        accept=".xlsx,.xls"
        drag
      >
        <el-icon class="el-icon--upload"><Upload /></el-icon>
        <div class="el-upload__text">
          将文件拖到此处,或 <em>点击上传</em>
        </div>
        <template #tip>
          <div class="el-upload__tip">
            只支持 .xlsx 或 .xls 格式文件大小不超过5MB
          </div>
        </template>
      </el-upload>
    </div>
    
    <!-- 步骤3导入结果 -->
    <div v-if="step === 2" class="step-content">
      <el-result
        v-if="importResult.failCount === 0"
        icon="success"
        :title="`导入成功,共${importResult.successCount}条数据`"
      />
      
      <div v-else>
        <el-alert
          :title="`成功${importResult.successCount}条,失败${importResult.failCount}条`"
          :type="importResult.successCount > 0 ? 'warning' : 'error'"
          :closable="false"
          style="margin-bottom: 16px;"
        />
        
        <el-table :data="importResult.failList" border max-height="300">
          <el-table-column prop="row" label="行号" width="80" align="center" />
          <el-table-column prop="reason" label="失败原因" show-overflow-tooltip />
        </el-table>
      </div>
    </div>
    
    <template #footer>
      <el-button v-if="step === 0" @click="dialogVisible = false">取消</el-button>
      <el-button v-if="step === 0" type="primary" @click="step = 1">下一步</el-button>
      
      <el-button v-if="step === 1" @click="step = 0">上一步</el-button>
      <el-button v-if="step === 1" type="primary" @click="submitUpload" :loading="uploading">
        开始导入
      </el-button>
      
      <el-button v-if="step === 2" type="primary" @click="handleClose">完成</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Upload } from '@element-plus/icons-vue'
import { downloadStudentTemplate } from '@/api/student'

const props = defineProps({
  visible: { type: Boolean, default: false }
})

const emit = defineEmits(['update:visible', 'success'])

const dialogVisible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val)
})

const step = ref(0)
const uploadRef = ref()
const uploading = ref(false)
const uploadUrl = '/api/student/import'
const uploadHeaders = {
  Authorization: 'Bearer ' + localStorage.getItem('token')
}

const importResult = ref({
  successCount: 0,
  failCount: 0,
  failList: []
})

// 下载模板
const downloadTemplate = async () => {
  try {
    await downloadStudentTemplate()
    ElMessage.success('模板下载成功')
  } catch (error) {
    ElMessage.error('模板下载失败')
  }
}

// 上传前校验
const beforeUpload = (file) => {
  const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
                  file.type === 'application/vnd.ms-excel'
  const isLt5M = file.size / 1024 / 1024 < 5
  
  if (!isExcel) {
    ElMessage.error('只支持上传Excel文件')
    return false
  }
  if (!isLt5M) {
    ElMessage.error('文件大小不能超过5MB')
    return false
  }
  return true
}

// 提交上传
const submitUpload = () => {
  uploading.value = true
  uploadRef.value?.submit()
}

// 上传成功
const handleUploadSuccess = (response) => {
  uploading.value = false
  if (response.code === 200) {
    importResult.value = response.data
    step.value = 2
    if (importResult.value.successCount > 0) {
      emit('success')
    }
  } else {
    ElMessage.error(response.msg || '导入失败')
  }
}

// 上传失败
const handleUploadError = () => {
  uploading.value = false
  ElMessage.error('上传失败,请重试')
}

// 关闭弹窗
const handleClose = () => {
  step.value = 0
  importResult.value = { successCount: 0, failCount: 0, failList: [] }
  dialogVisible.value = false
}
</script>

<style scoped>
.step-content {
  min-height: 200px;
  padding: 20px 0;
}
</style>

5. API接口

5.1 接口定义文件

// src/api/student.js

import request from '@/utils/request'

/**
 * 查询学生列表
 * @param {Object} params 查询参数
 * @returns {Promise}
 */
export function getStudentList(params) {
  return request({
    url: '/api/student/list',
    method: 'get',
    params
  })
}

/**
 * 获取学生详情
 * @param {Number} studentId 学生ID
 * @returns {Promise}
 */
export function getStudent(studentId) {
  return request({
    url: `/api/student/${studentId}`,
    method: 'get'
  })
}

/**
 * 新增学生
 * @param {Object} data 学生数据
 * @returns {Promise}
 */
export function addStudent(data) {
  return request({
    url: '/api/student',
    method: 'post',
    data
  })
}

/**
 * 修改学生
 * @param {Object} data 学生数据
 * @returns {Promise}
 */
export function updateStudent(data) {
  return request({
    url: '/api/student',
    method: 'put',
    data
  })
}

/**
 * 删除学生
 * @param {Number} studentId 学生ID
 * @returns {Promise}
 */
export function deleteStudent(studentId) {
  return request({
    url: `/api/student/${studentId}`,
    method: 'delete'
  })
}

/**
 * 下载导入模板
 * @returns {Promise}
 */
export function downloadStudentTemplate() {
  return request({
    url: '/api/student/template',
    method: 'get',
    responseType: 'blob'
  }).then(res => {
    const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
    const link = document.createElement('a')
    link.href = URL.createObjectURL(blob)
    link.download = '学生导入模板.xlsx'
    link.click()
    URL.revokeObjectURL(link.href)
  })
}

/**
 * 获取学校树
 * @returns {Promise}
 */
export function getSchoolTree() {
  return request({
    url: '/api/school/tree',
    method: 'get'
  })
}

/**
 * 搜索会员列表(用于选择归属用户)
 * @param {Object} params 查询参数
 * @returns {Promise}
 */
export function searchMemberList(params) {
  return request({
    url: '/api/member/search',
    method: 'get',
    params
  })
}

5.2 请求参数说明

getStudentList 参数

参数 类型 必填 说明
studentName string 学生姓名(模糊)
studentNo string 学号
gender string 性别
schoolId number 学校ID
schoolGradeId number 学校年级ID
schoolClassId number 学校班级ID
subjectId number 学科ID
memberPhone string 归属用户手机号
pageNum number 页码
pageSize number 每页条数

6. Mock数据

// src/mock/student.js

import Mock from 'mockjs'

// 学校树Mock数据
const schoolTree = [
  {
    id: 1,
    label: '武汉市第一中学',
    nodeType: 'school',
    children: [
      {
        id: 101,
        label: '七年级',
        nodeType: 'grade',
        schoolId: 1,
        children: [
          { id: 1001, label: '1班', nodeType: 'class', schoolGradeId: 101 },
          { id: 1002, label: '2班', nodeType: 'class', schoolGradeId: 101 },
          { id: 1003, label: '3班', nodeType: 'class', schoolGradeId: 101 }
        ]
      },
      {
        id: 102,
        label: '八年级',
        nodeType: 'grade',
        schoolId: 1,
        children: []
      }
    ]
  },
  {
    id: 3,
    label: '武汉市水果湖小学',
    nodeType: 'school',
    children: [
      { id: 301, label: '一年级', nodeType: 'grade', schoolId: 3, children: [] },
      { id: 302, label: '二年级', nodeType: 'grade', schoolId: 3, children: [] },
      { id: 303, label: '三年级', nodeType: 'grade', schoolId: 3, children: [] }
    ]
  }
]

// 学生列表Mock数据
const studentList = Mock.mock({
  'rows|20': [
    {
      'studentId|+1': 1,
      'studentName': '@cname',
      'studentNo': /STU2026\d{4}/,
      'gender|1': ['0', '1', '2'],
      'birthday': '@date("yyyy-MM")',
      'regionPath': '湖北省-武汉市-武昌区',
      'schoolName': '武汉市第一中学',
      'gradeName': '七年级',
      'className': '@pick(["1班", "2班", "3班"])',
      'subjectName': '@pick(["语文", "数学", "英语", ""])',
      'memberNickname': '@cname',
      'memberPhone': /138\*\*\*\*\d{4}/
    }
  ],
  'total': 100
})

// 学校树
Mock.mock(/\/api\/school\/tree/, 'get', () => {
  return { code: 200, msg: '查询成功', data: schoolTree }
})

// 学生列表
Mock.mock(/\/api\/student\/list/, 'get', (options) => {
  return {
    code: 200,
    msg: '查询成功',
    total: studentList.total,
    rows: studentList.rows
  }
})

// 学生详情
Mock.mock(/\/api\/student\/\d+/, 'get', (options) => {
  const id = parseInt(options.url.match(/\/api\/student\/(\d+)/)[1])
  return {
    code: 200,
    msg: '查询成功',
    data: {
      studentId: id,
      studentName: Mock.mock('@cname'),
      studentNo: 'STU20260001',
      gender: '1',
      birthday: '2015-03',
      regionIds: [1, 11, 111],
      regionId: 111,
      schoolId: 1,
      schoolGradeId: 101,
      schoolClassId: 1001,
      subjectId: 1,
      memberId: 1
    }
  }
})

// 新增学生
Mock.mock('/api/student', 'post', () => {
  return { code: 200, msg: '新增成功' }
})

// 修改学生
Mock.mock('/api/student', 'put', () => {
  return { code: 200, msg: '修改成功' }
})

// 删除学生
Mock.mock(/\/api\/student\/\d+/, 'delete', () => {
  return { code: 200, msg: '删除成功' }
})

// 批量导入
Mock.mock('/api/student/import', 'post', () => {
  return {
    code: 200,
    msg: '导入成功',
    data: {
      successCount: 98,
      failCount: 2,
      failList: [
        { row: 5, reason: '学号已存在' },
        { row: 10, reason: '学校信息不匹配' }
      ]
    }
  }
})

// 搜索会员
Mock.mock(/\/api\/member\/search/, 'get', () => {
  return {
    code: 200,
    msg: '查询成功',
    rows: [
      { memberId: 1, nickname: '张三家长', phone: '13812345678' },
      { memberId: 2, nickname: '李四家长', phone: '13912345678' }
    ]
  }
})

7. 状态管理

本模块主要使用组件内部状态暂不需要Pinia全局状态管理。

如需共享状态(如学校树数据缓存),可使用:

// src/store/modules/school.js
import { defineStore } from 'pinia'
import { getSchoolTree } from '@/api/student'

export const useSchoolStore = defineStore('school', {
  state: () => ({
    schoolTree: [],
    loading: false
  }),
  
  actions: {
    async fetchSchoolTree() {
      if (this.schoolTree.length > 0) return this.schoolTree
      
      this.loading = true
      try {
        const res = await getSchoolTree()
        this.schoolTree = res.data || []
        return this.schoolTree
      } finally {
        this.loading = false
      }
    },
    
    clearCache() {
      this.schoolTree = []
    }
  }
})

8. 编码规范

8.1 表格规范

<!-- 必须设置的属性 -->
<el-table
  :data="tableData"
  border
  stripe
  v-loading="loading"
  :header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
  style="width: 100%"
>
  <!-- 列宽必须明确指定 -->
  <el-table-column prop="studentName" label="姓名" min-width="80" show-overflow-tooltip />
  <el-table-column prop="studentNo" label="学号" width="100" />
  <el-table-column prop="gender" label="性别" width="60" align="center">
    <template #default="{ row }">
      <el-tag :type="row.gender === '1' ? '' : row.gender === '2' ? 'danger' : 'info'">
        {{ { '0': '未知', '1': '男', '2': '女' }[row.gender] }}
      </el-tag>
    </template>
  </el-table-column>
  <!-- 操作列固定右侧 -->
  <el-table-column label="操作" width="120" fixed="right" align="center">
    <template #default="{ row }">
      <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
      <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
    </template>
  </el-table-column>
</el-table>

8.2 手机号脱敏

<el-table-column prop="memberPhone" label="用户手机号" width="120">
  <template #default="{ row }">
    {{ row.memberPhone ? row.memberPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '-' }}
  </template>
</el-table-column>

8.3 删除确认

const handleDelete = (row) => {
  ElMessageBox.confirm(`确定要删除学生"${row.studentName}"吗?`, '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(async () => {
    const res = await deleteStudent(row.studentId)
    if (res.code === 200) {
      ElMessage.success('删除成功')
      handleQuery()
    }
  }).catch(() => {})
}

9. 测试方案

9.1 功能测试用例

用例编号 测试场景 测试步骤 预期结果
TC-001 学生列表查询 进入学生管理页面 正确显示学生列表和学校树
TC-002 条件筛选 输入姓名点击查询 列表按条件过滤
TC-003 学校树筛选 点击学校树节点 列表按学校/年级/班级过滤
TC-004 新增学生 点击新增,填写表单提交 新增成功,列表刷新
TC-005 编辑学生 点击编辑,修改信息提交 修改成功,列表刷新
TC-006 删除学生 点击删除,确认 删除成功,列表刷新
TC-007 批量导入 下载模板,填写数据,上传 导入成功,显示结果
TC-008 必填校验 不填必填项提交 提示错误信息
TC-009 学号重复校验 输入已存在学号 提示学号已存在
TC-010 分页功能 切换页码和每页条数 分页正确

9.2 兼容性测试

浏览器 版本 测试结果
Chrome 最新两版本 待测试
Edge 最新两版本 待测试
Firefox 最新两版本 待测试

文档结束