新增教练管理,优化预约人数计数方式

This commit is contained in:
2026-07-19 19:02:10 +08:00
parent 97a5eff678
commit df0e68469b
26 changed files with 1537 additions and 264 deletions
@@ -0,0 +1,356 @@
<template>
<div class="coach-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索教练用户名或昵称"
clearable
style="width: 300px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
</div>
<el-button type="primary" @click="handleAdd">
新增教练
</el-button>
</div>
</template>
<el-table v-loading="loading" :data="filteredData" style="width: 100%">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="phone" label="手机号" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="row.status === 1 ? 'success' : 'danger'"
effect="dark"
>
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
编辑
</el-button>
<el-button type="info" link @click="handleViewCourses(row)">
查看团课
</el-button>
<el-button
v-if="row.status === 1"
type="danger"
link
@click="handleDisable(row)"
>
禁用
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑教练弹窗 -->
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="500px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="80px"
>
<el-form-item label="用户名" prop="username" required>
<el-input v-model="formState.username" :disabled="!!formState.id" />
</el-form-item>
<el-form-item v-if="!formState.id" label="密码" prop="password" required>
<el-input v-model="formState.password" type="password" show-password />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="formState.nickname" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="formState.email" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="formState.phone" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleModalCancel">取消</el-button>
<el-button type="primary" @click="handleModalOk">确定</el-button>
</template>
</el-dialog>
<!-- 查看团课弹窗 -->
<el-dialog
v-model="coursesDialogVisible"
:title="'教练团课 - ' + selectedCoachName"
width="900px"
>
<el-table v-loading="coursesLoading" :data="coachCourses" style="width: 100%" max-height="400">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="courseName" label="课程名称" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getCourseStatusTag(row.status)" effect="dark">
{{ getCourseStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="开课时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.startTime) }}
</template>
</el-table-column>
<el-table-column label="结课时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column label="预约人数" width="100">
<template #default="{ row }">
{{ row.currentMembers }} / {{ row.maxMembers }}
</template>
</el-table-column>
<el-table-column prop="location" label="上课地点" />
</el-table>
<template #footer>
<el-button @click="coursesDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { coachApi, type Coach, type CoachCreateRequest, type CoachUpdateRequest, type GroupCourse } from '@/api/coach.api'
import { handleApiError } from '@/utils/errorHandler'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<Coach[]>([])
const searchKeyword = ref('')
const filteredData = computed(() => {
if (!searchKeyword.value) return dataSource.value
const keyword = searchKeyword.value.toLowerCase()
return dataSource.value.filter(
(c) =>
c.username.toLowerCase().includes(keyword) ||
c.nickname.toLowerCase().includes(keyword) ||
c.email.toLowerCase().includes(keyword)
)
})
// 新增/编辑表单
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const formState = reactive<CoachCreateRequest & { id?: string }>({
username: '',
password: '',
nickname: '',
email: '',
phone: '',
})
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 50, message: '用户名长度在 3 到 50 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '用户名只能包含字母、数字、下划线和横线', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 20, message: '密码长度在 8 到 20 个字符', trigger: 'blur' },
{ pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, message: '密码必须包含大小写字母和数字', trigger: 'blur' },
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' },
],
}
// 团课查看
const coursesDialogVisible = ref(false)
const coursesLoading = ref(false)
const coachCourses = ref<GroupCourse[]>([])
const selectedCoachName = ref('')
// 团课状态映射
const courseStatusMap: Record<number, { text: string; tag: string }> = {
0: { text: '正常', tag: 'success' },
1: { text: '已取消', tag: 'info' },
2: { text: '已结束', tag: 'warning' },
3: { text: '进行中', tag: 'danger' },
4: { text: '超时', tag: 'info' },
}
function getCourseStatusText(status: number) {
return courseStatusMap[status]?.text || '未知'
}
function getCourseStatusTag(status: number) {
return courseStatusMap[status]?.tag || 'info'
}
const fetchData = async () => {
loading.value = true
try {
dataSource.value = await coachApi.getAll()
} catch (error) {
handleApiError(error)
} finally {
loading.value = false
}
}
const handleSearch = () => {
// 前端过滤,无需重新请求
}
const handleAdd = () => {
modalTitle.value = '新增教练'
Object.assign(formState, {
id: undefined,
username: '',
password: '',
nickname: '',
email: '',
phone: '',
})
modalVisible.value = true
}
const handleEdit = (row: Coach) => {
modalTitle.value = '编辑教练'
Object.assign(formState, {
id: row.id,
username: row.username,
nickname: row.nickname,
email: row.email,
phone: row.phone,
})
modalVisible.value = true
}
const handleDisable = async (row: Coach) => {
try {
await ElMessageBox.confirm(
`确定要禁用教练「${row.nickname || row.username}」吗?\n\n禁用后:\n1. 该教练正在进行中的团课不会被影响\n2. 该教练非进行中的团课将被自动取消\n3. 该教练将无法再被关联到团课`,
'禁用教练确认',
{
confirmButtonText: '确定禁用',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: false,
}
)
await coachApi.disable(row.id)
ElMessage.success('教练已禁用')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (formState.id) {
const updateData: CoachUpdateRequest = {
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
}
await coachApi.update(formState.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: CoachCreateRequest = {
username: formState.username,
password: formState.password,
nickname: formState.nickname,
email: formState.email,
phone: formState.phone,
}
await coachApi.create(createData)
ElMessage.success('创建成功')
}
modalVisible.value = false
fetchData()
} catch (error) {
if (error !== 'cancel') {
handleApiError(error)
}
}
}
const handleModalCancel = () => {
modalVisible.value = false
}
const handleViewCourses = async (row: Coach) => {
selectedCoachName.value = row.nickname || row.username
coursesDialogVisible.value = true
coursesLoading.value = true
try {
coachCourses.value = await coachApi.getCourses(row.id)
} catch (error) {
handleApiError(error)
} finally {
coursesLoading.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="css">
.coach-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
</style>