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

---

| 文档信息 | 内容 |
|---------|------|
| **文档版本** | 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 | 最新两版本 | 待测试 |

---

*文档结束*
