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

1282 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 学生管理模块 - 前端技术方案
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | V1.0 |
| **项目名称** | 盘古用户平台Pangu User Platform |
| **模块名称** | 学生管理模块 - 前端 |
| **编写团队** | pangu |
| **创建日期** | 2026-01-31 |
| **审核状态** | 待审核 |
---
## 目录
1. [技术栈说明](#1-技术栈说明)
2. [目录结构](#2-目录结构)
3. [页面设计](#3-页面设计)
4. [组件设计](#4-组件设计)
5. [API接口](#5-api接口)
6. [Mock数据](#6-mock数据)
7. [状态管理](#7-状态管理)
8. [编码规范](#8-编码规范)
9. [测试方案](#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 页面组件结构
```vue
<!-- 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 } | 节点点击事件 |
**组件代码示例**
```vue
<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 | ✓ | 支持搜索 |
**组件代码示例**
```vue
<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
**组件代码示例**
```vue
<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 接口定义文件
```javascript
// 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数据
```javascript
// 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全局状态管理。
如需共享状态(如学校树数据缓存),可使用:
```javascript
// 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 表格规范
```vue
<!-- 必须设置的属性 -->
<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 手机号脱敏
```vue
<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 删除确认
```javascript
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 | 最新两版本 | 待测试 |
---
*文档结束*