feat: 完善会员和课程管理模块

This commit is contained in:
hyql 2026-07-09 16:31:26 +08:00
commit 7c1b021178
8 changed files with 580 additions and 6 deletions

View file

@ -47,6 +47,12 @@ const activeMenu = computed(() => route.path)
</el-icon> </el-icon>
<span>会员列表</span> <span>会员列表</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/addmember">
<el-icon>
<Link />
</el-icon>
<span>会员新增</span>
</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="course"> <el-sub-menu index="course">
@ -62,6 +68,12 @@ const activeMenu = computed(() => route.path)
</el-icon> </el-icon>
<span>课程列表</span> <span>课程列表</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/addcourse">
<el-icon>
<Link />
</el-icon>
<span>课程新增</span>
</el-menu-item>
</el-sub-menu> </el-sub-menu>
</el-menu> </el-menu>
</el-aside> </el-aside>

46
src/mock/course.js Normal file
View file

@ -0,0 +1,46 @@
const list = [
{"id":1,"name":"瑜伽入门","coachName":"铁娃","price":199,"duration":"60分钟","status":"进行中","remark":"适合零基础学员"},
{"id":2,"name":"力量训练","coachName":"小明","price":299,"duration":"45分钟","status":"进行中","remark":"增肌塑形专用"},
{"id":3,"name":"有氧舞蹈","coachName":"李娜","price":159,"duration":"50分钟","status":"进行中","remark":"燃脂效果好"},
{"id":4,"name":"搏击操","coachName":"王强","price":259,"duration":"40分钟","status":"进行中","remark":"释放压力"},
{"id":5,"name":"普拉提","coachName":"刘芳","price":229,"duration":"55分钟","status":"进行中","remark":"改善体态"},
{"id":6,"name":"动感单车","coachName":"陈刚","price":179,"duration":"45分钟","status":"已结束","remark":"高强度有氧"},
{"id":7,"name":"游泳训练","coachName":"赵敏","price":349,"duration":"60分钟","status":"进行中","remark":"全身运动"},
{"id":8,"name":"拳击入门","coachName":"周涛","price":289,"duration":"50分钟","status":"进行中","remark":"防身健身两不误"},
{"id":9,"name":"形体塑造","coachName":"吴雪","price":199,"duration":"45分钟","status":"进行中","remark":"塑造完美身材"},
{"id":10,"name":"康复训练","coachName":"孙磊","price":399,"duration":"60分钟","status":"进行中","remark":"运动损伤恢复"},
{"id":11,"name":"舞蹈培训","coachName":"朱婷","price":249,"duration":"50分钟","status":"已结束","remark":"多种舞种"},
{"id":12,"name":"攀岩课程","coachName":"林浩","price":329,"duration":"90分钟","status":"进行中","remark":"挑战自我"},
{"id":13,"name":"空手道","coachName":"杨丽","price":279,"duration":"55分钟","status":"进行中","remark":"防身技能"},
{"id":14,"name":"肚皮舞","coachName":"黄勇","price":189,"duration":"45分钟","status":"进行中","remark":"女性专属"},
{"id":15,"name":"CrossFit","coachName":"徐静","price":359,"duration":"60分钟","status":"进行中","remark":"综合训练"}
]
export const getByPage = (query) => {
const { name, status, page, limit } = query
const result = list.filter(item => {
if (name && item.name.indexOf(name) === -1) return false
if (status && item.status !== status) return false
return true
})
const start = (page - 1) * limit
return { total: result.length, data: result.slice(start, start + limit) }
}
// 删除课程
export const remove = (id) => {
const index = list.findIndex(item => item.id === id)
if (index > -1) {
list.splice(index, 1)
}
}
// 新增课程
export const add = (course) => {
const newCourse = {
id: list.length === 0 ? 1 : list[list.length - 1].id + 1,
...course,
status: "进行中"
}
list.push(newCourse)
}

57
src/mock/member.js Normal file
View file

@ -0,0 +1,57 @@
const list = [
{"id":1,"name":"张三","sex":1,"avatar":"","phone":"13800138001","remark":"年卡会员,喜欢瑜伽","expireDate":"2025-12-31"},
{"id":2,"name":"李四","sex":2,"avatar":"","phone":"13800138002","remark":"季卡会员,减脂塑形","expireDate":"2025-09-15"},
{"id":3,"name":"王五","sex":1,"avatar":"","phone":"13800138003","remark":"月卡会员,增肌训练","expireDate":"2025-08-01"},
{"id":4,"name":"赵六","sex":2,"avatar":"","phone":"13800138004","remark":"年卡会员,产后恢复","expireDate":null},
{"id":5,"name":"钱七","sex":1,"avatar":"","phone":"13800138005","remark":"次卡会员,游泳爱好者","expireDate":"2025-07-20"},
{"id":6,"name":"孙八","sex":2,"avatar":"","phone":"13800138006","remark":"年卡会员,搏击训练","expireDate":null},
{"id":7,"name":"周九","sex":1,"avatar":"","phone":"13800138007","remark":"季卡会员,普拉提","expireDate":"2025-10-10"},
{"id":8,"name":"吴十","sex":2,"avatar":"","phone":"13800138008","remark":"月卡会员,有氧舞蹈","expireDate":"2025-07-30"},
{"id":9,"name":"郑十一","sex":1,"avatar":"","phone":"13800138009","remark":"年卡会员,自由重量","expireDate":"2026-01-15"},
{"id":10,"name":"冯十二","sex":2,"avatar":"","phone":"13800138010","remark":"次卡会员,动感单车","expireDate":"2025-08-25"},
{"id":11,"name":"陈十三","sex":1,"avatar":"","phone":"13800138011","remark":"年卡会员,拳击训练","expireDate":null},
{"id":12,"name":"楚十四","sex":2,"avatar":"","phone":"13800138012","remark":"季卡会员,形体塑造","expireDate":"2025-11-30"},
{"id":13,"name":"魏十五","sex":1,"avatar":"","phone":"13800138013","remark":"月卡会员,康复训练","expireDate":"2025-07-25"},
{"id":14,"name":"蒋十六","sex":2,"avatar":"","phone":"13800138014","remark":"年卡会员,舞蹈培训","expireDate":"2026-02-28"},
{"id":15,"name":"沈十七","sex":1,"avatar":"","phone":"13800138015","remark":"次卡会员,攀岩爱好者","expireDate":"2025-09-05"},
{"id":16,"name":"韩十八","sex":2,"avatar":"","phone":"13800138016","remark":"季卡会员,有氧操","expireDate":"2025-12-01"},
{"id":17,"name":"杨十九","sex":1,"avatar":"","phone":"13800138017","remark":"年卡会员,力量训练","expireDate":null},
{"id":18,"name":"朱二十","sex":2,"avatar":"","phone":"13800138018","remark":"月卡会员,纤体瑜伽","expireDate":"2025-08-10"},
{"id":19,"name":"秦廿一","sex":1,"avatar":"","phone":"13800138019","remark":"年卡会员,CrossFit","expireDate":"2026-03-20"},
{"id":20,"name":"许廿二","sex":2,"avatar":"","phone":"13800138020","remark":"季卡会员,空中瑜伽","expireDate":"2025-10-25"}
]
export const getByPage = (query) => {
const { sex, name, page, limit, status } = query
const result = list.filter(item => {
if (sex && item.sex !== sex) return false
if (name && item.name.indexOf(name) === -1) return false
if (status == 2 && item.expireDate !== null) return false
if (status == 3 && item.expireDate === null) return false
return true
})
const start = (page - 1) * limit
return { total: result.length, data: result.slice(start, start + limit) }
}
// 注销
export const cancel = (id) => {
const target = list.find(item => item.id === id)
target.expireDate = new Date().toLocaleDateString()
}
// 续费
export const renew = (id) => {
const target = list.find(item => item.id === id)
target.expireDate = null
}
// 新增会员
export const add = (member) => {
const newMember = {
id: list.length === 0 ? 1 : list[list.length - 1].id + 1,
...member,
expireDate: null
}
list.push(newMember)
}

View file

@ -23,6 +23,14 @@ const router = createRouter({
path: '/addcoach', path: '/addcoach',
component: ()=>import('../views/AddCoachView.vue'), component: ()=>import('../views/AddCoachView.vue'),
}, },
{
path: '/addmember',
component: ()=>import('../views/AddMemberView.vue'),
},
{
path: '/addcourse',
component: ()=>import('../views/AddCourseView.vue'),
},
], ],
}) })

View file

@ -0,0 +1,71 @@
<script setup>
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { add } from '../mock/course.js'
const router = useRouter()
const formRef = ref(null)
const course = reactive({
name: "",
coachName: "",
price: null,
duration: "",
remark: ""
})
const rules = {
name: [
{ required: true, message: "课程名称不能为空", trigger: "blur" },
{ min: 2, max: 20, message: "课程名称长度 2-20", trigger: "blur" }
],
coachName: [
{ required: true, message: "授课教练不能为空", trigger: "blur" }
],
price: [
{ required: true, message: "价格不能为空", trigger: "blur" }
],
duration: [
{ required: true, message: "课程时长不能为空", trigger: "blur" }
]
}
const addHandler = async () => {
await formRef.value.validate()
add(course)
ElMessage({ type: "success", message: `课程 "${course.name}" 添加成功` })
router.replace("/course")
}
</script>
<template>
<el-form label-width="100px" label-position="left" ref="formRef" :model="course" :rules="rules">
<h2 class="title">——新增课程——</h2>
<el-form-item label="课程名称" prop="name">
<el-input placeholder="请输入课程名称" v-model.trim="course.name"></el-input>
</el-form-item>
<el-form-item label="授课教练" prop="coachName">
<el-input placeholder="请输入教练姓名" v-model.trim="course.coachName"></el-input>
</el-form-item>
<el-form-item label="价格(元)" prop="price">
<el-input-number v-model="course.price" :min="0" :step="10"></el-input-number>
</el-form-item>
<el-form-item label="课程时长" prop="duration">
<el-input placeholder="如:60分钟" v-model.trim="course.duration"></el-input>
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="course.remark" type="textarea" :rows="5" placeholder="请输入课程备注..."></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandler">确定</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.title {
text-align: center;
margin-bottom: 20px;
color: rgb(196, 86, 86);
}
</style>

107
src/views/AddMemberView.vue Normal file
View file

@ -0,0 +1,107 @@
<script setup>
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { add } from '../mock/member.js'
import { Plus } from '@element-plus/icons-vue'
const router = useRouter()
const formRef = ref(null)
const member = reactive({
name: "",
sex: 1,
avatar: "",
phone: "",
remark: ""
})
const rules = {
name: [
{ required: true, message: "会员名字不能为空", trigger: "blur" },
{ min: 2, max: 20, message: "会员名字长度 2-20", trigger: "blur" }
],
phone: [
{ required: true, message: "手机号不能为空", trigger: "blur" },
{ pattern: /^1[3-9]\d{9}$/, message: "手机号格式不正确", trigger: "blur" }
]
}
const addHandler = async () => {
await formRef.value.validate()
add(member)
ElMessage({ type: "success", message: `会员 "${member.name}" 添加成功` })
router.replace("/member")
}
const uploadChangeHandler = (file, fileList) => {
if (fileList.length > 1) { fileList.shift() }
const reader = new FileReader()
reader.onload = e => {
member.avatar = e.target.result
}
reader.readAsDataURL(file.raw)
}
</script>
<template>
<el-form label-width="100px" label-position="left" ref="formRef" :model="member" :rules="rules">
<h2 class="title">——新增会员——</h2>
<el-form-item label="会员名字" prop="name">
<el-input placeholder="请输入.." v-model.trim="member.name"></el-input>
</el-form-item>
<el-form-item label="性别:">
<el-radio-group v-model="member.sex">
<el-radio :value="1">男</el-radio>
<el-radio :value="2">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input placeholder="请输入手机号.." v-model.trim="member.phone"></el-input>
</el-form-item>
<el-form-item label="头像">
<el-upload class="avatar-uploader" action="" :auto-upload="false" list-type="picture-card"
:on-change="uploadChangeHandler">
<el-icon class="avatar-uploader-icon">
<Plus />
</el-icon>
<template #tip>
<div style="color: red;">提示:上传 300*300 的 png/jp(e)g 图片</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="member.remark" type="textarea" :rows="5" placeholder="请输入会员备注..."></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="addHandler">确定</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.title {
text-align: center;
margin-bottom: 20px;
color: rgb(196, 86, 86);
}
.avatar-uploader .el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.avatar-uploader .el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
}
</style>

View file

@ -1,9 +1,127 @@
<template> <template>
<div class="page-text">这是课程列表页面</div> <el-container>
<el-header>
<el-form :inline="true" @submit.prevent="submitHandler">
<el-form-item label="课程名:">
<el-input placeholder="请输入课程名" clearable v-model="queryModel.name" />
</el-form-item>
<el-form-item label="课程状态">
<el-select v-model="queryModel.status" style="width: 120px;" @change="submitHandler">
<el-option label="全部" :value="''"></el-option>
<el-option label="进行中" value="进行中"></el-option>
<el-option label="已结束" value="已结束"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="warning" native-type='submit' :icon="Search">查询</el-button>
<el-button type="primary" :icon="Plus" @click="$router.push('/addcourse')">新增</el-button>
</el-form-item>
</el-form>
</el-header>
<el-main>
<el-table border :data="courseList">
<el-table-column type="index" label="序号" width="60" align="center" :index="indexMethod"></el-table-column>
<el-table-column prop="name" label="课程名称" width="120" align="center"></el-table-column>
<el-table-column prop="coachName" label="授课教练" width="100" align="center"></el-table-column>
<el-table-column prop="price" label="价格(元)" width="100" align="center"></el-table-column>
<el-table-column prop="duration" label="时长" width="80" align="center"></el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">
<el-tag type="success" v-if="scope.row.status === '进行中'">进行中</el-tag>
<el-tag type="info" v-else>已结束</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" width="200">
<template #default="scope">
<el-popover placement="right-start" :title="scope.row.name" :width="200" trigger="hover"
:content="scope.row.remark">
<template #reference>
<span class="remark">{{ scope.row.remark }}</span>
</template>
</el-popover>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="100">
<template #default="scope">
<el-button :icon="Delete" type="danger" size="small" @click="deleteHandler(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-main>
<el-footer>
<el-pagination background :total="total" layout="prev, pager, next, jumper,->,total, sizes"
:page-sizes="[10, 20, 30, 40]" v-model:current-page="pageInfo.page" v-model:page-size="pageInfo.limit"
@size-change="query" @current-change="query" />
</el-footer>
</el-container>
</template> </template>
<script lang="ts" setup>
import { getByPage, remove } from '@/mock/course';
import { Delete, Plus, Search } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { reactive, ref, onMounted, shallowRef } from 'vue';
const queryModel = reactive({
name: "",
status: ""
})
const pageInfo = reactive({
page: 1,
limit: 10
})
const total = ref(0)
const courseList = shallowRef<any[]>([])
const query = () => {
const response = getByPage({ ...queryModel, ...pageInfo })
total.value = response.total
courseList.value = response.data
}
onMounted(() => {
query()
})
const submitHandler = () => {
pageInfo.page = 1;
query()
}
const indexMethod = (index: number) => {
return (pageInfo.page - 1) * pageInfo.limit + index + 1;
}
// 删除
const deleteHandler = (course: any) => {
ElMessageBox.confirm(`你确认要删除课程 "${course.name}" 吗?`, "警告", { type: "warning" })
.then(() => {
remove(course.id)
ElMessage({ type: 'success', message: `课程 "${course.name}" 删除成功!` })
query()
})
.catch(() => { })
}
</script>
<style scoped> <style scoped>
.page-text { .remark {
line-height: 1; cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.el-header,
.el-footer {
display: flex;
align-items: center;
} }
</style> </style>

View file

@ -1,9 +1,164 @@
<template> <template>
<div class="page-text">这是会员列表页面</div> <el-container>
<el-header>
<el-form :inline="true" @submit.prevent="submitHandler">
<el-form-item label="会员名:">
<el-input placeholder="请输入姓名" clearable v-model="queryModel.name" />
</el-form-item>
<el-form-item label="会员性别">
<el-select v-model="queryModel.sex" style="width: 100px;" @change="submitHandler">
<el-option label="全部" :value="0"></el-option>
<el-option label="男" :value="1"></el-option>
<el-option label="女" :value="2"></el-option>
</el-select>
</el-form-item>
<el-form-item label="会员状态">
<el-select v-model="queryModel.status" style="width: 100px;" @change="submitHandler">
<el-option label="全部" :value="1"></el-option>
<el-option label="有效" :value="2"></el-option>
<el-option label="过期" :value="3"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="warning" native-type='submit' :icon="Search">查询</el-button>
<el-button type="primary" :icon="Plus" @click="$router.push('/addmember')">新增</el-button>
</el-form-item>
</el-form>
</el-header>
<el-main>
<el-table border :data="memberList">
<el-table-column type="index" label="序号" width="60" align="center" :index="indexMethod"></el-table-column>
<el-table-column prop="name" label="会员名字" width="100" align="center"></el-table-column>
<el-table-column prop="sex" label="性别" width="80" align="center">
<template #default="scope">
<el-tag type="primary" v-if="scope.row.sex === 1">男</el-tag>
<el-tag type="danger" v-else>女</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="130" align="center"></el-table-column>
<el-table-column label="备注" width="200">
<template #default="scope">
<el-popover placement="right-start" :title="scope.row.name" :width="200" trigger="hover"
:content="scope.row.remark">
<template #reference>
<span class="remark">{{ scope.row.remark }}</span>
</template>
</el-popover>
</template>
</el-table-column>
<el-table-column label="状态" width="200">
<template #default="scope">
<el-tag type="success" v-if="scope.row.expireDate === null">有效</el-tag>
<el-tag type="info" v-else>过期:{{ scope.row.expireDate }}</el-tag>
</template>
</el-table-column>
<el-table-column label="头像" width="80">
<template #default="scope">
<el-popover v-if="scope.row.avatar" placement="right-start" :title="scope.row.name" trigger="click">
<el-image :src="scope.row.avatar"></el-image>
<template #reference>
<el-button type="primary" link>查看</el-button>
</template>
</el-popover>
<span v-else>暂无</span>
</template>
</el-table-column>
<!-- 操作 -->
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button :icon="Delete" type="danger" size="small" v-if="scope.row.expireDate"
@click="renewHandler(scope.row)">续费</el-button>
<el-button :icon="Mug" type="warning" size="small" v-else @click="cancelHandler(scope.row)">注销</el-button>
</template>
</el-table-column>
</el-table>
</el-main>
<el-footer>
<el-pagination background :total="total" layout="prev, pager, next, jumper,->,total, sizes"
:page-sizes="[10, 20, 30, 40]" v-model:current-page="pageInfo.page" v-model:page-size="pageInfo.limit"
@size-change="query" @current-change="query" />
</el-footer>
</el-container>
</template> </template>
<script lang="ts" setup>
import { getByPage, cancel, renew } from '@/mock/member';
import { Delete, Mug, Plus, Search } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { reactive, ref, onMounted, shallowRef } from 'vue';
const queryModel = reactive({
name: "",
sex: 0,
status: 1
})
const pageInfo = reactive({
page: 1,
limit: 10
})
const total = ref(0)
const memberList = shallowRef<any[]>([])
const query = () => {
const response = getByPage({ ...queryModel, ...pageInfo })
total.value = response.total
memberList.value = response.data
}
onMounted(() => {
query()
})
const submitHandler = () => {
pageInfo.page = 1;
query()
}
const indexMethod = (index: number) => {
return (pageInfo.page - 1) * pageInfo.limit + index + 1;
}
// 注销
const cancelHandler = (member: any) => {
ElMessageBox.confirm(`你确认要注销会员 "${member.name}" 吗?`, "警告", { type: "warning" })
.then(() => {
cancel(member.id)
ElMessage({ type: 'success', message: `会员 "${member.name}" 注销成功!` })
query()
})
.catch(() => { })
}
// 续费
const renewHandler = (member: any) => {
ElMessageBox.confirm(`你确认要给会员 "${member.name}" 续费吗?`, "提示", { type: "info" })
.then(() => {
renew(member.id)
ElMessage({ type: 'success', message: `会员 "${member.name}" 续费成功!` })
query()
})
.catch(() => { })
}
</script>
<style scoped> <style scoped>
.page-text { .remark {
line-height: 1; cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.el-header,
.el-footer {
display: flex;
align-items: center;
} }
</style> </style>