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

1583 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 会员管理模块 - 前端详细设计
---
| 文档信息 | 内容 |
|---------|------|
| **文档版本** | V1.0 |
| **模块名称** | 会员管理模块 - 前端 |
| **编写团队** | pangu |
| **创建日期** | 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 目录结构
```
pangu-ui/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. **错误提示**:友好的错误提示信息
---
*文档结束*