Files
gym-manage/gym-manage-web/src/views/groupcourse/GroupCourseManagement.vue
T

941 lines
30 KiB
Vue

<template>
<div class="group-course-management">
<el-card>
<template #header>
<div class="card-header">
<div class="search-section">
<el-input
v-model="searchKeyword"
placeholder="搜索课程名称"
clearable
style="width: 240px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="searchStatus"
placeholder="课程状态"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option value="0" label="正常" />
<el-option value="1" label="已取消" />
<el-option value="2" label="已结束" />
<el-option value="3" label="进行中" />
<el-option value="5" label="教练缺席" />
<el-option value="6" label="自动结束" />
<el-option value="7" label="教练迟到" />
</el-select>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="fetchData" :loading="loading" circle>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<el-button type="primary" @click="handleAdd">
新增团课
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
<el-table-column label="封面" width="100">
<template #default="{ row }">
<div class="cover-cell">
<img
v-if="coverCache[row.coverImage]"
:src="coverCache[row.coverImage]"
class="cover-thumb"
@error="onCoverError(row)"
/>
<span v-else class="cover-no-data">No data</span>
</div>
</template>
</el-table-column>
<el-table-column prop="courseName" label="课程名称" min-width="140" />
<el-table-column label="课程类型" width="100">
<template #default="{ row }">
{{ getCourseTypeName(row.courseType) }}
</template>
</el-table-column>
<el-table-column label="教练" width="100">
<template #default="{ row }">
{{ getCoachDisplayName(row.coachId) || row.coachName || '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="statusMap[row.status]?.type || 'info'"
effect="dark"
style="font-weight: 500; font-size: 14px;"
>
{{ statusMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.actualStartTime || row.startTime) }}
</template>
</el-table-column>
<el-table-column prop="endTime" label="结束时间" width="170">
<template #default="{ row }">
{{ formatDateTime(row.actualEndTime || row.endTime) }}
</template>
</el-table-column>
<el-table-column prop="location" label="上课地点" width="120" />
<el-table-column label="人数" width="90">
<template #default="{ row }">
{{ realMemberCounts[row.id!] ?? row.currentMembers ?? 0 }}/{{ row.maxMembers || 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="info" link size="small" @click="showDetail(row)">
详情
</el-button>
<el-button type="success" link size="small" @click="showQrCode(row)">
二维码
</el-button>
<el-button
v-if="row.status === '0'"
type="warning"
link
size="small"
@click="handleCancel(row)"
>
取消
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="handleTableChange"
@size-change="handleSizeChange"
/>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="600px"
>
<el-form
ref="formRef"
:model="formState"
:rules="formRules"
label-width="120px"
>
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formState.courseName" placeholder="请输入课程名称" />
</el-form-item>
<el-form-item label="课程类型" prop="courseType">
<el-select v-model="formState.courseType" placeholder="请选择课程类型" style="width: 100%">
<el-option
v-for="type in courseTypes"
:key="type.id"
:value="type.id"
:label="type.typeName"
/>
</el-select>
</el-form-item>
<el-form-item label="教练" prop="coachId">
<el-select
v-model="formState.coachId"
placeholder="请选择教练"
clearable
style="width: 100%"
>
<el-option
v-for="coach in coaches"
:key="coach.id"
:value="coach.id"
:label="`${coach.nickname || coach.username} (${coach.username})`"
/>
</el-select>
<div v-if="coachConflictWarning" style="margin-top: 6px; color: #e6a23c; font-size: 12px; line-height: 1.5;">
<el-icon style="vertical-align: middle;"><WarningFilled /></el-icon>
{{ coachConflictWarning }}
</div>
<div v-if="coachConflictChecking" style="margin-top: 4px; color: #909399; font-size: 12px;">
正在检查教练排期...
</div>
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="formState.startTime"
type="datetime"
placeholder="选择开始时间"
value-format="YYYY-MM-DDTHH:mm:ss"
:disabled-date="notBeforeToday"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime" v-if="false">
<span />
</el-form-item>
<el-form-item label="持续时间(分钟)" prop="duration">
<el-input-number
v-model="formState.duration"
:min="1"
:max="1440"
placeholder="请输入课程持续时间"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="最大人数" prop="maxMembers">
<el-input-number v-model="formState.maxMembers" :min="1" :max="999" style="width: 100%" />
</el-form-item>
<el-form-item label="上课地点" prop="location">
<el-input v-model="formState.location" placeholder="请输入上课地点" />
</el-form-item>
<el-form-item label="封面图片">
<el-upload
class="cover-upload"
:http-request="uploadCover"
:before-upload="beforeCoverUpload"
:show-file-list="false"
accept="image/*"
>
<img v-if="coverPreviewSrc" :src="coverPreviewSrc" class="cover-preview" />
<el-icon v-else class="cover-upload-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item v-if="formState.id" label="课程状态" prop="status">
<el-select v-model="formState.status" placeholder="请选择状态" style="width: 100%">
<el-option value="0" label="正常" />
<el-option value="1" label="已取消" />
</el-select>
</el-form-item>
<el-form-item label="储值卡额度">
<el-input-number v-model="formState.storedValueAmount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="课程描述">
<el-input
v-model="formState.description"
type="textarea"
:rows="3"
placeholder="请输入课程描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" @click="handleModalOk">确定</el-button>
</template>
</el-dialog>
<!-- 签到二维码弹窗 -->
<el-dialog
v-model="qrDialogVisible"
:title="'签到二维码 - ' + currentCourseName"
width="420px"
center
>
<div class="qr-dialog-body">
<div v-loading="qrLoading" class="qr-image-wrapper">
<img
v-if="qrImageUrl"
:src="qrImageUrl"
class="qr-image"
alt="签到二维码"
/>
<el-empty v-else-if="!qrLoading" description="暂无二维码" />
</div>
<p class="qr-tip">扫码签到参加团课</p>
</div>
<template #footer>
<el-button @click="qrDialogVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!qrImageUrl" @click="downloadQrCode">
下载二维码
</el-button>
</template>
</el-dialog>
<!-- 团课详情弹窗 -->
<el-dialog
v-model="detailVisible"
title="团课详情"
width="640px"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
<el-descriptions-item label="课程名称">{{ detailRow.courseName }}</el-descriptions-item>
<el-descriptions-item label="课程类型">{{ getCourseTypeName(detailRow.courseType) }}</el-descriptions-item>
<el-descriptions-item label="教练">{{ getCoachDisplayName(detailRow.coachId) || detailRow.coachName || '-' }}</el-descriptions-item>
<el-descriptions-item label="课程状态">
<el-tag :type="statusMap[detailRow.status || '']?.type || 'info'" effect="dark" size="small">
{{ statusMap[detailRow.status || '']?.label || detailRow.status }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上课地点">{{ detailRow.location || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划开始时间">{{ formatDateTime(detailRow.startTime) }}</el-descriptions-item>
<el-descriptions-item label="实际开课时间">{{ formatDateTime(detailRow.actualStartTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="计划结束时间">{{ formatDateTime(detailRow.endTime) }}</el-descriptions-item>
<el-descriptions-item label="实际结课时间">{{ formatDateTime(detailRow.actualEndTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="人数">{{ realMemberCounts[detailRow.id!] ?? detailRow.currentMembers ?? 0 }} / {{ detailRow.maxMembers || 0 }}</el-descriptions-item>
<el-descriptions-item label="储值卡额度">{{ detailRow.storedValueAmount ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="封面图片">
<img v-if="detailRow.coverImage && coverCache[detailRow.coverImage]" :src="coverCache[detailRow.coverImage]" class="detail-cover" />
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="课程描述" :span="2">{{ detailRow.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建人">{{ detailRow.createBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新人">{{ detailRow.updateBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailRow.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detailRow.updatedAt) }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, WarningFilled, Refresh } from '@element-plus/icons-vue'
import { groupCourseApi, groupCourseTypeApi, groupCourseBookingApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
import { coachApi, type Coach } from '@/api/coach.api'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
const dataSource = ref<GroupCourse[]>([])
const searchKeyword = ref('')
const searchStatus = ref('')
const courseTypes = ref<GroupCourseType[]>([])
const coaches = ref<Coach[]>([])
const realMemberCounts = reactive<Record<number, number>>({})
const statusMap: Record<string, { type: string; label: string }> = {
'0': { type: 'success', label: '正常' },
'1': { type: 'danger', label: '已取消' },
'2': { type: 'info', label: '已结束' },
'3': { type: '', label: '进行中' },
'5': { type: 'danger', label: '教练缺席' },
'6': { type: 'warning', label: '自动结束' },
'7': { type: 'warning', label: '教练迟到' },
}
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
})
const sortInfo = reactive({
sortBy: 'id',
sortOrder: 'asc' as 'asc' | 'desc',
})
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
const coachConflictWarning = ref('')
const coachConflictChecking = ref(false)
const formState = reactive<GroupCourse>({
courseName: '',
courseType: undefined,
coachId: undefined,
startTime: '',
endTime: '',
maxMembers: 20,
location: '',
coverImage: '',
description: '',
storedValueAmount: 0,
status: '0',
})
const duration = ref(60)
// @ts-expect-error duration is a frontend-only field
formState.duration = duration
const formRules = {
courseName: [
{ required: true, message: '请输入课程名称', trigger: 'blur' },
{ min: 2, max: 100, message: '课程名称长度在 2 到 100 个字符', trigger: 'blur' },
],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
duration: [{ required: true, message: '请输入课程持续时间', trigger: 'blur' }],
maxMembers: [{ required: true, message: '请输入最大人数', trigger: 'blur' }],
}
const coverCache = reactive<Record<string, string>>({})
// ===== 详情弹窗 =====
const detailVisible = ref(false)
const detailRow = ref<GroupCourse>({} as GroupCourse)
const showDetail = (row: GroupCourse) => {
detailRow.value = row
detailVisible.value = true
if (row.coverImage) {
loadRowCover(row.coverImage)
}
}
const loadRowCover = async (coverImage: string | undefined) => {
if (!coverImage) return
// already loaded
if (coverCache[coverImage]) return
// Extract file ID from either /api/files/{id}/preview format or pure numeric string
const match = coverImage.match(/\/api\/files\/(\d+)\/preview$/)
const fileId = match ? match[1] : (/^\d+$/.test(coverImage) ? coverImage : null)
if (fileId) {
try {
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
coverCache[coverImage] = URL.createObjectURL(blob)
} catch {
// leave empty, template will show "No data"
}
} else {
// legacy URL — use directly, but may fail at load time
coverCache[coverImage] = coverImage
}
}
const onCoverError = (row: GroupCourse) => {
if (row.coverImage) {
delete coverCache[row.coverImage]
}
}
const fetchCourseTypes = async () => {
try {
courseTypes.value = await groupCourseTypeApi.getAll()
} catch {
// ignore
}
}
const fetchCoaches = async () => {
try {
coaches.value = await coachApi.getAll()
} catch {
// ignore
}
}
const getCourseTypeName = (typeId: number | undefined): string => {
if (typeId === undefined || typeId === null) return '-'
const type = courseTypes.value.find(t => t.id === typeId)
return type?.typeName || String(typeId)
}
const getCoachDisplayName = (coachId: number | string | undefined): string => {
if (coachId === undefined || coachId === null) return ''
const coach = coaches.value.find(c => String(c.id) === String(coachId))
if (!coach) return ''
return coach.nickname || coach.username
}
const getComputedEndTime = (): string => {
if (!formState.startTime || !(formState as any).duration) return ''
const start = new Date(formState.startTime)
if (isNaN(start.getTime())) return ''
start.setMinutes(start.getMinutes() + (formState as any).duration)
const pad = (n: number) => String(n).padStart(2, '0')
return `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
}
const checkCoachConflict = async () => {
const coachId = formState.coachId
const startTime = formState.startTime
const durationVal = (formState as any).duration
if (!coachId || !startTime || !durationVal) {
coachConflictWarning.value = ''
return
}
const endTime = getComputedEndTime()
if (!endTime) {
coachConflictWarning.value = ''
return
}
coachConflictChecking.value = true
try {
const res = await groupCourseApi.checkCoachConflict({
coachId,
startTime,
endTime,
excludeCourseId: formState.id,
})
if ((res as any).hasConflict && (res as any).conflicts?.length > 0) {
const names = (res as any).conflicts.map((c: any) => c.courseName || '未知课程').join('、')
coachConflictWarning.value = `该教练在此时间段已有团课:${names}`
} else {
coachConflictWarning.value = ''
}
} catch {
coachConflictWarning.value = ''
} finally {
coachConflictChecking.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
const res = await groupCourseApi.getPage({
page: pagination.current - 1,
size: pagination.pageSize,
sortBy: sortInfo.sortBy,
sortOrder: sortInfo.sortOrder,
keyword: searchKeyword.value || undefined,
status: searchStatus.value || undefined,
})
dataSource.value = res.content
pagination.total = Number(res.totalElements) || 0
// load cover images for all rows
res.content.forEach((row: GroupCourse) => loadRowCover(row.coverImage))
// 实时获取预约人数
fetchRealMemberCounts(res.content)
} catch {
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
const fetchRealMemberCounts = async (courses: GroupCourse[]) => {
// 批量获取每门课程的实时预约人数
const results = await Promise.allSettled(
courses.map(course =>
groupCourseBookingApi.getByCourseId(course.id!).then(bookings => ({
courseId: course.id!,
count: bookings.filter(b => b.status === '0' || b.status === '2').length,
}))
)
)
results.forEach(r => {
if (r.status === 'fulfilled') {
realMemberCounts[r.value.courseId] = r.value.count
}
})
}
const handleTableChange = () => fetchData()
const handleSizeChange = () => {
pagination.current = 1
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleSortChange = ({ prop, order }: any) => {
sortInfo.sortBy = prop
sortInfo.sortOrder = order === 'ascending' ? 'asc' : 'desc'
fetchData()
}
const beforeCoverUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
const notBeforeToday = (date: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
const uploadCover = async (options: any) => {
const formData = new FormData()
formData.append('file', options.file)
try {
const res: any = await request.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
// res is the SysFile object (axios interceptor unwrapped response.data)
if (res && res.id) {
formState.coverImage = `/api/files/${res.id}/preview`
// immediately fetch preview for the newly uploaded file
loadCoverPreview(formState.coverImage)
}
ElMessage.success('封面上传成功')
} catch {
ElMessage.error('封面上传失败')
}
}
const coverPreviewSrc = ref('')
// ===== 二维码弹窗相关 =====
const qrDialogVisible = ref(false)
const qrLoading = ref(false)
const qrImageUrl = ref('')
const currentCourseName = ref('')
/**
* 从 qrCodePath 中提取文件 ID
* 支持格式:/api/files/preview/{id} 或旧版 OSS URL
*/
const extractFileId = (path: string | undefined): string | null => {
if (!path) return null
// 新格式: /api/files/{id}/preview
const match = path.match(/\/files\/(\d+)\/preview/)
if (match) return match[1]
// 旧格式 OSS URL: 直接作为图片 URL 使用
if (path.startsWith('http')) return path
// 纯数字 ID
if (/^\d+$/.test(path)) return path
return null
}
/**
* 显示团课签到二维码
*/
const showQrCode = async (row: GroupCourse) => {
currentCourseName.value = row.courseName
qrDialogVisible.value = true
qrLoading.value = true
qrImageUrl.value = ''
const fileId = extractFileId(row.qrCodePath)
if (!fileId) {
qrLoading.value = false
return
}
try {
if (fileId.startsWith('http')) {
// 旧版 OSS URL,直接使用
qrImageUrl.value = fileId
} else {
// 新版文件 ID,通过预览接口获取
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
// 释放旧 URL
if (qrImageUrl.value) URL.revokeObjectURL(qrImageUrl.value)
qrImageUrl.value = URL.createObjectURL(blob)
}
} catch {
ElMessage.error('获取二维码失败')
} finally {
qrLoading.value = false
}
}
/**
* 下载二维码图片
*/
const downloadQrCode = () => {
if (!qrImageUrl.value) return
const a = document.createElement('a')
a.href = qrImageUrl.value
a.download = `签到二维码_${currentCourseName.value}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
ElMessage.success('二维码下载成功')
}
const loadCoverPreview = async (val: string) => {
if (!val) {
coverPreviewSrc.value = ''
return
}
// Extract file ID from either /api/files/{id}/preview format or pure numeric string
const match = val.match(/\/api\/files\/(\d+)\/preview$/)
const fileId = match ? match[1] : (/^\d+$/.test(val) ? val : null)
if (fileId) {
try {
const blob: any = await request.get(`/files/${fileId}/preview`, { responseType: 'blob' })
const oldUrl = coverPreviewSrc.value
if (oldUrl) URL.revokeObjectURL(oldUrl)
coverPreviewSrc.value = URL.createObjectURL(blob)
} catch {
coverPreviewSrc.value = ''
}
} else {
// Legacy: direct URL
coverPreviewSrc.value = val
}
}
// Watch coverImage changes to auto-load preview (e.g. when editing an existing course)
watch(() => formState.coverImage, (val) => loadCoverPreview(val || ''), { immediate: true })
// 监听教练选择和时间变化,检查冲突
watch(() => [formState.coachId, formState.startTime, (formState as any).duration] as const, () => {
checkCoachConflict()
}, { deep: false })
// 打开弹窗时清除冲突提示
watch(modalVisible, (val) => {
if (val) {
coachConflictWarning.value = ''
}
})
const handleAdd = () => {
modalTitle.value = '新增团课'
Object.assign(formState, {
id: undefined,
courseName: '',
courseType: undefined,
coachId: undefined,
startTime: '',
endTime: '',
maxMembers: 20,
location: '',
coverImage: '',
description: '',
storedValueAmount: 0,
})
formState.duration = 60
modalVisible.value = true
}
const handleEdit = (row: GroupCourse) => {
modalTitle.value = '编辑团课'
Object.assign(formState, { ...row })
// Calculate duration from startTime and endTime
if (row.startTime && row.endTime) {
const start = new Date(row.startTime)
const end = new Date(row.endTime)
formState.duration = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))
}
modalVisible.value = true
}
const handleCancel = async (row: GroupCourse) => {
try {
await ElMessageBox.confirm('确定要取消该团课吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await groupCourseApi.cancel(row.id!)
ElMessage.success('取消成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('取消失败')
}
}
}
const handleDelete = async (row: GroupCourse) => {
try {
await ElMessageBox.confirm('确定要删除该团课吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await groupCourseApi.delete(row.id!)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// Auto-calculate endTime from startTime + duration
if (formState.startTime && (formState as any).duration) {
const start = new Date(formState.startTime)
start.setMinutes(start.getMinutes() + (formState as any).duration)
const pad = (n: number) => String(n).padStart(2, '0')
formState.endTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
}
// 最终教练冲突检查
if (formState.coachId && formState.startTime && formState.endTime) {
try {
const res = await groupCourseApi.checkCoachConflict({
coachId: formState.coachId,
startTime: formState.startTime,
endTime: formState.endTime,
excludeCourseId: formState.id,
})
if ((res as any).hasConflict && (res as any).conflicts?.length > 0) {
const names = (res as any).conflicts.map((c: any) => c.courseName || '未知课程').join('、')
await ElMessageBox.confirm(
`该教练在此时间段已有团课:${names},确定继续吗?`,
'时间冲突警告',
{ confirmButtonText: '继续保存', cancelButtonText: '取消', type: 'warning' }
)
// 用户点击了"继续保存",继续执行
}
} catch (e) {
// 用户取消了冲突确认对话框,中止保存
return
}
}
if (formState.id) {
await groupCourseApi.update(formState.id, formState)
} else {
await groupCourseApi.create(formState)
}
ElMessage.success('操作成功')
modalVisible.value = false
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await Promise.all([fetchCourseTypes(), fetchCoaches()])
fetchData()
// 每30秒自动刷新列表,捕获调度器状态变更
refreshTimer = setInterval(() => {
fetchData()
}, 30000)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<style scoped lang="css">
.group-course-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.search-section {
display: flex;
gap: 8px;
align-items: center;
}
}
}
.cover-upload :deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
width: 180px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.3s;
}
.cover-upload :deep(.el-upload:hover) {
border-color: #409eff;
}
.cover-preview {
width: 180px;
height: 120px;
object-fit: cover;
border-radius: 6px;
}
.cover-upload-icon {
font-size: 28px;
color: #8c939d;
}
.cover-cell {
display: flex;
align-items: center;
justify-content: center;
}
.cover-thumb {
width: 64px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
.cover-no-data {
color: #c0c4cc;
font-size: 12px;
}
/* 二维码弹窗样式 */
.qr-dialog-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
}
.qr-image-wrapper {
width: 260px;
height: 260px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafafa;
overflow: hidden;
}
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.qr-tip {
margin-top: 16px;
color: #909399;
font-size: 14px;
}
.detail-cover {
width: 120px;
height: 80px;
object-fit: cover;
border-radius: 4px;
}
</style>