38 KiB
38 KiB
会员管理模块 - 前端详细设计
| 文档信息 | 内容 |
|---|---|
| 文档版本 | 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 开发注意事项
- 手机号脱敏:列表页显示脱敏手机号,编辑页显示完整手机号
- 身份类型切换:切换身份类型时需清空或显示相关字段
- 级联选择器:区域-学校-年级-班级需要逐级加载
- 学生绑定规则:教师只能绑定本校学生,家长可绑定任意学生
- 密码复制:使用 Clipboard API,需要处理兼容性
7.2 性能优化
- 列表分页:使用后端分页,避免大数据量渲染
- 区域树缓存:区域树数据可缓存到 Pinia Store
- 防抖处理:搜索输入可添加防抖
- 懒加载:级联数据使用懒加载方式
7.3 用户体验
- 加载状态:所有异步操作显示 loading 状态
- 操作确认:删除、解绑等操作需要二次确认
- 表单验证:即时验证,提交前完整校验
- 错误提示:友好的错误提示信息
文档结束