pangu-user-platform/docs/05-模块技术方案/会员管理/会员管理前端详细设计_v1.0.md

38 KiB
Raw Permalink Blame History

会员管理模块 - 前端详细设计


文档信息 内容
文档版本 V1.0
模块名称 会员管理模块 - 前端
编写团队 pangu
创建日期 2026-01-31

1. 设计概述

1.1 技术选型

技术 版本 说明
Vue 3.5.26 前端框架
Element Plus 2.13.2 UI组件库
Pinia 3.0.4 状态管理
Vue Router 4.6.4 路由管理
Axios 1.13.4 HTTP客户端
Vite 7.3.1 构建工具

1.2 目录结构

frontend/src/
├── api/
│   └── member.js                    # 会员API接口
├── views/
│   └── member/
│       ├── index.vue                # 会员列表页
│       ├── form.vue                 # 会员新增/编辑页
│       └── components/
│           ├── MemberSearch.vue     # 搜索表单组件
│           ├── MemberTable.vue      # 数据表格组件
│           ├── StudentBindDialog.vue # 学生绑定弹窗
│           └── PasswordDialog.vue   # 密码显示弹窗
├── store/
│   └── modules/
│       └── member.js                # 会员状态管理
└── utils/
    └── member.js                    # 会员相关工具函数

2. 路由配置

2.1 路由定义

// router/index.js
{
  path: '/member',
  component: Layout,
  redirect: '/member/list',
  name: 'Member',
  meta: { title: '会员管理', icon: 'peoples' },
  children: [
    {
      path: 'list',
      name: 'MemberList',
      component: () => import('@/views/member/index.vue'),
      meta: { title: '会员列表', activeMenu: '/member' }
    },
    {
      path: 'add',
      name: 'MemberAdd',
      component: () => import('@/views/member/form.vue'),
      meta: { title: '新增会员', activeMenu: '/member' },
      hidden: true
    },
    {
      path: 'edit/:id',
      name: 'MemberEdit',
      component: () => import('@/views/member/form.vue'),
      meta: { title: '编辑会员', activeMenu: '/member' },
      hidden: true
    }
  ]
}

3. API接口层

3.1 接口定义member.js

/**
 * 会员管理API接口
 * @author pangu
 */
import request from '@/utils/request'

// 查询会员列表
export function listMember(query) {
  return request({
    url: '/member/list',
    method: 'get',
    params: query
  })
}

// 获取会员详情
export function getMember(id) {
  return request({
    url: `/member/${id}`,
    method: 'get'
  })
}

// 新增会员
export function addMember(data) {
  return request({
    url: '/member',
    method: 'post',
    data
  })
}

// 修改会员
export function updateMember(data) {
  return request({
    url: '/member',
    method: 'put',
    data
  })
}

// 删除会员
export function deleteMember(id) {
  return request({
    url: `/member/${id}`,
    method: 'delete'
  })
}

// 重置会员密码
export function resetMemberPwd(id) {
  return request({
    url: `/member/resetPwd/${id}`,
    method: 'put'
  })
}

// 修改会员状态
export function changeMemberStatus(id, status) {
  return request({
    url: '/member/changeStatus',
    method: 'put',
    data: { memberId: id, status }
  })
}

// 绑定学生
export function bindStudent(memberId, studentId) {
  return request({
    url: '/member/bindStudent',
    method: 'post',
    data: { memberId, studentId }
  })
}

// 解绑学生
export function unbindStudent(memberId, studentId) {
  return request({
    url: `/member/unbindStudent/${memberId}/${studentId}`,
    method: 'delete'
  })
}

// 获取区域树
export function getRegionTree() {
  return request({
    url: '/region/tree',
    method: 'get'
  })
}

// 获取学校列表(根据区域)
export function getSchoolListByRegion(regionId) {
  return request({
    url: '/school/listByRegion',
    method: 'get',
    params: { regionId }
  })
}

// 获取年级列表(根据学校)
export function getGradeListBySchool(schoolId) {
  return request({
    url: '/school/gradeList',
    method: 'get',
    params: { schoolId }
  })
}

// 获取班级列表(根据学校年级)
export function getClassListByGrade(schoolGradeId) {
  return request({
    url: '/school/classList',
    method: 'get',
    params: { schoolGradeId }
  })
}

// 获取可绑定的学生列表
export function getBindableStudents(query) {
  return request({
    url: '/student/bindableList',
    method: 'get',
    params: query
  })
}

4. 页面组件设计

4.1 会员列表页index.vue

4.1.1 完整代码

<template>
  <div class="member-container">
    <!-- 搜索区域 -->
    <el-form :model="queryParams" ref="queryFormRef" :inline="true" class="search-form">
      <el-form-item label="手机号" prop="phone">
        <el-input
          v-model="queryParams.phone"
          placeholder="请输入手机号"
          clearable
          style="width: 200px"
          @keyup.enter="handleQuery"
        />
      </el-form-item>
      <el-form-item label="昵称" prop="nickname">
        <el-input
          v-model="queryParams.nickname"
          placeholder="请输入昵称"
          clearable
          style="width: 200px"
          @keyup.enter="handleQuery"
        />
      </el-form-item>
      <el-form-item label="身份类型" prop="identityType">
        <el-select
          v-model="queryParams.identityType"
          placeholder="请选择身份类型"
          clearable
          style="width: 150px"
        >
          <el-option label="家长" value="1" />
          <el-option label="教师" value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="状态" prop="status">
        <el-select
          v-model="queryParams.status"
          placeholder="请选择状态"
          clearable
          style="width: 120px"
        >
          <el-option label="正常" value="0" />
          <el-option label="停用" value="1" />
        </el-select>
      </el-form-item>
      <el-form-item label="注册时间">
        <el-date-picker
          v-model="dateRange"
          type="daterange"
          range-separator="-"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          value-format="YYYY-MM-DD"
          style="width: 260px"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 操作按钮区域 -->
    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <el-button
          type="primary"
          icon="Plus"
          @click="handleAdd"
          v-hasPermi="['user:member:add']"
        >新增</el-button>
      </el-col>
    </el-row>

    <!-- 数据表格 -->
    <el-table
      v-loading="loading"
      :data="memberList"
      border
      stripe
      style="width: 100%"
    >
      <el-table-column label="会员编号" prop="memberCode" width="150" show-overflow-tooltip />
      <el-table-column label="手机号" prop="phone" width="130">
        <template #default="scope">
          {{ maskPhone(scope.row.phone) }}
        </template>
      </el-table-column>
      <el-table-column label="昵称" prop="nickname" min-width="120" show-overflow-tooltip />
      <el-table-column label="性别" prop="gender" width="80" align="center">
        <template #default="scope">
          {{ formatGender(scope.row.gender) }}
        </template>
      </el-table-column>
      <el-table-column label="出生日期" prop="birthday" width="120" />
      <el-table-column label="身份类型" prop="identityType" width="100" align="center">
        <template #default="scope">
          <el-tag :type="scope.row.identityType === '1' ? 'info' : 'success'">
            {{ scope.row.identityType === '1' ? '家长' : '教师' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="注册时间" prop="registerTime" width="170" />
      <el-table-column label="注册来源" prop="registerSource" width="100" align="center">
        <template #default="scope">
          {{ formatRegisterSource(scope.row.registerSource) }}
        </template>
      </el-table-column>
      <el-table-column label="状态" prop="status" width="80" align="center">
        <template #default="scope">
          <el-switch
            v-model="scope.row.status"
            active-value="0"
            inactive-value="1"
            @change="handleStatusChange(scope.row)"
            v-hasPermi="['user:member:edit']"
          />
        </template>
      </el-table-column>
      <el-table-column label="操作" width="200" fixed="right" align="center">
        <template #default="scope">
          <el-button
            link
            type="primary"
            icon="Edit"
            @click="handleEdit(scope.row)"
            v-hasPermi="['user:member:edit']"
          >编辑</el-button>
          <el-button
            link
            type="primary"
            icon="Key"
            @click="handleResetPwd(scope.row)"
            v-hasPermi="['user:member:resetPwd']"
          >重置密码</el-button>
          <el-popconfirm
            title="确认删除该会员吗?"
            confirm-button-text="确定"
            cancel-button-text="取消"
            @confirm="handleDelete(scope.row)"
          >
            <template #reference>
              <el-button
                link
                type="danger"
                icon="Delete"
                v-hasPermi="['user:member:remove']"
              >删除</el-button>
            </template>
          </el-popconfirm>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      class="pagination"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :page-sizes="[10, 20, 50, 100]"
      :total="total"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />

    <!-- 密码显示弹窗 -->
    <el-dialog
      v-model="passwordDialogVisible"
      title="密码重置成功"
      width="400px"
      :close-on-click-modal="false"
    >
      <div class="password-display">
        <el-alert type="success" :closable="false" show-icon>
          <template #title>
            <span>新密码已生成,请妥善保管</span>
          </template>
        </el-alert>
        <div class="password-content">
          <span class="label">新密码:</span>
          <span class="password">{{ newPassword }}</span>
          <el-button type="primary" link @click="copyPassword">
            <el-icon><DocumentCopy /></el-icon>
            复制
          </el-button>
        </div>
      </div>
      <template #footer>
        <el-button type="primary" @click="passwordDialogVisible = false">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { DocumentCopy } from '@element-plus/icons-vue'
import {
  listMember,
  deleteMember,
  resetMemberPwd,
  changeMemberStatus
} from '@/api/member'

const router = useRouter()

// 查询参数
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  phone: '',
  nickname: '',
  identityType: '',
  status: '',
  beginTime: '',
  endTime: ''
})

// 日期范围
const dateRange = ref([])

// 数据列表
const memberList = ref([])
const total = ref(0)
const loading = ref(false)

// 密码弹窗
const passwordDialogVisible = ref(false)
const newPassword = ref('')

// 查询表单引用
const queryFormRef = ref(null)

// 页面初始化
onMounted(() => {
  getList()
})

// 获取会员列表
async function getList() {
  loading.value = true
  try {
    // 处理日期范围
    if (dateRange.value && dateRange.value.length === 2) {
      queryParams.beginTime = dateRange.value[0]
      queryParams.endTime = dateRange.value[1]
    } else {
      queryParams.beginTime = ''
      queryParams.endTime = ''
    }
    
    const res = await listMember(queryParams)
    if (res.code === 200) {
      memberList.value = res.rows
      total.value = res.total
    }
  } finally {
    loading.value = false
  }
}

// 搜索
function handleQuery() {
  queryParams.pageNum = 1
  getList()
}

// 重置
function resetQuery() {
  queryFormRef.value?.resetFields()
  dateRange.value = []
  queryParams.beginTime = ''
  queryParams.endTime = ''
  handleQuery()
}

// 新增
function handleAdd() {
  router.push('/member/add')
}

// 编辑
function handleEdit(row) {
  router.push(`/member/edit/${row.memberId}`)
}

// 删除
async function handleDelete(row) {
  try {
    const res = await deleteMember(row.memberId)
    if (res.code === 200) {
      ElMessage.success('删除成功')
      getList()
    } else {
      ElMessage.error(res.msg || '删除失败')
    }
  } catch (error) {
    console.error('删除失败:', error)
  }
}

// 重置密码
async function handleResetPwd(row) {
  try {
    await ElMessageBox.confirm(
      `确认重置会员"${row.nickname}"的密码吗?`,
      '提示',
      { type: 'warning' }
    )
    
    const res = await resetMemberPwd(row.memberId)
    if (res.code === 200) {
      newPassword.value = res.password
      passwordDialogVisible.value = true
    } else {
      ElMessage.error(res.msg || '重置失败')
    }
  } catch (error) {
    if (error !== 'cancel') {
      console.error('重置密码失败:', error)
    }
  }
}

// 复制密码
async function copyPassword() {
  try {
    await navigator.clipboard.writeText(newPassword.value)
    ElMessage.success('复制成功')
  } catch (error) {
    ElMessage.error('复制失败,请手动复制')
  }
}

// 修改状态
async function handleStatusChange(row) {
  const text = row.status === '0' ? '启用' : '停用'
  try {
    await ElMessageBox.confirm(
      `确认${text}会员"${row.nickname}"吗?`,
      '提示',
      { type: 'warning' }
    )
    
    const res = await changeMemberStatus(row.memberId, row.status)
    if (res.code === 200) {
      ElMessage.success(`${text}成功`)
    } else {
      // 恢复原状态
      row.status = row.status === '0' ? '1' : '0'
      ElMessage.error(res.msg || `${text}失败`)
    }
  } catch (error) {
    // 取消操作,恢复原状态
    row.status = row.status === '0' ? '1' : '0'
  }
}

// 分页
function handleSizeChange(val) {
  queryParams.pageSize = val
  getList()
}

function handleCurrentChange(val) {
  queryParams.pageNum = val
  getList()
}

// 格式化函数
function maskPhone(phone) {
  if (!phone || phone.length !== 11) return phone
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}

function formatGender(gender) {
  const map = { '0': '未知', '1': '男', '2': '女' }
  return map[gender] || '未知'
}

function formatRegisterSource(source) {
  const map = { '1': '小程序', '2': 'H5', '3': '后台', '4': '导入' }
  return map[source] || '-'
}
</script>

<style scoped>
.member-container {
  padding: 20px;
}

.search-form {
  margin-bottom: 20px;
}

.mb8 {
  margin-bottom: 8px;
}

.pagination {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end;
}

.password-display {
  padding: 10px 0;
}

.password-content {
  display: flex;
  align-items: center;
  margin-top: 20px;
  padding: 15px;
  background: #f5f7fa;
  border-radius: 4px;
}

.password-content .label {
  color: #606266;
}

.password-content .password {
  font-size: 18px;
  font-weight: bold;
  color: #409eff;
  margin: 0 15px;
  letter-spacing: 2px;
}
</style>

4.2 会员编辑页form.vue

4.2.1 完整代码

<template>
  <div class="member-form-container">
    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="100px"
      class="member-form"
    >
      <!-- 基本信息 -->
      <el-divider content-position="left">
        <el-icon><User /></el-icon>
        <span style="margin-left: 8px">基本信息</span>
      </el-divider>

      <el-row :gutter="24">
        <el-col :span="12">
          <el-form-item label="手机号" prop="phone">
            <el-input
              v-model="form.phone"
              placeholder="请输入手机号"
              maxlength="11"
              show-word-limit
            />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="昵称" prop="nickname">
            <el-input
              v-model="form.nickname"
              placeholder="请输入昵称(不填则自动生成)"
              maxlength="50"
            />
          </el-form-item>
        </el-col>
      </el-row>

      <el-row :gutter="24">
        <el-col :span="12">
          <el-form-item label="性别" prop="gender">
            <el-radio-group v-model="form.gender">
              <el-radio value="0">未知</el-radio>
              <el-radio value="1">男</el-radio>
              <el-radio value="2">女</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="date"
              placeholder="请选择出生日期"
              value-format="YYYY-MM-DD"
              style="width: 100%"
            />
          </el-form-item>
        </el-col>
      </el-row>

      <el-row :gutter="24">
        <el-col :span="12">
          <el-form-item label="身份类型" prop="identityType">
            <el-select
              v-model="form.identityType"
              placeholder="请选择身份类型"
              style="width: 100%"
              @change="handleIdentityTypeChange"
            >
              <el-option label="家长" value="1" />
              <el-option label="教师" value="2" />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="状态" prop="status">
            <el-switch
              v-model="form.status"
              active-value="0"
              inactive-value="1"
              active-text="正常"
              inactive-text="停用"
            />
          </el-form-item>
        </el-col>
      </el-row>

      <!-- 教师信息(仅教师身份显示) -->
      <template v-if="form.identityType === '2'">
        <el-divider content-position="left">
          <el-icon><School /></el-icon>
          <span style="margin-left: 8px">教师信息</span>
        </el-divider>

        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="所属区域" prop="regionIds">
              <el-cascader
                v-model="form.regionIds"
                :options="regionTree"
                :props="regionCascaderProps"
                placeholder="请选择区域"
                style="width: 100%"
                @change="handleRegionChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="所属学校" prop="schoolId">
              <el-select
                v-model="form.schoolId"
                placeholder="请选择学校"
                style="width: 100%"
                :loading="schoolLoading"
                @change="handleSchoolChange"
              >
                <el-option
                  v-for="item in schoolList"
                  :key="item.schoolId"
                  :label="item.schoolName"
                  :value="item.schoolId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>

        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="所属年级" prop="schoolGradeId">
              <el-select
                v-model="form.schoolGradeId"
                placeholder="请选择年级"
                style="width: 100%"
                :loading="gradeLoading"
                @change="handleGradeChange"
              >
                <el-option
                  v-for="item in gradeList"
                  :key="item.id"
                  :label="item.gradeName"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="所属班级" prop="schoolClassId">
              <el-select
                v-model="form.schoolClassId"
                placeholder="请选择班级"
                style="width: 100%"
                :loading="classLoading"
              >
                <el-option
                  v-for="item in classList"
                  :key="item.id"
                  :label="item.className"
                  :value="item.id"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
      </template>

      <!-- 绑定学生 -->
      <el-divider content-position="left">
        <el-icon><UserFilled /></el-icon>
        <span style="margin-left: 8px">绑定学生</span>
      </el-divider>

      <div class="student-section">
        <el-button type="primary" icon="Plus" @click="openStudentDialog">
          绑定学生
        </el-button>

        <el-table
          v-if="form.students && form.students.length > 0"
          :data="form.students"
          border
          style="margin-top: 16px"
        >
          <el-table-column label="姓名" prop="studentName" width="120" />
          <el-table-column label="学号" prop="studentNo" width="150" />
          <el-table-column label="学校" prop="schoolName" min-width="150" />
          <el-table-column label="年级" prop="gradeName" width="100" />
          <el-table-column label="班级" prop="className" width="100" />
          <el-table-column label="操作" width="100" align="center">
            <template #default="scope">
              <el-popconfirm
                title="确认解绑该学生吗?"
                @confirm="handleUnbind(scope.row)"
              >
                <template #reference>
                  <el-button link type="danger">解绑</el-button>
                </template>
              </el-popconfirm>
            </template>
          </el-table-column>
        </el-table>

        <el-empty
          v-else
          description="暂未绑定学生"
          :image-size="80"
        />
      </div>

      <!-- 操作按钮 -->
      <el-form-item class="form-actions">
        <el-button type="primary" @click="submitForm" :loading="submitLoading">
          保存
        </el-button>
        <el-button @click="cancel">取消</el-button>
      </el-form-item>
    </el-form>

    <!-- 学生选择弹窗 -->
    <StudentBindDialog
      v-model:visible="studentDialogVisible"
      :identity-type="form.identityType"
      :school-id="form.schoolId"
      :exclude-ids="form.students?.map(s => s.studentId) || []"
      @confirm="handleStudentBind"
    />
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, School, UserFilled } from '@element-plus/icons-vue'
import {
  getMember,
  addMember,
  updateMember,
  getRegionTree,
  getSchoolListByRegion,
  getGradeListBySchool,
  getClassListByGrade,
  bindStudent,
  unbindStudent
} from '@/api/member'
import StudentBindDialog from './components/StudentBindDialog.vue'

const route = useRoute()
const router = useRouter()

// 是否编辑模式
const isEdit = computed(() => !!route.params.id)
const memberId = computed(() => route.params.id)

// 表单数据
const form = reactive({
  memberId: null,
  phone: '',
  nickname: '',
  gender: '0',
  birthday: '',
  identityType: '1',
  status: '0',
  regionIds: [],
  regionId: null,
  schoolId: null,
  schoolGradeId: null,
  schoolClassId: null,
  students: []
})

// 表单校验规则
const rules = reactive({
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
  ],
  identityType: [
    { required: true, message: '请选择身份类型', trigger: 'change' }
  ],
  regionIds: [
    { required: true, message: '请选择所属区域', trigger: 'change', type: 'array' }
  ],
  schoolId: [
    { required: true, message: '请选择所属学校', trigger: 'change' }
  ],
  schoolGradeId: [
    { required: true, message: '请选择所属年级', trigger: 'change' }
  ],
  schoolClassId: [
    { required: true, message: '请选择所属班级', trigger: 'change' }
  ]
})

// 下拉数据
const regionTree = ref([])
const schoolList = ref([])
const gradeList = ref([])
const classList = ref([])

// 加载状态
const schoolLoading = ref(false)
const gradeLoading = ref(false)
const classLoading = ref(false)
const submitLoading = ref(false)

// 表单引用
const formRef = ref(null)

// 学生选择弹窗
const studentDialogVisible = ref(false)

// 区域级联配置
const regionCascaderProps = {
  value: 'id',
  label: 'label',
  children: 'children',
  checkStrictly: false
}

// 页面初始化
onMounted(async () => {
  // 加载区域树
  await loadRegionTree()
  
  // 编辑模式加载会员数据
  if (isEdit.value) {
    await loadMemberData()
  }
})

// 加载区域树
async function loadRegionTree() {
  const res = await getRegionTree()
  if (res.code === 200) {
    regionTree.value = res.data
  }
}

// 加载会员数据
async function loadMemberData() {
  const res = await getMember(memberId.value)
  if (res.code === 200) {
    const data = res.data
    Object.assign(form, {
      memberId: data.memberId,
      phone: data.phoneFull || data.phone,
      nickname: data.nickname,
      gender: data.gender,
      birthday: data.birthday,
      identityType: data.identityType,
      status: data.status,
      regionId: data.regionId,
      schoolId: data.schoolId,
      schoolGradeId: data.schoolGradeId,
      schoolClassId: data.schoolClassId,
      students: data.students || []
    })
    
    // 教师需要加载关联数据
    if (data.identityType === '2' && data.regionId) {
      // 构建区域ID数组需要完整路径
      // 实际项目中可能需要根据接口返回的ancestors构建
      form.regionIds = [data.regionId]
      
      // 加载学校列表
      await loadSchoolList(data.regionId)
      // 加载年级列表
      if (data.schoolId) {
        await loadGradeList(data.schoolId)
      }
      // 加载班级列表
      if (data.schoolGradeId) {
        await loadClassList(data.schoolGradeId)
      }
    }
  }
}

// 身份类型变化
function handleIdentityTypeChange(val) {
  if (val === '1') {
    // 家长:清空教师相关字段
    form.regionIds = []
    form.regionId = null
    form.schoolId = null
    form.schoolGradeId = null
    form.schoolClassId = null
    schoolList.value = []
    gradeList.value = []
    classList.value = []
  }
}

// 区域变化
async function handleRegionChange(val) {
  if (val && val.length > 0) {
    form.regionId = val[val.length - 1]
    // 清空下级
    form.schoolId = null
    form.schoolGradeId = null
    form.schoolClassId = null
    gradeList.value = []
    classList.value = []
    // 加载学校
    await loadSchoolList(form.regionId)
  }
}

// 学校变化
async function handleSchoolChange(val) {
  form.schoolGradeId = null
  form.schoolClassId = null
  classList.value = []
  if (val) {
    await loadGradeList(val)
  } else {
    gradeList.value = []
  }
}

// 年级变化
async function handleGradeChange(val) {
  form.schoolClassId = null
  if (val) {
    await loadClassList(val)
  } else {
    classList.value = []
  }
}

// 加载学校列表
async function loadSchoolList(regionId) {
  schoolLoading.value = true
  try {
    const res = await getSchoolListByRegion(regionId)
    if (res.code === 200) {
      schoolList.value = res.data || []
    }
  } finally {
    schoolLoading.value = false
  }
}

// 加载年级列表
async function loadGradeList(schoolId) {
  gradeLoading.value = true
  try {
    const res = await getGradeListBySchool(schoolId)
    if (res.code === 200) {
      gradeList.value = res.data || []
    }
  } finally {
    gradeLoading.value = false
  }
}

// 加载班级列表
async function loadClassList(schoolGradeId) {
  classLoading.value = true
  try {
    const res = await getClassListByGrade(schoolGradeId)
    if (res.code === 200) {
      classList.value = res.data || []
    }
  } finally {
    classLoading.value = false
  }
}

// 打开学生选择弹窗
function openStudentDialog() {
  studentDialogVisible.value = true
}

// 绑定学生
async function handleStudentBind(students) {
  for (const student of students) {
    // 检查是否已绑定
    if (form.students.some(s => s.studentId === student.studentId)) {
      continue
    }
    
    // 编辑模式下调用接口绑定
    if (isEdit.value) {
      try {
        await bindStudent(form.memberId, student.studentId)
      } catch (error) {
        ElMessage.error(`绑定学生${student.studentName}失败`)
        continue
      }
    }
    
    // 添加到列表
    form.students.push(student)
  }
  ElMessage.success('绑定成功')
}

// 解绑学生
async function handleUnbind(row) {
  // 编辑模式下调用接口解绑
  if (isEdit.value) {
    try {
      await unbindStudent(form.memberId, row.studentId)
    } catch (error) {
      ElMessage.error('解绑失败')
      return
    }
  }
  
  // 从列表移除
  const index = form.students.findIndex(s => s.studentId === row.studentId)
  if (index > -1) {
    form.students.splice(index, 1)
  }
  ElMessage.success('解绑成功')
}

// 提交表单
async function submitForm() {
  try {
    await formRef.value?.validate()
    
    submitLoading.value = true
    
    const submitData = {
      ...form,
      // 新增时带上要绑定的学生ID列表
      studentIds: isEdit.value ? undefined : form.students.map(s => s.studentId)
    }
    
    const res = isEdit.value
      ? await updateMember(submitData)
      : await addMember(submitData)
    
    if (res.code === 200) {
      ElMessage.success(isEdit.value ? '修改成功' : '新增成功')
      router.push('/member/list')
    } else {
      ElMessage.error(res.msg || '操作失败')
    }
  } catch (error) {
    console.error('表单验证失败:', error)
  } finally {
    submitLoading.value = false
  }
}

// 取消
function cancel() {
  router.back()
}
</script>

<style scoped>
.member-form-container {
  padding: 20px;
  background: #fff;
  border-radius: 4px;
}

.member-form {
  max-width: 900px;
}

.student-section {
  padding: 10px 0;
}

.form-actions {
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #eee;
}
</style>

4.3 学生绑定弹窗StudentBindDialog.vue

<template>
  <el-dialog
    v-model="dialogVisible"
    title="选择学生"
    width="800px"
    :close-on-click-modal="false"
    @open="handleOpen"
  >
    <!-- 搜索区域 -->
    <el-form :model="queryParams" :inline="true" class="search-form">
      <el-form-item label="姓名">
        <el-input
          v-model="queryParams.studentName"
          placeholder="请输入学生姓名"
          clearable
          style="width: 150px"
        />
      </el-form-item>
      <el-form-item label="学号">
        <el-input
          v-model="queryParams.studentNo"
          placeholder="请输入学号"
          clearable
          style="width: 150px"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 提示信息 -->
    <el-alert
      v-if="identityType === '2'"
      type="info"
      :closable="false"
      show-icon
      style="margin-bottom: 16px"
    >
      <template #title>
        教师身份只能绑定本校学生
      </template>
    </el-alert>

    <!-- 学生列表 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="studentList"
      border
      @selection-change="handleSelectionChange"
    >
      <el-table-column type="selection" width="55" />
      <el-table-column label="姓名" prop="studentName" width="100" />
      <el-table-column label="学号" prop="studentNo" width="140" />
      <el-table-column label="学校" prop="schoolName" min-width="150" show-overflow-tooltip />
      <el-table-column label="年级" prop="gradeName" width="100" />
      <el-table-column label="班级" prop="className" width="80" />
    </el-table>

    <!-- 分页 -->
    <el-pagination
      class="pagination"
      v-model:current-page="queryParams.pageNum"
      v-model:page-size="queryParams.pageSize"
      :total="total"
      :page-sizes="[10, 20, 50]"
      layout="total, sizes, prev, pager, next"
      @size-change="getList"
      @current-change="getList"
    />

    <template #footer>
      <span class="selected-info">已选择 {{ selectedStudents.length }} 名学生</span>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleConfirm" :disabled="selectedStudents.length === 0">
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { getBindableStudents } from '@/api/member'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  identityType: {
    type: String,
    default: '1'
  },
  schoolId: {
    type: [Number, String],
    default: null
  },
  excludeIds: {
    type: Array,
    default: () => []
  }
})

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

// 弹窗显示状态
const dialogVisible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val)
})

// 查询参数
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  studentName: '',
  studentNo: ''
})

// 数据
const studentList = ref([])
const total = ref(0)
const loading = ref(false)
const selectedStudents = ref([])

// 表格引用
const tableRef = ref(null)

// 弹窗打开时
function handleOpen() {
  selectedStudents.value = []
  queryParams.studentName = ''
  queryParams.studentNo = ''
  queryParams.pageNum = 1
  getList()
}

// 获取学生列表
async function getList() {
  loading.value = true
  try {
    const params = {
      ...queryParams,
      excludeIds: props.excludeIds.join(','),
      // 教师身份限制本校学生
      schoolId: props.identityType === '2' ? props.schoolId : undefined
    }
    
    const res = await getBindableStudents(params)
    if (res.code === 200) {
      studentList.value = res.rows
      total.value = res.total
    }
  } finally {
    loading.value = false
  }
}

// 搜索
function handleSearch() {
  queryParams.pageNum = 1
  getList()
}

// 重置
function resetSearch() {
  queryParams.studentName = ''
  queryParams.studentNo = ''
  handleSearch()
}

// 选择变化
function handleSelectionChange(selection) {
  selectedStudents.value = selection
}

// 确认选择
function handleConfirm() {
  emit('confirm', [...selectedStudents.value])
  dialogVisible.value = false
}
</script>

<style scoped>
.search-form {
  margin-bottom: 16px;
}

.pagination {
  margin-top: 16px;
  display: flex;
  justify-content: flex-end;
}

.selected-info {
  float: left;
  color: #909399;
  line-height: 32px;
}
</style>

5. 工具函数

5.1 会员相关工具utils/member.js

/**
 * 会员管理相关工具函数
 * @author pangu
 */

/**
 * 手机号脱敏
 * @param {string} phone 手机号
 * @returns {string} 脱敏后的手机号
 */
export function maskPhone(phone) {
  if (!phone || phone.length !== 11) return phone
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}

/**
 * 性别格式化
 * @param {string} gender 性别代码
 * @returns {string} 性别文字
 */
export function formatGender(gender) {
  const map = { '0': '未知', '1': '男', '2': '女' }
  return map[gender] || '未知'
}

/**
 * 身份类型格式化
 * @param {string} type 身份类型代码
 * @returns {string} 身份类型文字
 */
export function formatIdentityType(type) {
  return type === '1' ? '家长' : '教师'
}

/**
 * 注册来源格式化
 * @param {string} source 注册来源代码
 * @returns {string} 注册来源文字
 */
export function formatRegisterSource(source) {
  const map = {
    '1': '小程序',
    '2': 'H5',
    '3': '后台新增',
    '4': '批量导入'
  }
  return map[source] || '-'
}

/**
 * 复制文本到剪贴板
 * @param {string} text 要复制的文本
 * @returns {Promise<boolean>} 是否成功
 */
export async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text)
    return true
  } catch (error) {
    console.error('复制失败:', error)
    return false
  }
}

/**
 * 生成默认昵称
 * @param {string} phone 手机号
 * @returns {string} 默认昵称
 */
export function generateNickname(phone) {
  if (!phone || phone.length < 4) return '用户'
  return `用户${phone.slice(-4)}`
}

/**
 * 验证手机号格式
 * @param {string} phone 手机号
 * @returns {boolean} 是否有效
 */
export function validatePhone(phone) {
  return /^1[3-9]\d{9}$/.test(phone)
}

6. Mock数据更新

如果需要在开发阶段使用Mock数据更新 mock/member.js 以匹配新的接口格式。


7. 注意事项

7.1 开发注意事项

  1. 手机号脱敏:列表页显示脱敏手机号,编辑页显示完整手机号
  2. 身份类型切换:切换身份类型时需清空或显示相关字段
  3. 级联选择器:区域-学校-年级-班级需要逐级加载
  4. 学生绑定规则:教师只能绑定本校学生,家长可绑定任意学生
  5. 密码复制:使用 Clipboard API需要处理兼容性

7.2 性能优化

  1. 列表分页:使用后端分页,避免大数据量渲染
  2. 区域树缓存:区域树数据可缓存到 Pinia Store
  3. 防抖处理:搜索输入可添加防抖
  4. 懒加载:级联数据使用懒加载方式

7.3 用户体验

  1. 加载状态:所有异步操作显示 loading 状态
  2. 操作确认:删除、解绑等操作需要二次确认
  3. 表单验证:即时验证,提交前完整校验
  4. 错误提示:友好的错误提示信息

文档结束