35 KiB
35 KiB
学生管理模块 - 前端技术方案
| 文档信息 | 内容 |
|---|---|
| 文档版本 | V1.0 |
| 项目名称 | 盘古用户平台(Pangu User Platform) |
| 模块名称 | 学生管理模块 - 前端 |
| 编写团队 | pangu |
| 创建日期 | 2026-01-31 |
| 审核状态 | 待审核 |
目录
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 | 最新两版本 | 待测试 |
文档结束