新增教练端,实现业务闭环:会员登录注册→查询团课→预约团课→扫码签到→教练端开课→记录实际开课时间→教练端结课→记录实际结课时间→后台查询数据

This commit is contained in:
2026-07-20 17:21:28 +08:00
parent df0e68469b
commit 4a4697c816
84 changed files with 6914 additions and 258 deletions
@@ -26,7 +26,7 @@
</div>
</template>
<el-table v-loading="loading" :data="filteredData" style="width: 100%">
<el-table v-loading="loading" :data="filteredData" style="width: 100%" :row-class-name="getRowClass">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="email" label="邮箱" />
@@ -41,12 +41,19 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="违规次数" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getViolationTag(Number(row.id))" effect="dark" round>
{{ violationCounts[Number(row.id)] ?? 0 }}
</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">
<el-table-column label="操作" width="380">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
编辑
@@ -54,6 +61,9 @@
<el-button type="info" link @click="handleViewCourses(row)">
查看团课
</el-button>
<el-button type="success" link @click="handleViewDetail(row)">
详情
</el-button>
<el-button
v-if="row.status === 1"
type="danger"
@@ -138,6 +148,88 @@
<el-button @click="coursesDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 教练详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
:title="'教练详情 - ' + (detailCoach?.nickname || detailCoach?.username || '')"
width="960px"
destroy-on-close
>
<template v-if="detailCoach">
<!-- 基本信息 -->
<el-descriptions :column="3" border size="small" style="margin-bottom: 20px">
<el-descriptions-item label="用户名">{{ detailCoach.username }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ detailCoach.nickname }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailCoach.status === 1 ? 'success' : 'danger'" effect="dark">
{{ detailCoach.status === 1 ? '正常' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detailCoach.email }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ detailCoach.phone }}</el-descriptions-item>
<el-descriptions-item label="教练ID">{{ detailCoach.id }}</el-descriptions-item>
<el-descriptions-item label="违规次数">
<el-tag :type="getViolationTag(Number(detailCoach.id))" effect="dark" round>
{{ violationCounts[Number(detailCoach.id)] ?? 0 }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailCoach.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detailCoach.updatedAt) }}</el-descriptions-item>
</el-descriptions>
<!-- 违规记录 -->
<h4 style="margin-bottom: 10px; color: #E6A23C;">违规记录</h4>
<el-table v-loading="detailViolationsLoading" :data="detailViolations" style="width: 100%" max-height="240" empty-text="暂无违规记录">
<el-table-column prop="course_name" label="课程名称" min-width="120" />
<el-table-column prop="course_id" label="课程ID" width="80" />
<el-table-column label="违规类型" width="120">
<template #default="{ row }">
<el-tag :type="getViolationTypeTag(row.violation_reason)" effect="dark">
{{ getViolationTypeText(row.violation_reason) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="违规时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.violation_time) }}
</template>
</el-table-column>
</el-table>
<!-- 团课记录 -->
<h4 style="margin-top: 20px; margin-bottom: 10px; color: #409EFF;">团课记录</h4>
<el-table v-loading="detailCoursesLoading" :data="detailCourses" style="width: 100%" max-height="240" empty-text="暂无团课记录">
<el-table-column prop="courseName" label="课程名称" min-width="120" />
<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>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -145,13 +237,14 @@
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 { coachApi, type Coach, type CoachCreateRequest, type CoachUpdateRequest, type GroupCourse, type ViolationCountItem, type ViolationRecord } 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 violationCounts = reactive<Record<number, number>>({})
const filteredData = computed(() => {
if (!searchKeyword.value) return dataSource.value
@@ -209,7 +302,24 @@ const courseStatusMap: Record<number, { text: string; tag: string }> = {
1: { text: '已取消', tag: 'info' },
2: { text: '已结束', tag: 'warning' },
3: { text: '进行中', tag: 'danger' },
4: { text: '超时', tag: 'info' },
5: { text: '教练缺席', tag: 'danger' },
6: { text: '自动结束', tag: 'info' },
7: { text: '教练迟到', tag: 'warning' },
}
// 违规类型映射
const violationTypeMap: Record<string, { text: string; tag: string }> = {
COACH_LATE: { text: '教练迟到', tag: 'warning' },
COACH_ABSENT: { text: '教练缺席', tag: 'danger' },
NOT_MANUAL_END: { text: '未手动结课', tag: 'info' },
}
function getViolationTypeText(reason: string) {
return violationTypeMap[reason]?.text || reason
}
function getViolationTypeTag(reason: string) {
return violationTypeMap[reason]?.tag || 'info'
}
function getCourseStatusText(status: number) {
@@ -224,6 +334,13 @@ const fetchData = async () => {
loading.value = true
try {
dataSource.value = await coachApi.getAll()
// 同步获取违规次数统计
const counts = await coachApi.getViolationCounts()
// 清空旧数据
Object.keys(violationCounts).forEach(key => delete violationCounts[Number(key)])
counts.forEach(item => {
violationCounts[item.coach_id] = item.count
})
} catch (error) {
handleApiError(error)
} finally {
@@ -333,6 +450,49 @@ const handleViewCourses = async (row: Coach) => {
}
}
// 违规次数标签颜色
function getViolationTag(coachId: number) {
const count = violationCounts[coachId] ?? 0
if (count === 0) return 'success'
if (count <= 2) return 'warning'
return 'danger'
}
// 违规行高亮
function getRowClass({ row }: { row: Coach }) {
const count = violationCounts[Number(row.id)] ?? 0
return count > 0 ? 'row--has-violation' : ''
}
// 详情弹窗
const detailDialogVisible = ref(false)
const detailCoach = ref<Coach | null>(null)
const detailViolations = ref<ViolationRecord[]>([])
const detailViolationsLoading = ref(false)
const detailCourses = ref<GroupCourse[]>([])
const detailCoursesLoading = ref(false)
const handleViewDetail = async (row: Coach) => {
detailCoach.value = row
detailDialogVisible.value = true
// 并行加载违规记录和团课记录
detailViolationsLoading.value = true
detailCoursesLoading.value = true
try {
const [violations, courses] = await Promise.all([
coachApi.getViolations(row.id),
coachApi.getCourses(row.id),
])
detailViolations.value = violations
detailCourses.value = courses
} catch (error) {
handleApiError(error)
} finally {
detailViolationsLoading.value = false
detailCoursesLoading.value = false
}
}
onMounted(() => {
fetchData()
})
@@ -353,4 +513,10 @@ onMounted(() => {
}
}
}
/* 有违规记录的教练行:左侧红色边框 + 浅色背景 */
:deep(.row--has-violation) {
border-left: 3px solid #F56C6C;
background-color: #fef0f0 !important;
}
</style>