完善团课相关管理

This commit is contained in:
2026-07-08 17:02:04 +08:00
parent 1e41f31271
commit f5b5724e92
23 changed files with 979 additions and 122 deletions
@@ -45,8 +45,25 @@
@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 prop="courseType" label="课程类型" width="100" />
<el-table-column label="课程类型" width="100">
<template #default="{ row }">
{{ getCourseTypeName(row.courseType) }}
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
@@ -115,7 +132,7 @@
ref="formRef"
:model="formState"
:rules="formRules"
label-width="100px"
label-width="120px"
>
<el-form-item label="课程名称" prop="courseName">
<el-input v-model="formState.courseName" placeholder="请输入课程名称" />
@@ -135,16 +152,20 @@
v-model="formState.startTime"
type="datetime"
placeholder="选择开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DDTHH:mm:ss"
:disabled-date="notBeforeToday"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="formState.endTime"
type="datetime"
placeholder="选择结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
<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>
@@ -155,7 +176,22 @@
<el-input v-model="formState.location" placeholder="请输入上课地点" />
</el-form-item>
<el-form-item label="封面图片">
<el-input v-model="formState.coverImage" placeholder="请输入封面图片URL" />
<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%" />
@@ -178,10 +214,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Search, Plus } from '@element-plus/icons-vue'
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
import request from '@/utils/request'
import { formatDateTime } from '@/utils/dateFormat'
const loading = ref(false)
@@ -222,18 +259,49 @@ const formState = reactive<GroupCourse>({
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' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
duration: [{ required: true, message: '请输入课程持续时间', trigger: 'blur' }],
maxMembers: [{ required: true, message: '请输入最大人数', trigger: 'blur' }],
}
const coverCache = reactive<Record<string, string>>({})
const loadRowCover = async (coverImage: string | undefined) => {
if (!coverImage) return
// already loaded
if (coverCache[coverImage]) return
// if it's a numeric file ID, fetch via preview API
if (/^\d+$/.test(coverImage)) {
try {
const blob: any = await request.get(`/files/${coverImage}/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()
@@ -242,6 +310,12 @@ const fetchCourseTypes = async () => {
}
}
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 fetchData = async () => {
loading.value = true
try {
@@ -255,6 +329,8 @@ const fetchData = async () => {
})
dataSource.value = res.content
pagination.total = Number(res.totalElements) || 0
// load cover images for all rows
res.content.forEach((row: GroupCourse) => loadRowCover(row.coverImage))
} catch {
ElMessage.error('加载数据失败')
} finally {
@@ -278,6 +354,71 @@ const handleSortChange = ({ prop, order }: any) => {
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 = String(res.id)
// immediately fetch preview for the newly uploaded file
loadCoverPreview(String(res.id))
}
ElMessage.success('封面上传成功')
} catch {
ElMessage.error('封面上传失败')
}
}
const coverPreviewSrc = ref('')
const loadCoverPreview = async (val: string) => {
if (!val) {
coverPreviewSrc.value = ''
return
}
// If it looks like a numeric file ID, fetch via preview API
if (/^\d+$/.test(val)) {
try {
const blob: any = await request.get(`/files/${val}/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 })
const handleAdd = () => {
modalTitle.value = '新增团课'
Object.assign(formState, {
@@ -292,12 +433,19 @@ const handleAdd = () => {
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
}
@@ -339,6 +487,13 @@ const handleModalOk = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// Auto-calculate endTime from startTime + duration
if (formState.startTime && formState.duration) {
const start = new Date(formState.startTime)
start.setMinutes(start.getMinutes() + formState.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.id) {
await groupCourseApi.update(formState.id, formState)
} else {
@@ -375,4 +530,50 @@ onMounted(() => {
}
}
}
.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;
}
</style>