完善团课相关管理
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user