941 lines
30 KiB
Vue
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>
|