1583 lines
38 KiB
Markdown
1583 lines
38 KiB
Markdown
# 会员管理模块 - 前端详细设计
|
||
|
||
---
|
||
|
||
| 文档信息 | 内容 |
|
||
|---------|------|
|
||
| **文档版本** | 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 路由定义
|
||
|
||
```javascript
|
||
// 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)
|
||
|
||
```javascript
|
||
/**
|
||
* 会员管理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 完整代码
|
||
|
||
```vue
|
||
<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 完整代码
|
||
|
||
```vue
|
||
<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)
|
||
|
||
```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)
|
||
|
||
```javascript
|
||
/**
|
||
* 会员管理相关工具函数
|
||
* @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. **错误提示**:友好的错误提示信息
|
||
|
||
---
|
||
|
||
*文档结束*
|